updates
This commit is contained in:
1
python-backend/.gitignore
vendored
1
python-backend/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
config.yml
|
||||
/.musicmouse-cache
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user