Files
musicmouse/python-backend/musicmouse/library/__init__.py
Martin Bauer 747c390303 Add IR remote control (LIRC) with a number-key content mapping
Adds a TCP client for lircd's classic protocol: play/pause/next/prev/
volume/mute map to the same intents every other front-end already
emits, and number keys 0-9 play an assigned album/audiobook from the
start or a podcast show's newest episode, resolved fresh on every
press. The mapping is configured in config.yml and editable from the
frontend: a small "Taste zuweisen" button on the play screen (or the
A+digit keyboard shortcut) opens a 10-key picker to assign whatever is
currently playing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 08:31:28 +02:00

318 lines
12 KiB
Python

"""The music collection: what exists, what it is called, and what colour it is.
The scan is the only part of this backend that touches hundreds of files, so it runs
in a worker thread and its results are cached. Everything the rest of the app sees is
plain immutable data.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
from collections.abc import Awaitable, Callable, Collection, Mapping
from dataclasses import replace
from pathlib import Path
from typing import Final
from musicmouse.library.analysis import (
ANALYZER_VERSION,
Analyzer,
BeatGrid,
NullAnalyzer,
TrackAnalysis,
TrackCurves,
)
from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
from musicmouse.library.scanner import scan_library
from musicmouse.library.sections import SECTIONS, AlbumKind
from musicmouse.media import Playlist
_log = logging.getLogger(__name__)
__all__ = [
"ANALYZER_VERSION",
"SECTIONS",
"Album",
"Analyzer",
"BeatGrid",
"LibraryCache",
"LibraryTrack",
"MusicLibrary",
"NullAnalyzer",
"TrackCurves",
"album_id",
"track_key",
]
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
#: tens of minutes of narration with no musical mood to extract, and there are far more
#: of them in a typical library than there are songs.
_ANALYZED_KINDS: Final[tuple[AlbumKind, ...]] = ("music",)
#: How often the background worker checks whether it may resume after `is_busy()` said
#: no. A track's own analysis is a couple of seconds of CPU, so overshooting by this
#: much when playback stops is not worth polling harder for.
_BUSY_POLL_SECONDS: Final = 2.0
#: Analysis is folded back into the live index and persisted this often during a long
#: run, so a first-time pass over an unanalyzed library shows up gradually in open
#: browser tabs rather than only after the whole thing finishes.
_PUBLISH_BATCH_SIZE: Final = 25
def _analyze_one(
analyzer: Analyzer, path: Path
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Runs in a worker thread. Lowers this thread's own scheduling priority first.
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so
this makes idle-time analysis yield CPU to anything else without touching threads
used for other work. Niceness only ever increases and clamps at the OS maximum
(19), so calling this repeatedly on a reused pool thread is harmless.
"""
with contextlib.suppress(OSError):
os.nice(1)
return analyzer.analyze(path)
class MusicLibrary:
"""An immutable index of albums, rebuilt wholesale rather than mutated in place."""
def __init__(
self,
root: Path,
cache: LibraryCache,
extensions: frozenset[str],
*,
analyzer: Analyzer | None = None,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> None:
self.root = root
self.cache = cache
self.extensions = extensions
self.analyzer: Analyzer = analyzer or NullAnalyzer()
#: What each figure holds. The only thing a folder name cannot say.
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
#: Set by `request_analysis`, consumed by `run_analysis`. An `Event` rather than
#: a queue: a request raised while one is already pending or running just
#: coalesces into it, which is exactly what "look again" should mean here.
self._analysis_requested = asyncio.Event()
# -------------------------------------------------------------------- reading
@property
def albums(self) -> list[Album]:
return [album for album, _ in self._entries.values()]
def get(self, identifier: str | None) -> Album | None:
entry = self._entries.get(identifier) if identifier else None
return entry[0] if entry else None
def figure_playlists(self) -> dict[str, Playlist]:
"""One playlist per figure folder, keyed by figure name.
The figure path and the web path must hand the player the *same* object for the
same album: ``reactions.playback.play_figure`` resumes on an identity check.
"""
return {
album.figure: album.to_playlist()
for album, _ in self._entries.values()
if album.figure is not None
}
def latest_episode(self, series: str) -> Album | None:
"""The newest episode-unit album of a podcast show, by filename.
Episode files are named ``YYYYMMDD - Title``, so filename order is
chronological - the same fact ``Kinderpodcasts``' ``order="newest_first"``
already relies on at scan time. ``None`` if the show is unknown or empty.
"""
candidates = [
album
for album in self.albums
if album.series == series
and (section := SECTIONS.get(album.section)) is not None
and section.album_unit == "episode"
]
if not candidates:
return None
return max(
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
)
def beats(self, identifier: str, index: int) -> BeatGrid | None:
album = self.get(identifier)
if album is None or not 0 <= index < len(album.tracks):
return None
return self.cache.load_beats(track_key(album.tracks[index].path))
def curve(self, identifier: str, index: int) -> TrackCurves | None:
album = self.get(identifier)
if album is None or not 0 <= index < len(album.tracks):
return None
return self.cache.load_curve(track_key(album.tracks[index].path))
# -------------------------------------------------------------------- writing
async def refresh(self) -> None:
"""Rescan from disk. Blocking work happens off the loop; the swap is atomic.
Ends by waking the background analyzer: a rescan is exactly when new tracks -
the only ones analysis can be pending for - enter the index, whether that is
the startup scan or a parent tapping "Bibliothek neu einlesen".
"""
known = dict(self._entries)
entries = await asyncio.to_thread(
scan_library,
self.root,
self.extensions,
self.cache,
known=known,
figure_kinds=self.figure_kinds,
)
self._entries = await asyncio.to_thread(self._with_analysis, entries)
await asyncio.to_thread(self.cache.store_index, self._entries)
self.request_analysis()
def _with_analysis(
self,
entries: dict[str, tuple[Album, Fingerprint]],
*,
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
) -> dict[str, tuple[Album, Fingerprint]]:
"""Fold cached analysis results into the freshly scanned index.
Restricted to `kinds` (music by default): stat-ing hundreds of book and podcast
tracks whose analysis can never exist would be pure waste. Skipped entirely
while ``analysis/`` is empty, which is the normal case until an analyzer has
actually been run.
"""
if not any(self.cache.analysis.glob("*.json")):
return entries
out: dict[str, tuple[Album, Fingerprint]] = {}
for identifier, (album, fingerprint) in entries.items():
if album.kind not in kinds:
out[identifier] = (album, fingerprint)
continue
tracks = tuple(
replace(track, analysis=self.cache.load_analysis(track_key(track.path)))
for track in album.tracks
)
out[identifier] = (replace(album, tracks=tracks), fingerprint)
return out
@classmethod
async def build(
cls,
root: Path,
cache_dir: Path,
extensions: frozenset[str],
*,
analyzer: Analyzer | None = None,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> MusicLibrary:
cache = LibraryCache(cache_dir)
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds)
library._entries = await asyncio.to_thread(cache.load_index)
await library.refresh()
return library
# ------------------------------------------------------------------- analysis
def request_analysis(self) -> None:
"""Wake the background worker to look for tracks with no current analysis.
Idempotent, and safe to call before `run_analysis` has even started a first
time - the request just waits on the event.
"""
self._analysis_requested.set()
async def run_analysis(
self,
*,
is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None,
) -> None:
"""Analyze pending tracks whenever `request_analysis` wakes this up.
A long-running task, cancelled at shutdown alongside every other one. Loops
forever so a request raised *during* a pass (a rescan mid-analysis) starts
another pass right after, rather than being lost.
"""
while True:
await self._analysis_requested.wait()
self._analysis_requested.clear()
await self.analyze_pending(is_busy=is_busy, on_batch=on_batch)
async def analyze_pending(
self,
*,
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None,
batch_size: int = _PUBLISH_BATCH_SIZE,
) -> int:
"""Run the analyzer over tracks of `kinds` that have no current result.
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every
track, `is_busy()` pauses the whole pass rather than one file: analysis must
never compete with audio decoding for CPU, and a children's player is idle most
of the day, so the pass simply resumes next time it is. A track the analyzer
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with
every scalar left `None` - so it is never retried forever and the frontend falls
back to the un-analyzed baseline for it. Results are folded into the live index
and persisted every `batch_size` tracks, so a long first run is visible in open
browser tabs as it goes rather than only once it finishes.
"""
analyzer = self.analyzer
if analyzer.version < ANALYZER_VERSION:
return 0
done = 0
for album in self.albums:
if album.kind not in kinds:
continue
for track in album.tracks:
while is_busy():
await asyncio.sleep(_BUSY_POLL_SECONDS)
key = track_key(track.path)
cached = self.cache.load_analysis(key)
if cached is not None and cached.version >= analyzer.version:
continue
try:
analysis, grid, curve = await asyncio.to_thread(
_analyze_one, analyzer, track.path
)
except Exception:
_log.warning(
"Analyzer raised on %s; marking it attempted so it is not retried forever",
track.path,
exc_info=True,
)
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None
if grid is not None:
self.cache.store_beats(key, grid)
if curve is not None:
self.cache.store_curve(key, curve)
self.cache.store_analysis(key, analysis)
done += 1
if done % batch_size == 0:
await self._publish_analysis(kinds, on_batch)
if done % batch_size:
await self._publish_analysis(kinds, on_batch)
if done:
_log.info("Analyzed %d tracks", done)
return done
async def _publish_analysis(
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
) -> None:
self._entries = await asyncio.to_thread(self._with_analysis, self._entries, kinds=kinds)
await asyncio.to_thread(self.cache.store_index, self._entries)
if on_batch is not None:
await on_batch()