diff options
Diffstat (limited to 'packages/meshbay-hub/src')
14 files changed, 168 insertions, 46 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 26519da..4fdf5f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -256,6 +256,44 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, } }, [passInput, username]); + // Everything one handshake ack tells this page, applied in one place. + // + // Called by the first connect and again by every automatic reconnect: a node + // that restarted is a different process, and its answers are not the ones the + // first handshake got. Written once because the two paths drifting is how + // `helloworld`'s directories went missing from one of them. + const applyAck = useCallback((ack) => { + if (!ack) return; + setIsNodeAdmin(!!ack.is_node_admin); + setEnabledApps(ack.enabled_apps || null); + setScanSettings(ack.scan_settings || null); + setTmdbConfig({ + // Per-group (2026-08-24, used to be node-wide). + enabled: ack.tmdb_enabled !== false, + // Node-wide — one shared credential/cache. + tokenCustomized: !!ack.tmdb_token_customized, + language: ack.tmdb_language || '', + }); + // Every `<app>_directories` the ack carries, keyed by the app's own + // name — read off the ack rather than from a list of app names held + // here, so an application the node knows about is one this page already + // handles. Three names were hardcoded until 2026-09-10 and `helloworld` + // was not among them, so the app that exists to prove a new one needs + // no special-casing had its directories dropped on arrival. The live + // path below (`onAppDirectories`) was always generic; this was the half + // that was not. + setAppDirectories(Object.fromEntries( + Object.keys(ack) + .filter((k) => k.endsWith('_directories')) + .map((k) => [k.slice(0, -'_directories'.length), ack[k] || []]))); + setChatDirectory(ack.chat_directory || ''); + setChatLinkPreview(ack.chat_link_preview !== false); + setSearchListed(ack.search_listed !== false); + setMusicbrainzConfig({ + enabled: ack.musicbrainz_enabled !== false, + }); + }, []); + // One place that takes an index from the node and puts it everywhere it has to // go. Deleting a file used to refresh the table and leave the cache alone, so // the search page went on offering a file that no longer existed until the @@ -298,6 +336,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, useEffect(() => { let cancelled = false; + // Dropped by the teardown below, so a transport handed on to a running + // download (`releaseWhenIdle`) stops driving a page that is gone. + let offReconnect = null; // The cache is written here and read only by the search page. It used to // seed this list too, which put a stale index on screen and then raced the @@ -398,34 +439,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (!transport) throw (lastErr || new Error('no node served this group')); session.pendingJoinCode = null; if (cancelled) return; - setIsNodeAdmin(!!ack.is_node_admin); - setEnabledApps(ack.enabled_apps || null); - setScanSettings(ack.scan_settings || null); - setTmdbConfig({ - // Per-group (2026-08-24, used to be node-wide). - enabled: ack.tmdb_enabled !== false, - // Node-wide — one shared credential/cache. - tokenCustomized: !!ack.tmdb_token_customized, - language: ack.tmdb_language || '', - }); - // Every `<app>_directories` the ack carries, keyed by the app's own - // name — read off the ack rather than from a list of app names held - // here, so an application the node knows about is one this page already - // handles. Three names were hardcoded until 2026-09-10 and `helloworld` - // was not among them, so the app that exists to prove a new one needs - // no special-casing had its directories dropped on arrival. The live - // path below (`onAppDirectories`) was always generic; this was the half - // that was not. - setAppDirectories(Object.fromEntries( - Object.keys(ack) - .filter((k) => k.endsWith('_directories')) - .map((k) => [k.slice(0, -'_directories'.length), ack[k] || []]))); - setChatDirectory(ack.chat_directory || ''); - setChatLinkPreview(ack.chat_link_preview !== false); - setSearchListed(ack.search_listed !== false); - setMusicbrainzConfig({ - enabled: ack.musicbrainz_enabled !== false, - }); + applyAck(ack); transport.onAppsEnabled = (apps) => setEnabledApps(apps); // Two independent acks now (tmdb_config_ack: token/language, // node-wide; tmdb_enabled_ack: the per-group switch) — each merges @@ -513,6 +527,49 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (onPresence) onPresence(groupId, 'online'); }; + // An automatic reconnect (transport.js's _reconnectLoop) re-does the + // handshake and nothing else: no index is fetched, and any push sent + // while the old channel was dying is simply lost. That was survivable + // while a node that came back came back with the same answers — and a + // restarted one does not. It rebuilds its index from + // `index_cache.db`, which stores path/mtime/size/hash/type and no + // enrichment at all, so for the ~25s its re-enrichment pass takes + // (measured: 6176 audio files, 6.4s of tag reads plus cover work) the + // index it serves has no artist and no album on any track. A client + // that reconnected inside that window kept exactly that view for as + // long as the page stayed open: Files and Videos looked right — one + // needs no enrichment, the other's is restored from `media_cache.db` + // — and Music, whose grouping *is* the enrichment, drew nothing. + // + // So the reconnect asks again, for the ack and the index both. The + // full fetch rather than a delta: this session was never told what it + // missed, and a delta is computed against a snapshot only the node + // has. + offReconnect = transport.addReconnectListener((reack) => { + if (cancelled) return; + (async () => { + try { + applyAck(reack); + // Re-imported, not kept: a chat epoch or a re-key while we were + // away means the handshake just handed us a different GEK, and + // gekRef is what every decrypt on this page reads. + if (transport.gekRaw && window.MeshBayCrypto) { + gekRef.current = await window.MeshBayCrypto.importGEK( + window.MeshBayCrypto.b64encode(transport.gekRaw)); + } + const msg = await transport.fetchIndex(); + if (cancelled) return; + applyIndex(msg); + } catch (e) { + // The connection went again mid-refresh: the next reconnect + // runs this same handler. Saying so beats a view that is + // quietly one node-restart old. + console.warn('[MeshBay] index refresh after reconnect failed:', + e.message); + } + })(); + }); + // We are in: an invitation to this group has served its purpose. if (onJoined) onJoined(groupId); @@ -591,6 +648,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, return () => { cancelled = true; + if (offReconnect) { offReconnect(); offReconnect = null; } // Nothing will update this group's dock row once the page lets go of it. reportIndexPush(groupId, null); if (transportRef.current) { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 2d925e1..aea1196 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -302,6 +302,7 @@ export default { 'music.mode_flat': 'Flache Liste', 'music.empty': 'Keine Musik gefunden.', 'music.unknown_album': 'Unbekanntes Album', + 'music.unknown_artist': 'Unbekannter Künstler', 'music.various': 'Verschiedenes', 'music.play_all': 'Alle abspielen', 'music.menu_more': 'Mehr…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 690a70b..d9172a6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -300,6 +300,7 @@ export default { 'music.mode_flat': 'Flat list', 'music.empty': 'No music found.', 'music.unknown_album': 'Unknown album', + 'music.unknown_artist': 'Unknown artist', 'music.various': 'Various', 'music.play_all': 'Play all', 'music.menu_more': 'More…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index c48f942..fc0e639 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -300,6 +300,7 @@ export default { 'music.mode_flat': 'Lista plana', 'music.empty': 'No se encontró música.', 'music.unknown_album': 'Álbum desconocido', + 'music.unknown_artist': 'Artista desconocido', 'music.various': 'Varios', 'music.play_all': 'Reproducir todo', 'music.menu_more': 'Más…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 6d148d6..5eb8d60 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -301,6 +301,7 @@ export default { 'music.mode_flat': 'Liste à plat', 'music.empty': 'Aucune musique trouvée.', 'music.unknown_album': 'Album inconnu', + 'music.unknown_artist': 'Artiste inconnu', 'music.various': 'Divers', 'music.play_all': 'Tout lire', 'music.menu_more': 'Plus…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index c619d61..6d12cb3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -301,6 +301,7 @@ export default { 'music.mode_flat': 'Elenco semplice', 'music.empty': 'Nessuna musica trovata.', 'music.unknown_album': 'Album sconosciuto', + 'music.unknown_artist': 'Artista sconosciuto', 'music.various': 'Vari', 'music.play_all': 'Riproduci tutto', 'music.menu_more': 'Altro…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index eac90dc..f4ced88 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -298,6 +298,7 @@ export default { 'music.mode_flat': 'フラットリスト', 'music.empty': '音楽が見つかりません。', 'music.unknown_album': '不明なアルバム', + 'music.unknown_artist': '不明なアーティスト', 'music.various': 'その他', 'music.play_all': 'すべて再生', 'music.menu_more': 'その他…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 65bf4f5..31a2321 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -302,6 +302,7 @@ export default { 'music.mode_flat': 'Platte lijst', 'music.empty': 'Geen muziek gevonden.', 'music.unknown_album': 'Onbekend album', + 'music.unknown_artist': 'Onbekende artiest', 'music.various': 'Diversen', 'music.play_all': 'Alles afspelen', 'music.menu_more': 'Meer…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 264d442..463a6a9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -309,6 +309,7 @@ export default { 'music.mode_flat': 'Lista płaska', 'music.empty': 'Nie znaleziono muzyki.', 'music.unknown_album': 'Nieznany album', + 'music.unknown_artist': 'Nieznany wykonawca', 'music.various': 'Różne', 'music.play_all': 'Odtwórz wszystko', 'music.menu_more': 'Więcej…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 0fbfb1e..20822ee 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -302,6 +302,7 @@ export default { 'music.mode_flat': 'Lista simples', 'music.empty': 'Nenhuma música encontrada.', 'music.unknown_album': 'Álbum desconhecido', + 'music.unknown_artist': 'Artista desconhecido', 'music.various': 'Diversos', 'music.play_all': 'Reproduzir tudo', 'music.menu_more': 'Mais…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index ab75b11..b2d7591 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -295,6 +295,7 @@ export default { 'music.mode_flat': '平铺列表', 'music.empty': '未找到音乐。', 'music.unknown_album': '未知专辑', + 'music.unknown_artist': '未知艺术家', 'music.various': '其他', 'music.play_all': '全部播放', 'music.menu_more': '更多…', 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 e0308cb..1dbecbb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -256,7 +256,13 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen, on const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0]; const tRef = repTrack._tRef || transportRef; const gRef = repTrack._gRef || gekRef; - const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash; + // Never for an album this file minted itself (`isUnknown`): the release + // name is one we wrote -- '<artist> - Various', or the untagged pile + // below -- so the lookup is a third-party request, on the operator's + // connection, that cannot match anything. Confirmed in a node's log: + // queries went out naming a placeholder as both the artist and the + // release, once per card. + const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash && !album.isUnknown; const meta = useMusicMeta(tRef, repTrack.id, needsLookup); const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null; @@ -287,7 +293,7 @@ function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onC const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0]; const tRef = repTrack._tRef || transportRef; const gRef = repTrack._gRef || gekRef; - const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash; + const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash && !album.isUnknown; const meta = useMusicMeta(tRef, repTrack.id, needsLookup); const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null; @@ -672,17 +678,39 @@ function MusicApp({ const filteredTracks = useMemo(() => (!needle ? tracks : tracks.filter( (tr) => (tr.display_title || tr.name).toLowerCase().includes(needle))), [tracks, needle]); + // A track whose artist tag is empty *and* whose folder gave nothing to fall + // back on. The flat list has always drawn these as its own top-level rows; + // the grid, whose unit is an album, drew them nowhere at all -- and `empty` + // below counts them, so it stayed false and no message appeared either. A + // library nothing has tagged therefore rendered a toolbar over a blank page, + // with every one of its tracks one mode-switch away and nothing saying so. + // Reported after a node restart, where the index is briefly served without + // the tags it re-reads at start-up, and true of a genuinely untagged library + // with no restart involved. + // + // One card, the same shape the singleton folding above already mints for an + // artist's leftovers: it names what it is, and the tracks are playable from + // it. Not a card each -- that is the wall of one-track tiles this file + // exists to avoid -- and not sorted in among the artists, because it is not + // a name anybody chose and the alphabet is no place for it. + const untagged = useMemo(() => (filteredTracks.length + ? { artist: t('music.unknown_artist'), album: t('music.unknown_album'), + isUnknown: true, tracks: filteredTracks } + : null), [filteredTracks]); + // What this mode draws, in drawing order: albums under their artists in the // grid, and in the flat list loose tracks and artist folders sorted together. const units = useMemo(() => { if (mode === 'grid') { - return filteredArtists.flatMap((a) => a.albums.map((album) => ({ artist: a.artist, album }))); + const byArtist = filteredArtists.flatMap( + (a) => a.albums.map((album) => ({ artist: a.artist, album }))); + return untagged ? [...byArtist, { artist: untagged.artist, album: untagged }] : byArtist; } return [ ...filteredTracks.map((tr) => ({ key: tr.display_title || tr.name, kind: 'track', track: tr })), ...filteredArtists.map((a) => ({ key: a.artist, kind: 'artist', artist: a })), ].sort((a, b) => a.key.localeCompare(b.key)); - }, [mode, filteredArtists, filteredTracks]); + }, [mode, filteredArtists, filteredTracks, untagged]); const pager = usePager(units.length, pageSizeFrom(userPrefs), `${groupId}|${mode}|${needle}|${pageResetKey || ''}`); @@ -690,6 +718,9 @@ function MusicApp({ return units.slice(pager.start, pager.end); }, [units, pager.start, pager.end]); + // True exactly when the library has nothing, and — now that every track + // reaches a card — exactly when the grid has nothing to draw either. The two + // used to disagree, which is the whole of the defect above. const empty = albums.length === 0 && tracks.length === 0; 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 f4979f4..39e0ccf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -487,7 +487,12 @@ class MeshBayTransport { // "a reconnect to wait for" and stall every handshake step for the full // 6s gate below before ever sending it. this._inReconnectAttempt = false; - this._onReconnected = null; + // A set, not one slot. Two consumers want this at once — the video + // player, to re-ask for the stream it was watching, and the group + // page, to re-read an index the node rebuilt while we were away — + // and a single setter meant the second to arrive silently replaced + // the first, then cleared it on the way out. + this._reconnectListeners = new Set(); this._onNeedToken = null; // Which device key THIS connection has identified itself to the node with. // Empty means "not identified": nothing can be sealed, so nothing can be @@ -548,10 +553,19 @@ class MeshBayTransport { // Fired when a message that must open under the group key does not — // see _failSession. The session is over by the time this runs. set onSessionFailed(fn) { this._onSessionFailed = fn; } - // Fired once an automatic reconnect (see _reconnectLoop) lands a fresh - // handshake, so a consumer with something mid-flight on the old channel — - // today only the video player — can pick back up rather than sit dead. - set onReconnected(fn) { this._onReconnected = fn; } + /** + * Told once an automatic reconnect (see _reconnectLoop) lands a fresh + * handshake, with that handshake's ack. + * + * Returns its own unsubscribe, because the caller that stops listening + * must not be able to stop anyone else listening: `onReconnected` was a + * setter, the video player took it on open and set it back to `null` on + * close, and any other consumer's handler went with it. + */ + addReconnectListener(fn) { + this._reconnectListeners.add(fn); + return () => this._reconnectListeners.delete(fn); + } /** * Told whenever this connection's device identity changes — including to @@ -1213,10 +1227,11 @@ class MeshBayTransport { const token = this._onNeedToken ? await this._onNeedToken() : this._lastToken; trace('reconnect_attempt', { attempt: this._reconnectAttempts }); this._inReconnectAttempt = true; + let ack; try { - await this.connect(args.nodeId, token, args.groupId, args.gekRaw, - this._sessionKeys, args.bundleKey, args.username, - args.userId, args.joinCode); + ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, + this._sessionKeys, args.bundleKey, args.username, + args.userId, args.joinCode); } finally { this._inReconnectAttempt = false; } @@ -1226,9 +1241,14 @@ class MeshBayTransport { // have asked for its slot back first, or its next `file_req` carries a // `tr` the node has never heard of. this._reopenTransfers(); - if (this._onReconnected) { - try { this._onReconnected(); } catch (e) { - console.error('[MeshBay] onReconnected handler threw:', e); + // The ack goes with it: this is a *new* session against whatever the + // node is running now, and everything the first handshake taught the + // page — the folders each app reads, which apps are on, the roots — + // was answered by a process that may since have restarted. One + // listener throwing must not rob the next of the notification. + for (const fn of [...this._reconnectListeners]) { + try { fn(ack); } catch (e) { + console.error('[MeshBay] reconnect listener threw:', e); } } return; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index a07558e..66099de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -521,6 +521,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { useEffect(() => { let cancelled = false; + // Held so the teardown below can drop *this* listener and no one else's + // (transport.js's addReconnectListener). + let offReconnect = null; // Reset here, not in the teardown of the run before: switching video while // an append was in flight left `appendingRef` true, and flushQueue bails // out on it. The new SourceBuffer then never appended anything, so no @@ -730,7 +733,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // exactly what dragging the scrubber does — so reusing it here means a // screen-lock reconnect looks like a seek to where the film already // was, not a reload. - transport.onReconnected = () => { + offReconnect = transport.addReconnectListener(() => { if (cancelled) return; const v = videoRef.current; const seek = requestSeekRef.current; @@ -738,7 +741,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { console.log('[MeshBay] transport reconnected — resuming stream at', v.currentTime.toFixed(1)); seek(v.currentTime); - }; + }); transport.onStreamInit = (msg) => { if (cancelled) return; @@ -1121,8 +1124,8 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { transport.onStreamData = null; transport.onStreamEnd = null; transport.onStreamError = null; - transport.onReconnected = null; } + if (offReconnect) { offReconnect(); offReconnect = null; } // The queue can hold several megabytes of decrypted video. queueRef.current = []; const ms = msRef.current; |