310 lines
11 KiB
Python
310 lines
11 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",
|
|
"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=<six presenters>.
|
|
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
|