summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py110
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()