Files
musicmouse/python-backend/scripts/fetch_podcast_covers.py
Martin Bauer 2e0e6ad199 Add librosa beat/mood analysis and an Ambience background driven by it
The background worker now runs a real librosa analyzer (tempo, beat grid,
per-second energy/valence curves) instead of only the null baseline, kept
behind build_analyzer() so a plain checkout without the analysis extra still
runs fine. The web player reads that per-track analysis and drives a new
animated "Ambience" background (bubbles, colour, current) that reacts to the
beat and the mood curve as the track plays, plus a debug overlay for tuning
it. Also adds a one-off script to backfill podcast cover art from iTunes.
2026-09-10 22:42:51 +02:00

116 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""One-off: backfill cover art for the podcast shows already in the library.
The scanner now reads a podcast episode's own embedded art first, and a show folder's
``cover.jpg`` second (see ``musicmouse.library.scanner._cover_for_episode``) - but
episodes downloaded before today rarely carry per-episode art, and none of the shows
have a folder-level cover on disk yet. This script fills that gap once, by hand, so it
is not something the running app does on its own.
For every ``Kinderpodcasts/<show>`` folder that has no ``cover.jpg``/``.jpeg``/``.png``
already, it looks the show up on the public iTunes Search API and saves the top match's
artwork as ``cover.jpg`` in that folder. A show it cannot match confidently is left
alone and logged, rather than guessed at - check those by hand afterwards.
This makes real network requests to a third-party service and writes into the real
music library, so it is meant to be run and reviewed by a person, not called from the
app:
python scripts/fetch_podcast_covers.py --config /path/to/config.yml
python scripts/fetch_podcast_covers.py --config /path/to/config.yml --dry-run
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from musicmouse.config import load_config
_log = logging.getLogger("fetch_podcast_covers")
#: Mirrors scanner.py's `_COVER_NAMES` - what counts as "already has a cover".
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
_ITUNES_SEARCH = "https://itunes.apple.com/search"
_TIMEOUT = 10.0
def _has_cover(folder: Path) -> bool:
return any((folder / name).is_file() for name in _COVER_NAMES)
def _find_artwork_url(show_name: str) -> str | None:
query = urllib.parse.urlencode({"media": "podcast", "term": show_name, "limit": 1})
try:
with urllib.request.urlopen(f"{_ITUNES_SEARCH}?{query}", timeout=_TIMEOUT) as response:
body = json.loads(response.read())
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
_log.warning("Lookup for %r failed: %s", show_name, error)
return None
results = body.get("results") or []
if not results:
return None
# iTunes serves a 100x100 thumbnail by default; ask for something worth showing.
artwork = results[0].get("artworkUrl100")
return artwork.replace("100x100", "600x600") if artwork else None
def backfill(root: Path, *, dry_run: bool) -> None:
podcasts_root = root / "Kinderpodcasts"
if not podcasts_root.is_dir():
_log.warning("No Kinderpodcasts folder at %s", podcasts_root)
return
for folder in sorted(podcasts_root.iterdir()):
if not folder.is_dir() or folder.name.startswith("."):
continue
if _has_cover(folder):
_log.info("%-40s already has a cover, skipping", folder.name)
continue
url = _find_artwork_url(folder.name)
if url is None:
_log.warning("%-40s no confident match - fetch this one by hand", folder.name)
continue
_log.info("%-40s -> %s", folder.name, url)
if dry_run:
continue
try:
with urllib.request.urlopen(url, timeout=_TIMEOUT) as response:
data = response.read()
except (urllib.error.URLError, TimeoutError) as error:
_log.warning("%-40s download failed: %s", folder.name, error)
continue
(folder / "cover.jpg").write_bytes(data)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
parser.add_argument(
"--dry-run",
action="store_true",
help="print what would be fetched without downloading or writing anything",
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = load_config(args.config)
backfill(config.general.library.root, dry_run=args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())