Files
musicmouse/python-backend/tests/test_library.py
2026-08-27 23:46:19 +02:00

343 lines
13 KiB
Python

"""The library scan: what ends up in the index, and what stays out of it."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
from musicmouse.library import Album, MusicLibrary
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis
from musicmouse.library.cache import LibraryCache
from musicmouse.library.colors import colors_from_id
from musicmouse.library.models import album_id
from tests.conftest import VALID_CONFIG, write_config, write_track
EXTENSIONS = frozenset(DEFAULT_AUDIO_EXTENSIONS)
async def build(config_dir: Path) -> MusicLibrary:
config = load_config(write_config(config_dir, VALID_CONFIG))
return await MusicLibrary.build(
config.general.library.root,
config.general.library.cache,
EXTENSIONS,
figure_kinds=config.figure_kinds,
)
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."""
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))
library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
# 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, "Eule")
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)
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