Files
musicmouse/python-backend/tests/test_library.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

584 lines
22 KiB
Python

"""The library scan: what ends up in the index, and what stays out of it."""
from __future__ import annotations
import asyncio
import contextlib
import json
import threading
from collections.abc import Callable
from pathlib import Path
import pytest
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
from musicmouse.library import Album, Analyzer, MusicLibrary
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.cache import LibraryCache
from musicmouse.library.colors import colors_from_id
from musicmouse.library.models import album_id, track_key
from tests.conftest import VALID_CONFIG, write_config, write_track
EXTENSIONS = frozenset(DEFAULT_AUDIO_EXTENSIONS)
async def build(config_dir: Path, *, analyzer: Analyzer | None = None) -> MusicLibrary:
config = load_config(write_config(config_dir, VALID_CONFIG))
return await MusicLibrary.build(
config.general.library.root,
config.general.library.cache,
EXTENSIONS,
analyzer=analyzer,
figure_kinds=config.figure_kinds,
)
class FakeAnalyzer:
"""An `Analyzer` that does no DSP, so the worker's own behaviour - which tracks it
touches, how it reacts to `is_busy`, what a raising analyzer does to the batch - can
be tested without librosa."""
def __init__(self, *, version: int = ANALYZER_VERSION, fails: set[str] | None = None) -> None:
self.version = version
self._fails = fails or set()
self.calls: list[Path] = []
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
self.calls.append(path)
if path.name in self._fails:
raise RuntimeError(f"boom: {path.name}")
analysis = TrackAnalysis(version=self.version, tempo=100.0, energy=0.5)
curves = TrackCurves(hop_seconds=1.0, energy=(0.5,), valence=(0.5,), drive=(0.5,))
return analysis, BeatGrid((0.1,), (1.0,)), curves
def album_named(library: MusicLibrary, title: str) -> Album:
return next(album for album in library.albums if album.title == title)
# ------------------------------------------------------------------------ structure
async def test_every_section_is_scanned(config_dir: Path) -> None:
library = await build(config_dir)
assert {album.title for album in library.albums} == {
"Fuchs",
"Eule",
"Kinderparty Lieder",
"Conni in den Bergen",
"Alt",
"Neu",
}
async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> None:
library = await build(config_dir)
playlists = library.figure_playlists()
assert set(playlists) == {"fuchs", "eule"}
assert len(playlists["fuchs"]) == 3
assert [track.path.name for track in playlists["fuchs"].tracks] == [
"00 - track.mp3",
"01 - track.mp3",
"02 - track.mp3",
]
# The playlist carries the album it came from, which is how a front-end answers
# "what is playing?" without keeping its own copy of that state.
assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Kinderparty Lieder")
assert album.kind == "music"
assert album.artist == "Kinderparty"
# Music groups by artist, so it has no series.
assert album.series is None
assert album.category == "Kinderparty"
async def test_audiobooks_group_by_the_name_before_the_comma(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Conni in den Bergen")
assert album.kind == "book"
# album_artist is a credit list; only the first name is the character.
assert album.artist == "Conni, Julia Boehme"
assert album.series == "Conni"
assert album.category == "Conni"
async def test_podcast_episodes_are_their_own_albums_grouped_by_the_show(
config_dir: Path,
) -> None:
"""One podcast mp3 behaves like a whole audiobook: its own album, its own cover,
rather than a chapter buried in one album named after the entire feed."""
library = await build(config_dir)
episodes = [a for a in library.albums if a.category == "Wissen macht Ah"]
assert {a.title for a in episodes} == {"Alt", "Neu"}
# The tags say album="Wissen macht Ah! - Podcast" and artist=<six presenters> -
# neither groups usefully, so the folder (the show) wins for artist/series, same
# as it always did; only the *title* now comes from the episode's own tags.
assert all(a.kind == "book" for a in episodes)
assert all(a.artist == "Wissen macht Ah" for a in episodes)
assert all(a.series == "Wissen macht Ah" for a in episodes)
assert all(len(a.tracks) == 1 for a in episodes)
async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None:
library = await build(config_dir)
episodes = [a for a in library.albums if a.category == "Wissen macht Ah"]
assert [a.title for a in episodes] == ["Neu", "Alt"]
async def test_other_sections_keep_filename_order(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Kinderparty Lieder")
assert [track.path.name for track in album.tracks] == [
"00 - lied.mp3",
"01 - lied.mp3",
]
async def test_figures_are_marked_and_others_are_not(config_dir: Path) -> None:
library = await build(config_dir)
assert album_named(library, "Fuchs").figure == "fuchs"
assert album_named(library, "Kinderparty Lieder").figure is None
async def test_durations_come_from_the_files(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Eule")
assert all(track.duration > 0 for track in album.tracks)
assert album.duration == pytest.approx(sum(t.duration for t in album.tracks))
# -------------------------------------------------------------------- what is skipped
async def test_scratch_files_never_reach_a_playlist(config_dir: Path) -> None:
library = await build(config_dir)
episodes = [a for a in library.albums if a.category == "Wissen macht Ah"]
names = {episode.tracks[0].path.name for episode in episodes}
assert "archive.json" not in names
assert not any(name.startswith(".") for name in names)
assert len(episodes) == 2
async def test_unknown_top_level_folders_are_ignored(config_dir: Path) -> None:
stray = config_dir / "music" / "Sonstiges" / "Irgendwas"
write_track(stray / "01 - x.mp3", title="X")
library = await build(config_dir)
assert not any(album.section == "Sonstiges" for album in library.albums)
async def test_a_missing_section_warns_rather_than_failing(
config_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
import shutil
shutil.rmtree(config_dir / "music" / "Musik")
library = await build(config_dir)
assert "Musik" in caplog.text
assert len(library.albums) == 5
async def test_an_empty_album_folder_is_not_an_album(config_dir: Path) -> None:
(config_dir / "music" / "Musik" / "Leer").mkdir()
library = await build(config_dir)
assert not any(album.title == "Leer" for album in library.albums)
# ------------------------------------------------------------------------- colours
async def test_every_album_has_three_colours(config_dir: Path) -> None:
library = await build(config_dir)
for album in library.albums:
assert len(album.colors) == 3
assert all(colour.startswith("#") and len(colour) == 7 for colour in album.colors)
async def test_colours_are_synthesised_when_there_is_no_cover(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Fuchs")
assert album.cover is None
assert album.colors == colors_from_id(album.id)
async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None:
from PIL import Image
folder = config_dir / "music" / "Musik" / "Kinderparty - Kinderparty Lieder"
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
album = album_named(await build(config_dir), "Kinderparty Lieder")
assert album.cover == folder / "cover.jpg"
# A solid red cover has one usable colour; the rest fall back to the synthesised
# palette rather than repeating it.
assert album.colors[0] != colors_from_id(album.id)[0]
# --------------------------------------------------------------------------- cache
async def test_a_second_build_reuses_the_index(config_dir: Path) -> None:
first = await build(config_dir)
second = await build(config_dir)
assert {a.id for a in first.albums} == {a.id for a in second.albums}
assert (config_dir / ".cache" / "index.json").is_file()
async def test_a_changed_folder_is_rescanned(config_dir: Path) -> None:
library = await build(config_dir)
assert len(album_named(library, "Eule").tracks) == 2
write_track(config_dir / "music" / "Figuren" / "eule" / "99 - neu.mp3", title="Neu")
library = await build(config_dir)
assert len(album_named(library, "Eule").tracks) == 3
async def test_a_new_episode_only_costs_scanning_that_one_file(config_dir: Path) -> None:
"""The point of fingerprinting a podcast per episode rather than per folder: a
show's other, untouched episodes come straight out of the cache, ids and all."""
first = await build(config_dir)
before = {a.id: a for a in first.albums if a.category == "Wissen macht Ah"}
assert len(before) == 2
write_track(
config_dir / "music" / "Kinderpodcasts" / "Wissen macht Ah" / "20260201 - Neuer.mp3",
title="Neuer",
album="Wissen macht Ah! - Podcast",
albumartist="Ein Name, Noch Einer, Und Einer",
)
second = await build(config_dir)
after = {a.id: a for a in second.albums if a.category == "Wissen macht Ah"}
assert len(after) == 3
# The two pre-existing episodes kept their identity across the rescan.
assert set(before) <= set(after)
async def test_a_rescan_leaves_analysis_alone(config_dir: Path) -> None:
"""The whole reason the cache is a directory rather than one file.
"Kinderparty Lieder" rather than "Eule": folding analysis back into the index is
restricted to music (see `_ANALYZED_KINDS`), since only music is ever analyzed - a
book's `analysis/` file, if one somehow existed, is not something a rescan need
resurface.
"""
library = await build(config_dir)
album = album_named(library, "Kinderparty Lieder")
from musicmouse.library.models import track_key
key = track_key(album.tracks[0].path)
library.cache.store_analysis(key, TrackAnalysis(version=ANALYZER_VERSION, tempo=128.0))
library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
curve = TrackCurves(hop_seconds=1.0, energy=(0.4, 0.6), valence=(0.5, 0.5), drive=(0.3, 0.7))
library.cache.store_curve(key, curve)
# Force a full rescan by dropping the cheap part of the cache.
(config_dir / ".cache" / "index.json").unlink()
library = await build(config_dir)
album = album_named(library, "Kinderparty Lieder")
assert album.tracks[0].analysis is not None
assert album.tracks[0].analysis.tempo == 128.0
grid = library.beats(album.id, 0)
assert grid is not None
assert grid.times == (0.5, 1.0)
curve = library.curve(album.id, 0)
assert curve is not None
assert curve.energy == (0.4, 0.6)
async def test_a_rescan_does_not_fold_analysis_into_a_non_music_album(
config_dir: Path,
) -> None:
"""The other half of the restriction above: a book track's `analysis/` file (however
it got there) is not folded into the index, so a rescan never stats hundreds of
book/podcast files that can never have one under normal operation."""
library = await build(config_dir)
album = album_named(library, "Eule")
from musicmouse.library.models import track_key
key = track_key(album.tracks[0].path)
library.cache.store_analysis(key, TrackAnalysis(version=ANALYZER_VERSION, tempo=128.0))
(config_dir / ".cache" / "index.json").unlink()
library = await build(config_dir)
album = album_named(library, "Eule")
assert album.tracks[0].analysis is None
async def test_analysis_survives_an_analyzer_that_grew_a_field(tmp_path: Path) -> None:
cache = LibraryCache(tmp_path / "cache")
cache.prepare()
(cache.analysis / "abc.json").write_text(
json.dumps({"version": 1, "tempo": 90.0, "danceability": 0.7})
)
analysis = cache.load_analysis("abc")
assert analysis is not None
assert analysis.tempo == 90.0
async def test_a_corrupt_index_is_rebuilt_rather_than_fatal(config_dir: Path) -> None:
await build(config_dir)
(config_dir / ".cache" / "index.json").write_text("{ not json")
library = await build(config_dir)
assert len(library.albums) == 6
def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None:
first = album_id(tmp_path, tmp_path / "Musik" / "Ein Album")
second = album_id(tmp_path, tmp_path / "Musik" / "Ein Album")
other = album_id(tmp_path, tmp_path / "Musik" / "Ein Anderes")
assert first == second
assert first != other
# -------------------------------------------------------------------- figure kinds
async def test_a_figure_is_music_unless_the_config_says_otherwise(
config_dir: Path,
) -> None:
"""A figure folder is named after the figurine, so nothing on disk says what it is."""
library = await build(config_dir)
assert album_named(library, "Fuchs").kind == "music"
assert album_named(library, "Eule").kind == "book"
async def test_a_book_figure_groups_with_the_audiobooks(config_dir: Path) -> None:
"""The bug this fixed: a figure holding an audiobook landed in its artist's shelf
as music, so the category showed "4 Alben" and was drawn like one."""
for index in range(2):
write_track(
config_dir / "music" / "Figuren" / "eule" / f"{index:02d} - track.mp3",
title=f"Teil {index}",
album="Conni in den Bergen",
albumartist="Conni, Julia Boehme",
)
library = await build(config_dir)
figure = album_named(library, "Eule")
assert figure.kind == "book"
# Books group by series, which a music album does not have at all.
assert figure.series == "Conni"
assert figure.category == "Conni"
conni = [album for album in library.albums if album.category == "Conni"]
assert len(conni) == 2
assert {album.kind for album in conni} == {"book"}
async def test_a_music_figure_has_no_series(config_dir: Path) -> None:
figure = album_named(await build(config_dir), "Fuchs")
assert figure.series is None
assert figure.category == "Die Tiere"
async def test_a_figures_kind_survives_the_cache(config_dir: Path) -> None:
"""It cannot be re-derived on load: the section says "Figuren", not what is in it.
The index used to rebuild `kind` from the section, so a book figure came back as
music on the second boot - and the browse view drew it square.
"""
first = await build(config_dir)
assert album_named(first, "Eule").kind == "book"
assert (config_dir / ".cache" / "index.json").is_file()
# Nothing on disk changed, so this run is served entirely from the index.
second = await build(config_dir)
eule = album_named(second, "Eule")
assert eule.kind == "book"
assert eule.series is not None
assert album_named(second, "Fuchs").kind == "music"
assert album_named(second, "Fuchs").series is None
# -------------------------------------------------------------------- analysis worker
async def _wait_for(predicate: Callable[[], bool], *, timeout: float = 2.0) -> None:
"""Poll until `predicate()` is true, rather than guessing a sleep duration."""
async def poll() -> None:
while not predicate():
await asyncio.sleep(0.01)
await asyncio.wait_for(poll(), timeout=timeout)
async def _cancel(task: asyncio.Task[object]) -> None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def test_analyze_pending_only_touches_music_albums(config_dir: Path) -> None:
"""Books and podcasts are the majority of a real library and none of them get a
background - see `_ANALYZED_KINDS`."""
analyzer = FakeAnalyzer()
library = await build(config_dir, analyzer=analyzer)
music_track_count = sum(len(a.tracks) for a in library.albums if a.kind == "music")
assert music_track_count == 5 # fuchs (3) + Kinderparty Lieder (2)
done = await library.analyze_pending()
assert done == 5
assert len(analyzer.calls) == 5
touched = {
next(a for a in library.albums if p in {t.path for t in a.tracks}).kind
for p in analyzer.calls
}
assert touched == {"music"}
async def test_analyze_pending_persists_curves_alongside_beats(config_dir: Path) -> None:
"""`MusicLibrary.curve()` mirrors `.beats()` - both are per-track detail fetched
for the one track currently playing, written together every pass."""
analyzer = FakeAnalyzer()
library = await build(config_dir, analyzer=analyzer)
album = next(a for a in library.albums if a.kind == "music")
await library.analyze_pending()
curve = library.curve(album.id, 0)
assert curve is not None
assert curve.hop_seconds == 1.0
assert curve.energy == (0.5,)
assert library.beats(album.id, 0) is not None
async def test_analyze_pending_does_not_repeat_work_already_cached(config_dir: Path) -> None:
analyzer = FakeAnalyzer()
library = await build(config_dir, analyzer=analyzer)
await library.analyze_pending()
again = await library.analyze_pending()
assert again == 0
assert len(analyzer.calls) == 5
async def test_a_failing_track_is_marked_attempted_and_not_retried(config_dir: Path) -> None:
"""A corrupt file, a DRM'd one, or one the analyzer just chokes on must not abort
the batch, and must not be retried on every single future pass either."""
analyzer = FakeAnalyzer(fails={"00 - lied.mp3"})
library = await build(config_dir, analyzer=analyzer)
done = await library.analyze_pending()
assert done == 5 # the failure still counts as "handled"
kinderparty = album_named(library, "Kinderparty Lieder")
failed = next(t for t in kinderparty.tracks if t.path.name == "00 - lied.mp3")
cached = library.cache.load_analysis(track_key(failed.path))
assert cached is not None
assert cached.version == ANALYZER_VERSION
assert cached.tempo is None # attempted, not computed
again = await library.analyze_pending()
assert again == 0
assert len(analyzer.calls) == 5
async def test_is_busy_pauses_the_pass_until_it_clears(
config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import musicmouse.library as library_module
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
analyzer = FakeAnalyzer()
library = await build(config_dir, analyzer=analyzer)
busy = True
task = asyncio.create_task(library.analyze_pending(is_busy=lambda: busy))
try:
await asyncio.sleep(0.05)
assert analyzer.calls == [] # never got past the busy check
busy = False
await asyncio.wait_for(task, timeout=2)
finally:
if not task.done():
await _cancel(task)
assert len(analyzer.calls) == 5
async def test_run_analysis_does_the_work_refresh_left_pending(config_dir: Path) -> None:
"""The trigger this feature actually depends on: `refresh()` - at startup, or from
a parent's "Bibliothek neu einlesen" - ends by requesting analysis, and
`run_analysis` is what turns that request into finished work, without a second
`refresh()` needed to see it."""
analyzer = FakeAnalyzer()
library = await build(config_dir, analyzer=analyzer) # refresh() already requested
batches: list[int] = []
async def on_batch() -> None:
batches.append(len(analyzer.calls))
worker = asyncio.create_task(library.run_analysis(on_batch=on_batch))
try:
await _wait_for(lambda: bool(batches))
finally:
await _cancel(worker)
assert batches[-1] == 5
kinderparty = album_named(library, "Kinderparty Lieder")
assert kinderparty.tracks[0].analysis is not None
assert kinderparty.tracks[0].analysis.tempo == 100.0
async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
config_dir: Path,
) -> None:
"""Calling `request_analysis` three times while a pass is already running must not
queue three more passes - just one, right after the current one finishes."""
release = threading.Event()
class BlockingOnceAnalyzer(FakeAnalyzer):
def __init__(self) -> None:
super().__init__()
self._blocked = False
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
if not self._blocked:
self._blocked = True
release.wait(timeout=2)
return super().analyze(path)
analyzer = BlockingOnceAnalyzer()
library = await build(config_dir, analyzer=analyzer)
pass_count = 0
original = library.analyze_pending
async def counting(**kwargs: object) -> int:
nonlocal pass_count
pass_count += 1
return await original(**kwargs) # type: ignore[arg-type]
library.analyze_pending = counting # type: ignore[method-assign]
worker = asyncio.create_task(library.run_analysis())
try:
await _wait_for(lambda: pass_count >= 1) # the first pass has started
await asyncio.sleep(0.05) # ... and is now blocked inside the analyzer
library.request_analysis()
library.request_analysis()
library.request_analysis()
release.set()
await _wait_for(lambda: pass_count >= 2)
# Give the coalesced pass a moment to actually run - everything is already
# cached, so it finds nothing pending and returns without incrementing further.
await asyncio.sleep(0.1)
finally:
await _cancel(worker)
assert pass_count == 2
assert len(analyzer.calls) == 5