This commit is contained in:
2026-08-27 23:46:19 +02:00
parent a8ed350aec
commit 8aed3b022b
16 changed files with 602 additions and 186 deletions

View File

@@ -1 +1,2 @@
config.yml
/.musicmouse-cache

File diff suppressed because one or more lines are too long

View File

@@ -97,6 +97,7 @@ class PlayerBase:
self._playlist: Playlist | None = None
self._index = 0
self._playing = False
self._playlist_changed = False
@classmethod
def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]:
@@ -167,6 +168,7 @@ class PlayerBase:
if playing == self._playing:
return
self._playing = playing
self._playlist_changed = False
self._bus.emit(
PlaybackChanged(
playing=playing, figure=figure, playlist=self._playlist, source="player"
@@ -174,11 +176,25 @@ class PlayerBase:
)
def _set_index(self, index: int) -> None:
if index == self._index:
# A playlist swap while already playing resets the index to 0 without going
# through here (see `_load_playlist`), so the following `play_track(0)` looks
# like a no-op index change. Force it through in that case - `_set_playing`
# will not itself announce anything, since it was already playing before and
# after. Starting from idle needs no such push: `_set_playing`'s own True
# transition already covers the broadcast.
force = self._playlist_changed and self._playing
if index == self._index and not force:
return
self._playlist_changed = False
self._index = index
self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player"))
def _load_playlist(self, playlist: Playlist) -> None:
"""Bookkeeping shared by every player's ``set_playlist``."""
self._playlist = playlist
self._index = 0
self._playlist_changed = True
def _announce_volume(self, source: EventSource = "player") -> None:
self._bus.emit(VolumeChanged(volume=self._volume, source=source))
@@ -247,8 +263,7 @@ class VlcPlayer(PlayerBase):
self._list_player.set_media_list(media_list)
self._list_player.set_playback_mode(self._vlc.PlaybackMode.default)
self._playlist = playlist
self._index = 0
self._load_playlist(playlist)
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
def play(self) -> None:

View File

@@ -36,7 +36,7 @@ __all__ = ["Fingerprint", "LibraryCache"]
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
#: files are untouched, so otherwise a change to that logic is invisible until somebody
#: edits their music folder.
_INDEX_VERSION = 3
_INDEX_VERSION = 4
@dataclass(frozen=True, slots=True)

View File

@@ -103,6 +103,22 @@ def _cover_for(
return None, None
def _cover_for_episode(
folder: Path, path: Path, identifier: str, cache: LibraryCache
) -> tuple[Path | None, bytes | None]:
"""The reverse priority from :func:`_cover_for`: with one album per episode, the
episode's own embedded art is the more specific and more correct source. The
folder's shared cover is the fallback for an episode whose file carries none."""
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art
for name in _COVER_NAMES:
candidate = folder / name
if candidate.is_file():
return candidate, candidate.read_bytes()
return None, None
def scan_album(
folder: Path,
*,
@@ -176,6 +192,59 @@ def scan_album(
return album, fingerprint
def scan_episodes(
folder: Path,
*,
root: Path,
section_name: str,
section: Section,
extensions: frozenset[str],
cache: LibraryCache,
known: dict[str, tuple[Album, Fingerprint]],
) -> dict[str, tuple[Album, Fingerprint]]:
"""One album per audio file, for a section whose folder is a show rather than a
single release - a podcast feed's hundreds of episodes, most obviously.
Fingerprinted per file rather than per folder, so a new episode landing in an
already-scanned show only costs scanning that one file, not the whole show.
"""
paths = _audio_files(folder, extensions)
if section.order == "newest_first":
paths.reverse()
out: dict[str, tuple[Album, Fingerprint]] = {}
for path in paths:
identifier = album_id(root, path)
fingerprint = Fingerprint.of([path])
cached = known.get(identifier)
if cached is not None and cached[1] == fingerprint:
out[identifier] = cached
continue
tags, duration = _tags(path)
title = tags.get("title") or path.stem
cover, art = _cover_for_episode(folder, path, identifier, cache)
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
album = Album(
id=identifier,
section=section_name,
kind=section.kind,
title=title,
artist=folder.name,
# The folder is the show; every episode in it groups under the same series,
# exactly like an audiobook's chapters group under its book.
series=folder.name,
figure=None,
colors=colors,
folder=folder,
cover=cover,
tracks=(LibraryTrack(path=path, title=title, duration=duration),),
)
out[identifier] = (album, fingerprint)
return out
def scan_library(
root: Path,
extensions: frozenset[str],
@@ -203,6 +272,23 @@ def scan_library(
for folder in sorted(section_root.iterdir(), key=lambda path: path.name):
if not folder.is_dir() or folder.name.startswith("."):
continue
if section.album_unit == "episode":
# Fingerprinted per episode inside scan_episodes; the whole-folder
# shortcut below does not apply since one folder yields many albums.
out.update(
scan_episodes(
folder,
root=root,
section_name=section_name,
section=section,
extensions=extensions,
cache=cache,
known=known,
)
)
continue
identifier = album_id(root, folder)
cached = known.get(identifier)
if cached is not None:

View File

@@ -16,6 +16,7 @@ type AlbumKind = Literal["music", "book"]
type TrackOrder = Literal["filename", "newest_first"]
type TitleSource = Literal["tags", "folder"]
type ArtistSource = Literal["tags", "folder"]
type AlbumUnit = Literal["folder", "episode"]
@dataclass(frozen=True, slots=True)
@@ -26,6 +27,13 @@ class Section:
order: TrackOrder = "filename"
title_from: TitleSource = "tags"
artist_from: ArtistSource = "tags"
#: "folder" (default): one album per folder, every audio file a track/chapter of it.
#: "episode": one album per audio *file* - a show folder groups its episodes rather
#: than being one giant album itself. `title_from`/`artist_from` are not consulted
#: for this unit: an episode's title always comes from its own tags (or filename),
#: and the folder always supplies the artist/series, so episodes of the same show
#: still group together everywhere the browse view groups by category.
album_unit: AlbumUnit = "folder"
#: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full
@@ -40,7 +48,5 @@ SECTIONS: Final[dict[str, Section]] = {
"Figuren": Section(figures=True, title_from="folder"),
"Musik": Section(),
"Hörbücher": Section(kind="book"),
"Kinderpodcasts": Section(
kind="book", order="newest_first", title_from="folder", artist_from="folder"
),
"Kinderpodcasts": Section(kind="book", order="newest_first", album_unit="episode"),
}

View File

@@ -62,8 +62,7 @@ class FakePlayer(PlayerBase):
def set_playlist(self, playlist: Playlist) -> None:
self._cancel_timer()
self._playlist = playlist
self._index = 0
self._load_playlist(playlist)
self._remaining = self.track_duration
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))

View File

@@ -42,7 +42,8 @@ async def test_every_section_is_scanned(config_dir: Path) -> None:
"Eule",
"Kinderparty Lieder",
"Conni in den Bergen",
"Wissen macht Ah",
"Alt",
"Neu",
}
@@ -80,17 +81,27 @@ async def test_audiobooks_group_by_the_name_before_the_comma(config_dir: Path) -
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_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:
album = album_named(await build(config_dir), "Wissen macht Ah")
assert [track.title for track in album.tracks] == ["Neu", "Alt"]
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:
@@ -117,11 +128,12 @@ async def test_durations_come_from_the_files(config_dir: Path) -> None:
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}
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(album.tracks) == 2
assert len(episodes) == 2
async def test_unknown_top_level_folders_are_ignored(config_dir: Path) -> None:
@@ -141,7 +153,7 @@ async def test_a_missing_section_warns_rather_than_failing(
library = await build(config_dir)
assert "Musik" in caplog.text
assert len(library.albums) == 4
assert len(library.albums) == 5
async def test_an_empty_album_folder_is_not_an_album(config_dir: Path) -> None:
@@ -199,6 +211,27 @@ async def test_a_changed_folder_is_rescanned(config_dir: Path) -> None:
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)
@@ -238,7 +271,7 @@ async def test_a_corrupt_index_is_rebuilt_rather_than_fatal(config_dir: Path) ->
(config_dir / ".cache" / "index.json").write_text("{ not json")
library = await build(config_dir)
assert len(library.albums) == 5
assert len(library.albums) == 6
def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None:

View File

@@ -125,6 +125,29 @@ async def test_play_from_start_starts_the_first_track(
assert only(seen, PlaybackChanged)[-1].playing is True
async def test_switching_albums_while_playing_announces_the_new_track(
bus: EventBus, player: FakePlayer, seen: list[Event]
) -> None:
"""Regression: starting a new playlist from track 0 while already playing track 0
of the previous one used to look like a no-op index change, so no ``TrackChanged``
went out and front-ends never learned the album had switched."""
player.set_playlist(playlist(name="fuchs"))
player.play_from_start()
await bus.drain()
player.set_playlist(playlist(name="eule"))
player.play_from_start()
await bus.drain()
assert player.is_playing
assert player.playlist is not None
assert player.playlist.name == "eule"
changes = only(seen, TrackChanged)
assert changes[-1].index == 0
assert changes[-1].track is not None
assert changes[-1].track.path.parent.name == "eule"
async def test_playing_an_empty_playlist_does_nothing(
bus: EventBus, player: FakePlayer, caplog: pytest.LogCaptureFixture
) -> None: