diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 110 |
1 files changed, 106 insertions, 4 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 7691467..913ad68 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -47,8 +47,10 @@ from meshbay_node.roots import RootSet, RootError, entry_abs_path from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.indexer import DirectoryIndexer, IndexCache, GroupIndex from meshbay_node.indexer.enrich import Enricher +from meshbay_node.indexer.enrich_audio import AudioEnricher from meshbay_node.media_cache import MediaCache from meshbay_node.tmdb import TmdbClient +from meshbay_node.musicbrainz import MusicBrainzClient from meshbay_node.keystore import create_keystore, load_keystore, load_or_create_keystore from meshbay_node.roster import Roster from meshbay_node.transport import ( @@ -144,11 +146,15 @@ class NodeDaemon: self._media_cache: MediaCache | None = None self._enricher: Enricher | None = None self._tmdb_client: TmdbClient | None = None + self._audio_enricher: AudioEnricher | None = None + self._musicbrainz_client: MusicBrainzClient | None = None # A file id attempted at most once per daemon run, success or # failure — a persistently unprobeable file (corrupt, still being # written) does not get re-queued on every coalesced broadcast. A # restart retries everything, matching the "disposable, rebuildable" # stance the rest of this cache takes (docs/mediacenter.md §1/§2). + # Shared across the video and audio enrichment paths — content- + # addressed ids never collide between the two. self._enriched_attempted: set[str] = set() self._roster: Roster | None = None self._indexers: list[DirectoryIndexer] = [] @@ -340,6 +346,10 @@ class NodeDaemon: # shape as video_root above. "tmdb_enabled": await self._roster.tmdb_enabled( group_cfg.id) if self._roster else True, + # Music app equivalent of tmdb_enabled — per-group from + # the start (docs/musicbay.md §6). + "musicbrainz_enabled": await self._roster.musicbrainz_enabled( + group_cfg.id) if self._roster else True, } if not groups_ctx: @@ -380,6 +390,15 @@ class NodeDaemon: tmdb_token, tmdb_language = await self._roster.tmdb_config() self._state["tmdb_token_customized"] = bool(tmdb_token) self._state["tmdb_language"] = tmdb_language or "" + + # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own + # enricher (mutagen, not ffmpeg) and its own MusicBrainz client. + # No token to read here (§3.1): only a contact string, and unset + # simply means the client makes no calls (musicbrainz.py). + self._audio_enricher = AudioEnricher(self._media_cache) + self._musicbrainz_client = MusicBrainzClient(roster=self._roster) + musicbrainz_contact = await self._roster.musicbrainz_contact() + self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact) log.info("Media cache opened: %s", media_cache_db) # 5. Denylist @@ -411,6 +430,7 @@ class NodeDaemon: self._webrtc._ctx["bundle_store"] = self._bundle_store self._webrtc._ctx["media_cache"] = self._media_cache self._webrtc._ctx["tmdb_client"] = self._tmdb_client + self._webrtc._ctx["musicbrainz_client"] = self._musicbrainz_client self._webrtc._ctx["sk_x25519_raw"] = sk_x_raw self._webrtc._ctx["pk_x25519_raw"] = pk_x_raw self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64 @@ -521,6 +541,7 @@ class NodeDaemon: self._state["hub"] = hub self._state["reload_fn"] = self._reload_config self._state["enrich_video_root_fn"] = self._enrich_video_root_now + self._state["enrich_music_now_fn"] = self._enrich_music_now # Rotating a key has to reach every transport holding a copy of it, # and clearing the denylist has to reach the one the handshake # consults — so both are published rather than reachable only @@ -732,6 +753,9 @@ class NodeDaemon: "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), + "musicbrainz_enabled": ( + await self._roster.musicbrainz_enabled(group_cfg.id) + if self._roster else True), "chat_store": store, } groups_ctx[group_cfg.id] = new_ctx @@ -964,6 +988,12 @@ class NodeDaemon: # INDEX_DELTA update (_on_enriched below). new_entries = delta.additions if delta is not None else list(idx.entries) asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries)) + # Music app (docs/musicbay.md §6): same shape, gated on the group's + # enabled_apps rather than a root (Music has no video_root analogue — + # see musicbay.md §2.1's note on why that scoping wasn't carried + # over). Tag/cover extraction is local and cheap either way; the gate + # exists to not do it at all for a group that never turned Music on. + asyncio.ensure_future(self._enrich_new_audio_entries(indexer, new_entries)) # A rename/move changes the very filename (or season folder) that # §3.3/§3.4's title-parse read display_title/season/episode from, @@ -976,11 +1006,15 @@ class NodeDaemon: if delta is not None and delta.updates and previous is not None: asyncio.ensure_future( self._reenrich_renamed_video_entries(indexer, delta.updates, previous)) + asyncio.ensure_future( + self._reenrich_renamed_audio_entries(indexer, delta.updates, previous)) - # Videos app: a file that leaves the index also loses its thumbnail - # and file->tmdb mapping — the "real deletion obligation" docs/ - # mediacenter.md §2/§8 calls out explicitly rather than leaving - # implicit. tmdb_meta rows are left alone (§2: shared across files). + # Videos/Music apps: a file that leaves the index also loses its + # thumbnail/cover and file->tmdb/file->mbid mapping — the "real + # deletion obligation" docs/mediacenter.md §2/§8 calls out + # explicitly rather than leaving implicit (docs/musicbay.md §6 + # follows the same rule). tmdb_meta/mbid_meta rows are left alone + # (§2: shared across files). if delta is not None and delta.deletions and self._media_cache: for file_id in delta.deletions: asyncio.ensure_future(self._media_cache.prune_file(file_id)) @@ -1110,6 +1144,71 @@ class NodeDaemon: self._enriched_attempted.discard(entry.id) await self._enrich_new_video_entries(indexer, updates) + async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None: + """ + Music app (docs/musicbay.md §2.1, §6): fire (never await further) + tag/cover enrichment for unattempted audio entries, gated on the + group having "music" in its enabled_apps — there is no video_root + analogue for Music (musicbay.md §2.1 deliberately didn't add one: + tag reads are free/local, unlike ffprobe+ffmpeg thumbnailing, so the + cost this gate protects against is smaller, and most personal MP3 + libraries want their whole shared tree available rather than one + scoped subfolder). `_enriched_attempted` is shared with the video + path — content-addressed ids never collide across the two. + """ + if not self._audio_enricher or not self._roster: + return + enabled_apps = await self._roster.enabled_apps(indexer.group_id) + if "music" not in enabled_apps: + return + for entry in entries: + if entry.type != "audio" or entry.id in self._enriched_attempted: + continue + file_path = entry_abs_path(indexer.roots, entry) + if not file_path or not file_path.exists(): + continue + self._enriched_attempted.add(entry.id) + + async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: + await self._on_enriched(_indexer, file_id, fields) + + self._audio_enricher.spawn(entry, file_path, on_done) + + async def _enrich_music_now(self, group_id: str) -> None: + """ + Music app: sweep a group's existing index right after "music" is + added to its enabled_apps (ops.set_enabled_apps) — the ordinary path + above only ever looks at entries new since the last broadcast, so a + library that was already sitting there before Music was turned on + would otherwise never get enriched. Mirrors + `_enrich_video_root_now`, triggered from a different setting because + Music has no root of its own to key off. + """ + indexer = self._state.get("indexers", {}).get(group_id) + if not indexer: + return + await self._enrich_new_audio_entries(indexer, list(indexer.index.entries)) + + async def _reenrich_renamed_audio_entries( + self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex, + ) -> None: + """ + Music app equivalent of `_reenrich_renamed_video_entries` — a rename + can change the filename-parse fallback (title/track_no) even though + embedded tags, when present, are unaffected. Re-running the whole + pass on a rename is redundant work for a tagged file and a real fix + for an untagged one, and renames are rare enough not to need a + cheaper, tags-only special case. + """ + for entry in updates: + if entry.type != "audio": + continue + old = previous.get_entry(entry.id) + if old is None or (old.name == entry.name and old.path == entry.path): + continue + self._enriched_attempted.discard(entry.id) + await self._enrich_new_audio_entries(indexer, updates) + async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None: """ Merge enrichment fields into the live index and re-trigger a @@ -1174,6 +1273,9 @@ class NodeDaemon: if self._tmdb_client: await self._tmdb_client.close() + if self._musicbrainz_client: + await self._musicbrainz_client.close() + if self._media_cache: await self._media_cache.close() |