Files
musicmouse/python-backend/musicmouse/library/scanner.py
Martin Bauer 69119bb72a Add per-episode podcast cover art and fix nested album grouping
- podcast_feeds.py: fetch per-episode cover art from a feed's itunes:image
  (when it's genuinely distinct from the channel image) or, failing that,
  from the og:image on the episode's own linked page; extract audio from
  video-only enclosures via ffmpeg; match podcast-dl's filename convention
  (illegal characters become "_" instead of being dropped) so enabling
  feed.txt on an already-downloaded show doesn't re-download its back catalog
- scanner.py: _cover_for_episode now checks for a same-stem sidecar cover
  image before falling back to embedded ID3 art and the shared folder cover
- scanner.py/__init__.py: fixed a bug where an album folder nested one level
  deeper than usual (an age-range grouping folder, say) was mistaken for an
  empty album and skipped; added periodic progress logging for long scans
  and analysis passes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 09:54:33 +02:00

360 lines
13 KiB
Python

"""Turning folders on disk into :class:`~musicmouse.library.models.Album` objects.
One level under each section folder, every directory holding audio files is one album.
Tags come from mutagen; where a section's tags are known to be useless the folder name
wins instead (see :mod:`musicmouse.library.sections`). Nothing here raises on bad
input - an unreadable file loses its metadata, not the boot.
"""
from __future__ import annotations
import logging
import time
from collections import Counter
from collections.abc import Iterator, Mapping
from pathlib import Path
from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.colors import colors_from_cover, colors_from_id
from musicmouse.library.models import Album, LibraryTrack, album_id
from musicmouse.library.sections import SECTIONS, AlbumKind, Section
_log = logging.getLogger(__name__)
__all__ = ["scan_library"]
#: Checked in order for a cover sitting next to the audio.
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
#: Extensions tried for an episode's own same-stem sidecar cover image (see
#: :func:`_cover_for_episode`), matching what `podcast_feeds.py`'s
#: ``episode_cover_filename`` can produce.
_SIDECAR_COVER_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp")
#: How often a long scan reports where it is, so a library of thousands of albums
#: doesn't sit silent for minutes with nothing on the console to say it is still going.
_PROGRESS_INTERVAL_SECONDS = 5.0
def _album_folders(folder: Path, extensions: frozenset[str]) -> Iterator[Path]:
"""Every leaf album folder under ``folder``, however deep it is nested.
A folder that holds audio files directly *is* an album - an artist who groups
their books under an extra "ab 3" / "ab 5" age-range folder, or a series folder
that groups its episodes one directory further down than usual, still bottoms out
here without needing to be special-cased. Only descended into when a folder holds
no audio of its own, so an ordinary album folder is never mistaken for a grouping
folder just because it also happens to contain subfolders.
"""
if _audio_files(folder, extensions):
yield folder
return
for sub in sorted(folder.iterdir(), key=lambda path: path.name):
if sub.is_dir() and not sub.name.startswith("."):
yield from _album_folders(sub, extensions)
def _audio_files(folder: Path, extensions: frozenset[str]) -> list[Path]:
"""The playable files in ``folder``, alphabetically.
Dotfiles are skipped outright and everything else must match ``audio_extensions``,
which is what keeps a podcast folder's ``archive.json`` and its half-finished
``.podcast-dl-*.download.tmp`` out of the playlist.
"""
return sorted(
(
path
for path in folder.iterdir()
if path.is_file()
and not path.name.startswith(".")
and path.suffix.lower() in extensions
),
key=lambda path: path.name,
)
def _tags(path: Path) -> tuple[dict[str, str], float]:
"""``(tags, duration)`` for one file. Empty and ``0.0`` when it cannot be read."""
try:
import mutagen
audio = mutagen.File(path, easy=True)
if audio is None:
return {}, 0.0
tags = {key: values[0] for key, values in dict(audio).items() if values}
return tags, float(getattr(audio.info, "length", 0.0) or 0.0)
except Exception: # pragma: no cover - mutagen raises freely on damaged files
_log.debug("No readable tags in %s", path)
return {}, 0.0
def _embedded_art(path: Path) -> bytes | None:
try:
import mutagen
raw = mutagen.File(path)
if raw is None or raw.tags is None:
return None
for key in raw.tags:
if key.startswith("APIC"):
data: bytes = raw.tags[key].data
return data
except Exception: # pragma: no cover - defensive
_log.debug("No readable embedded art in %s", path)
return None
def _split_folder_name(name: str) -> tuple[str, str]:
"""``"Conni - Conni in den Bergen"`` -> ``("Conni", "Conni in den Bergen")``."""
artist, separator, title = name.partition(" - ")
return (artist, title) if separator else ("", name)
def _most_common(values: list[str]) -> str:
"""The tag value most files in an album agree on, ignoring blanks."""
counted = Counter(value for value in values if value)
return counted.most_common(1)[0][0] if counted else ""
def _cover_for(
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
) -> tuple[Path | None, bytes | None]:
for name in _COVER_NAMES:
candidate = folder / name
if candidate.is_file():
return candidate, candidate.read_bytes()
for path in paths[:3]:
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art
return None, None
def _cover_for_episode(
folder: Path, path: Path, identifier: str, cache: LibraryCache
) -> tuple[Path | None, bytes | None]:
"""The reverse priority from :func:`_cover_for`: with one album per episode, art
specific to this one episode wins over the folder's shared cover. A same-stem
sidecar image (what ``podcast_feeds.py`` saves when a feed has real per-episode
art) is tried first - it's a plain file stat, cheaper than reading tags - then the
episode's own embedded art, then the folder's shared cover as the last resort for
an episode with neither."""
for extension in _SIDECAR_COVER_EXTENSIONS:
candidate = path.with_suffix(extension)
if candidate.is_file():
return candidate, candidate.read_bytes()
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art
for name in _COVER_NAMES:
candidate = folder / name
if candidate.is_file():
return candidate, candidate.read_bytes()
return None, None
def scan_album(
folder: Path,
*,
root: Path,
section_name: str,
section: Section,
extensions: frozenset[str],
cache: LibraryCache,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> tuple[Album, Fingerprint] | None:
paths = _audio_files(folder, extensions)
if not paths:
_log.debug("No audio files in %s", folder)
return None
if section.order == "newest_first":
paths.reverse()
fingerprint = Fingerprint.of(paths)
identifier = album_id(root, folder)
tracks: list[LibraryTrack] = []
albums: list[str] = []
artists: list[str] = []
for path in paths:
tags, duration = _tags(path)
tracks.append(
LibraryTrack(path=path, title=tags.get("title") or path.stem, duration=duration)
)
albums.append(tags.get("album", ""))
artists.append(tags.get("albumartist") or tags.get("artist", ""))
figure = folder.name if section.figures else None
# Every other shelf is named after what is on it. A figure folder is named after the
# figurine, so its media type has to be declared in the config.
kind = (figure_kinds or {}).get(figure, "music") if figure else section.kind
folder_artist, folder_title = _split_folder_name(folder.name)
if section.title_from == "folder":
# A figure folder is named in lowercase ("fuchs"); it sits next to real album
# titles in the browse grid, so give it a capital.
title = folder.name[:1].upper() + folder.name[1:] if section.figures else folder.name
else:
title = _most_common(albums) or folder_title or folder.name
if section.artist_from == "folder":
artist = folder.name
else:
artist = _most_common(artists) or folder_artist
cover, art = _cover_for(folder, paths, identifier, cache)
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
album = Album(
id=identifier,
section=section_name,
kind=kind,
title=title,
artist=artist,
# Books group by who or what they are about; music groups by artist. The browse
# view's category row is built straight off this, so it takes only the part
# before the first comma - an ``album_artist`` of "Bobo Siebenschlaefer, Markus
# Osterwalder, ..." is a credit list whose first name is the character.
series=artist.split(",")[0].strip() if kind == "book" else None,
figure=figure,
colors=colors,
folder=folder,
cover=cover,
tracks=tuple(tracks),
)
return album, fingerprint
def scan_episodes(
folder: Path,
*,
root: Path,
section_name: str,
section: Section,
extensions: frozenset[str],
cache: LibraryCache,
known: dict[str, tuple[Album, Fingerprint]],
) -> dict[str, tuple[Album, Fingerprint]]:
"""One album per audio file, for a section whose folder is a show rather than a
single release - a podcast feed's hundreds of episodes, most obviously.
Fingerprinted per file rather than per folder, so a new episode landing in an
already-scanned show only costs scanning that one file, not the whole show.
"""
paths = _audio_files(folder, extensions)
if section.order == "newest_first":
paths.reverse()
out: dict[str, tuple[Album, Fingerprint]] = {}
for path in paths:
identifier = album_id(root, path)
fingerprint = Fingerprint.of([path])
cached = known.get(identifier)
if cached is not None and cached[1] == fingerprint:
out[identifier] = cached
continue
tags, duration = _tags(path)
title = tags.get("title") or path.stem
cover, art = _cover_for_episode(folder, path, identifier, cache)
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
album = Album(
id=identifier,
section=section_name,
kind=section.kind,
title=title,
artist=folder.name,
# The folder is the show; every episode in it groups under the same series,
# exactly like an audiobook's chapters group under its book.
series=folder.name,
figure=None,
colors=colors,
folder=folder,
cover=cover,
tracks=(LibraryTrack(path=path, title=title, duration=duration),),
)
out[identifier] = (album, fingerprint)
return out
def scan_library(
root: Path,
extensions: frozenset[str],
cache: LibraryCache,
*,
known: dict[str, tuple[Album, Fingerprint]] | None = None,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> dict[str, tuple[Album, Fingerprint]]:
"""Scan every section under ``root``.
``known`` is the previously cached index: a folder whose files, sizes and mtimes are
unchanged is taken from it without a single tag being read. ``figure_kinds`` maps a
figure name to what it holds, which is the one thing the folders cannot say.
"""
cache.prepare()
known = known or {}
out: dict[str, tuple[Album, Fingerprint]] = {}
last_report = time.monotonic()
for section_name, section in SECTIONS.items():
section_root = root / section_name
if not section_root.is_dir():
_log.warning("Library section %r has no folder at %s", section_name, section_root)
continue
for top in sorted(section_root.iterdir(), key=lambda path: path.name):
if not top.is_dir() or top.name.startswith("."):
continue
if section.album_unit == "episode":
# Fingerprinted per episode inside scan_episodes; the whole-folder
# shortcut below does not apply since one folder yields many albums,
# and an episode show is never nested deeper than this either.
out.update(
scan_episodes(
top,
root=root,
section_name=section_name,
section=section,
extensions=extensions,
cache=cache,
known=known,
)
)
continue
for folder in _album_folders(top, extensions):
now = time.monotonic()
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
_log.info(
"Scanning library: %d albums found so far, now in %s",
len(out),
folder.relative_to(root),
)
last_report = now
identifier = album_id(root, folder)
cached = known.get(identifier)
if cached is not None:
paths = _audio_files(folder, extensions)
if paths and Fingerprint.of(paths) == cached[1]:
out[identifier] = cached
continue
scanned = scan_album(
folder,
root=root,
section_name=section_name,
section=section,
extensions=extensions,
cache=cache,
figure_kinds=figure_kinds,
)
if scanned is not None:
out[identifier] = scanned
_log.info("Library: %d albums under %s", len(out), root)
return out