#!/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/`` 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())