"""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", "Wissen macht Ah", } 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_podcasts_are_named_after_the_folder_not_the_tags(config_dir: Path) -> None: album = album_named(await build(config_dir), "Wissen macht Ah") # The tags say album="Wissen macht Ah! - Podcast" and artist=. assert album.kind == "book" assert album.artist == "Wissen macht Ah" assert album.series == "Wissen macht Ah" async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None: album = album_named(await build(config_dir), "Wissen macht Ah") assert [track.title for track in album.tracks] == ["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: album = album_named(await build(config_dir), "Wissen macht Ah") names = {track.path.name for track in album.tracks} assert "archive.json" not in names assert not any(name.startswith(".") for name in names) assert len(album.tracks) == 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) == 4 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_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) == 5 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