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.
116 lines
4.2 KiB
Python
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())
|