aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-26 01:08:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-26 01:08:58 +0200
commit2d144d76cee55cf8faaacf196e716a0930dfd7e9 (patch)
tree1220d39cb30ccc8a58a80f85ba020b5b30bea9b7 /packages/meshbay-hub/src/meshbay_hub
parent704cfe37506fc5316c997b025004fbc9c4d2a47b (diff)
downloadmeshbay-2d144d76cee55cf8faaacf196e716a0930dfd7e9.tar.gz
fix(node,hub): key music/media metadata lookups by file_id, not path
IndexEntry.path is the *folder* a file is in (indexer.py's _virtual_dir docstring: "the directory a file appears in"), not the file itself. GroupIndex.get_entry_by_path() treated it as if it named one file, and every one of its four callers did too: _do_music_meta_request, _do_media_meta_request, _do_tmdb_override, and _admin_exec_tmdb_override. Any two files sharing a folder — an album is one folder with many tracks, a season is one folder with many episodes — collided: a lookup by path silently returned whichever entry the index happened to iterate to first, regardless of which file the client actually asked about. Found live (2026-08-25): three unrelated albums ("High Tone - Various", two "Le Peuple de l'Herbe" albums) all showed the same MusicBrainz cover, because all their representative tracks happened to sit in one "high_tone" folder alongside a track that legitimately matched that cover. A force-reload didn't help — the bug is server-side, not a stale client state. Fixed by keying these four request/response pairs by `file_id` (the entry's own content hash — already unique, already how every other lookup in the system identifies a file) instead of `path`, both in the wire messages (music_meta_req/resp, media_meta_req/resp, tmdb_override) and in music-app.js/video-app.js's own hooks. GroupIndex.get_entry_by_path is now unused and removed — GroupIndex.get_entry(file_id) already did the right thing. No test previously exercised either handler with two entries sharing a folder — the only existing coverage (test_tmdb_override_policy.py) gave each entry its own folder, so the bug never had a chance to show up. Added that scenario there and in two new test files, all confirmed failing against the pre-fix code before being confirmed green against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js28
3 files changed, 51 insertions, 42 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index 298e259..ae482ad 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -131,21 +131,21 @@ function groupMusicEntries(entries, audioRoot) {
// -- album cover, MusicBrainz fetched lazily and only when actually needed --
-function useMusicMeta(transportRef, path, active) {
+function useMusicMeta(transportRef, fileId, active) {
const [meta, setMeta] = useState(null);
useEffect(() => {
- if (!active || !path) return;
+ if (!active || !fileId) return;
let cancelled = false;
(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
- const resp = await transport.fetchMusicMeta(path);
+ const resp = await transport.fetchMusicMeta(fileId);
if (!cancelled) setMeta(resp);
} catch { if (!cancelled) setMeta({ confidence: 0 }); }
})();
return () => { cancelled = true; };
- }, [path, active]);
+ }, [fileId, active]);
return meta;
}
@@ -196,7 +196,7 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen })
// Only when nothing in the library already gives us a cover -- the common
// case (a well-tagged rip with embedded art) needs no network call at all.
const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash;
- const meta = useMusicMeta(transportRef, repTrack.path, needsLookup);
+ const meta = useMusicMeta(transportRef, repTrack.id, needsLookup);
const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null;
return html`
@@ -218,7 +218,7 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen })
function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onClose, onPlayQueue }) {
const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0];
const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash;
- const meta = useMusicMeta(transportRef, repTrack.path, needsLookup);
+ const meta = useMusicMeta(transportRef, repTrack.id, needsLookup);
const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null;
return html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 065ccc0..7535594 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -498,13 +498,17 @@ class MeshBayTransport {
/**
* TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4).
- * `path` is root+relpath, exactly what index_sync/index_delta already
- * gave this browser — never a raw filesystem path constructed here.
+ * Keyed by the entry's own `id` (its content hash) — never a path: a
+ * path names the *folder* a file is in (indexer.py's `_virtual_dir`), so
+ * two files sharing a folder (any multi-episode season) would resolve to
+ * whichever entry the node's index happened to return first (found live
+ * via the Music app's identical bug, 2026-08-25 — see webrtc_server.py's
+ * `_do_media_meta_request`).
* `confidence: 0` (no tmdb_id, no fields) means no confident match —
* the caller falls back to a thumbnail-only card (§4.1), not an error.
*/
- async fetchMediaMeta(path) {
- const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.5', path });
+ async fetchMediaMeta(fileId) {
+ const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.6', file_id: fileId });
if (msg.type === 'error') throw new Error(msg.detail);
return msg;
}
@@ -550,15 +554,16 @@ class MeshBayTransport {
* override would let any member vandalize another show's metadata.
* Applies to every file sharing the representative one's display_title,
* not just the file the operator happened to be looking at (webrtc_
- * server.py's _admin_exec_tmdb_override).
+ * server.py's _admin_exec_tmdb_override). Keyed by `fileId`, not a path
+ * — same reasoning as fetchMediaMeta above.
*/
- async overrideTmdbMatch(path, tmdbId, mediaType, signFn) {
+ async overrideTmdbMatch(fileId, tmdbId, mediaType, signFn) {
const msg = await this._sendAndWait({
- type: 'tmdb_override', v: '0.6', path, tmdb_id: tmdbId, media_type: mediaType,
+ type: 'tmdb_override', v: '0.7', file_id: fileId, tmdb_id: tmdbId, media_type: mediaType,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- const subject = `path=${path},tmdb_id=${tmdbId},media_type=${mediaType}`;
+ const subject = `file_id=${fileId},tmdb_id=${tmdbId},media_type=${mediaType}`;
return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn);
}
return msg;
@@ -668,15 +673,19 @@ class MeshBayTransport {
}
/**
- * MusicBrainz metadata for one track's path (Music app, docs/musicbay.md
- * §4.3) — same shape as fetchMediaMeta, minus a season/episode concept:
+ * MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3)
+ * — same shape as fetchMediaMeta, minus a season/episode concept:
* album-level (release), resolved from the track's own artist/album
- * fields already in the index. `confidence: 0` means no confident match
- * (or MusicBrainz off for this group, or nothing configured) — the caller
- * falls back to the embedded/no cover it already had, not an error.
+ * fields already in the index. Keyed by the track's own `id` (content
+ * hash), not a path — a path names the *folder* a track is in, and an
+ * album is one folder with many tracks in it; three unrelated albums
+ * shared one folder's track's cover before this fix (found live,
+ * 2026-08-25). `confidence: 0` means no confident match (or MusicBrainz
+ * off for this group, or nothing configured) — the caller falls back to
+ * the embedded/no cover it already had, not an error.
*/
- async fetchMusicMeta(path) {
- const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.8', path });
+ async fetchMusicMeta(fileId) {
+ const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.9', file_id: fileId });
if (msg.type === 'error') throw new Error(msg.detail);
return msg;
}
@@ -1467,10 +1476,10 @@ class MeshBayTransport {
_key: obj.type === 'file_req'
? `chunk:${obj.file_id}:${obj.chunk_index}`
: obj.type === 'ping' ? `ping:${obj.token}`
- : obj.type === 'media_meta_req' ? `media_meta:${obj.path}`
+ : obj.type === 'media_meta_req' ? `media_meta:${obj.file_id}`
// Same reordering hazard as media_meta_req: an album grid fires
// one music_meta_req per visible tile, several at a time.
- : obj.type === 'music_meta_req' ? `music_meta:${obj.path}`
+ : obj.type === 'music_meta_req' ? `music_meta:${obj.file_id}`
// Same reordering hazard as media_meta_req: a season-tab bar or a
// search box can have more than one of these in flight at once.
: obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}`
@@ -1652,7 +1661,7 @@ class MeshBayTransport {
// member_upload_ack/apps_enabled_ack above.
if (msg.type === 'tmdb_override_ack' && this._onTmdbOverride) {
this._onTmdbOverride({
- path: msg.path || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '',
+ fileId: msg.file_id || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '',
});
}
@@ -1774,21 +1783,21 @@ class MeshBayTransport {
}
if (msg.type === 'media_meta_resp') {
- const key = `media_meta:${msg.path}`;
+ const key = `media_meta:${msg.file_id}`;
for (const [, handler] of this._pending) {
if (handler._key === key) { handler.resolve(msg); return; }
}
- // Nobody asked for this path any more (tile scrolled out and a fresh
+ // Nobody asked for this file any more (tile scrolled out and a fresh
// request superseded it, most likely) — must not fall through to the
// oldest pending request, which would hand a different tile's promise
- // a TMDB result for a path it never asked about.
+ // a TMDB result for a file it never asked about.
return;
}
// Same reasoning as media_meta_resp: keyed, not arrival-order, and
// "nobody's waiting any more" must not fall through either.
if (msg.type === 'music_meta_resp') {
- const key = `music_meta:${msg.path}`;
+ const key = `music_meta:${msg.file_id}`;
for (const [, handler] of this._pending) {
if (handler._key === key) { handler.resolve(msg); return; }
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index de61839..ce38b0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -190,43 +190,43 @@ function MediaThumb({
// ── TMDB metadata, fetched once per visible tile ────────────────────────────
// An operator correcting a wrong match (TmdbSearchOverlay below) changes
-// what `media_meta_req` returns for a path every already-mounted tile/modal
+// what `media_meta_req` returns for a file every already-mounted tile/modal
// already has cached in its own useMediaMeta state — nothing would ever
-// refetch otherwise, since path/active don't change. Bumping this and
+// refetch otherwise, since fileId/active don't change. Bumping this and
// telling every subscribed hook to redo its fetch is simpler than trying to
-// know which paths a given override actually affects (that's server-side
+// know which files a given override actually affects (that's server-side
// knowledge — display_title grouping — this module doesn't have).
const _mediaMetaListeners = new Set();
function bumpMediaMetaGeneration() {
for (const fn of _mediaMetaListeners) fn();
}
-function useMediaMeta(transportRef, path, active) {
+function useMediaMeta(transportRef, fileId, active) {
const [meta, setMeta] = useState(null);
const [refetchToken, setRefetchToken] = useState(0);
useEffect(() => {
// Clears immediately (so the spinner shows right away, not only once
// the new fetch resolves) and bumps the token, which re-runs the fetch
- // effect below regardless of whether path/active changed at all.
+ // effect below regardless of whether fileId/active changed at all.
const listener = () => { setMeta(null); setRefetchToken((n) => n + 1); };
_mediaMetaListeners.add(listener);
return () => _mediaMetaListeners.delete(listener);
}, []);
useEffect(() => {
- if (!active || !path) return;
+ if (!active || !fileId) return;
let cancelled = false;
(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
- const resp = await transport.fetchMediaMeta(path);
+ const resp = await transport.fetchMediaMeta(fileId);
if (!cancelled) setMeta(resp);
} catch { if (!cancelled) setMeta({ confidence: 0 }); }
})();
return () => { cancelled = true; };
- }, [path, active, refetchToken]);
+ }, [fileId, active, refetchToken]);
return meta;
}
@@ -266,7 +266,7 @@ function useSeasonMeta(transportRef, tmdbId, season, active) {
// ── Mode A: poster grid ──────────────────────────────────────────────────────
function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) {
- const meta = useMediaMeta(transportRef, repEntry.path, true);
+ const meta = useMediaMeta(transportRef, repEntry.id, true);
const confident = Boolean(meta && meta.confidence && meta.tmdb_id);
const metaReady = meta !== null;
@@ -366,7 +366,7 @@ function buildSignFn(transportRef) {
}
function TmdbSearchOverlay({
- initialQuery, mediaType, path, transportRef, gekRef, onClose, onApplied,
+ initialQuery, mediaType, fileId, transportRef, gekRef, onClose, onApplied,
}) {
const [query, setQuery] = useState(initialQuery || '');
const [results, setResults] = useState(null); // null = not searched yet
@@ -398,14 +398,14 @@ function TmdbSearchOverlay({
setError('');
try {
const signFn = buildSignFn(transportRef);
- await transportRef.current.overrideTmdbMatch(path, tmdbId, mediaType, signFn);
+ await transportRef.current.overrideTmdbMatch(fileId, tmdbId, mediaType, signFn);
bumpMediaMetaGeneration();
onApplied();
} catch (err) {
setError(err.message);
setApplying(false);
}
- }, [applying, path, mediaType, transportRef, onApplied]);
+ }, [applying, fileId, mediaType, transportRef, onApplied]);
return html`
<div class="video-overlay video-search-overlay" onClick=${(e) => {
@@ -554,7 +554,7 @@ function VideoDetailModal({
</div>
${searching && html`
<${TmdbSearchOverlay} initialQuery=${(confident && meta.title) || title} mediaType=${mediaType}
- path=${repEntry.path} transportRef=${transportRef} gekRef=${gekRef}
+ fileId=${repEntry.id} transportRef=${transportRef} gekRef=${gekRef}
onClose=${() => setSearching(false)}
onApplied=${() => setSearching(false)} />
`}
@@ -615,7 +615,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
}, [shows, metaByGroup]);
const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
- const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.path : null, !!detail);
+ const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.id : null, !!detail);
return html`
<div class="video-grid">