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.
584 lines
22 KiB
Python
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
|