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.
This commit is contained in:
115
python-backend/scripts/fetch_podcast_covers.py
Normal file
115
python-backend/scripts/fetch_podcast_covers.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user