From eba2e6b14484c124f3c87ff95cd7ee640833e5d3 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 15:35:20 +0200 Subject: feat(hub): cross-group search with reuse of existing views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search page fetches indexes from all groups, then renders consolidated entries through the existing FilesPanel, VideoApp, and MusicApp components — no reimplemented views. Groups appear as top-level directories in the file browser; video/music entries use a synthetic root with per-entry transport refs for thumbnails and metadata across groups. Music player lifted to app.js with getConnection(groupId) for cross-group playback. Connection pool (max 3, LRU eviction) manages lazy WebRTC connections. All 10 locales updated with search keys. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/search-page.js | 560 +++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/search-page.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/search-page.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js new file mode 100644 index 0000000..032d26a --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -0,0 +1,560 @@ +import { + html, useState, useEffect, useRef, useMemo, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { canPreview, downloadEntry } from './file-utils.js'; +import { + HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey, +} from './hub-client.js'; +import { FilesPanel, FilePreview } from './files-app.js'; +import { VideoApp, bumpMediaMetaGeneration, bumpThumbGeneration } from './video-app.js'; +import { MusicApp, bumpMusicMetaGeneration } from './music-app.js'; +import { VideoPlayer } from './video-player.js'; +import { transfers } from './transfers.js'; + +const BATCH_SIZE = 3; +const MAX_POOL_SIZE = 3; +const DEBOUNCE_MS = 200; +const SEARCH_VIDEO_ROOT = '__search__'; +const SEARCH_AUDIO_ROOT = '__search__'; + +// -- Connection pool ---------------------------------------------------------- + +class ConnectionPool { + constructor(hubBase) { + this._hubBase = hubBase; + this._connections = new Map(); + this._connecting = new Map(); + } + + async connect(groupId, token, bundleKey, username, userId) { + const existing = this._connections.get(groupId); + if (existing && existing.transport.connected) { + existing.lastUsed = Date.now(); + return existing; + } + if (this._connecting.has(groupId)) return this._connecting.get(groupId); + + const p = this._doConnect(groupId, token, bundleKey, username, userId); + this._connecting.set(groupId, p); + try { + const conn = await p; + this._connections.set(groupId, conn); + this._evict(); + return conn; + } finally { + this._connecting.delete(groupId); + } + } + + async _doConnect(groupId, token, bundleKey, username, userId) { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (!nodesData.nodes || !nodesData.nodes.length) throw new Error('offline'); + + const live = (await ensureFreshToken()) || token; + const transport = new window.MeshBayTransport(this._hubBase, live); + transport.onNeedToken = async () => (await ensureFreshToken()) || token; + + await transport.connect( + nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, + username, userId, null); + + let gek = null; + if (transport.gekRaw && window.MeshBayCrypto) { + gek = await window.MeshBayCrypto.importGEK( + window.MeshBayCrypto.b64encode(transport.gekRaw)); + } + return { transport, gek, lastUsed: Date.now() }; + } + + _evict() { + while (this._connections.size > MAX_POOL_SIZE) { + let oldestId = null, oldestTime = Infinity; + for (const [id, conn] of this._connections) { + if (conn.lastUsed < oldestTime) { oldestTime = conn.lastUsed; oldestId = id; } + } + if (!oldestId) break; + const conn = this._connections.get(oldestId); + try { conn.transport.close(); } catch {} + this._connections.delete(oldestId); + } + } + + closeAll() { + for (const [, conn] of this._connections) { + try { conn.transport.close(); } catch {} + } + this._connections.clear(); + for (const [, p] of this._connecting) { + p.then(c => { try { c.transport.close(); } catch {} }).catch(() => {}); + } + this._connecting.clear(); + } +} + +// -- Index fetching ----------------------------------------------------------- + +async function fetchGroupIndex(groupId, token, bundleKey, username, userId) { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (!nodesData.nodes || !nodesData.nodes.length) return null; + + const live = (await ensureFreshToken()) || token; + const transport = new window.MeshBayTransport(HUB, live); + transport.onNeedToken = async () => (await ensureFreshToken()) || token; + + try { + const ack = await transport.connect( + nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, + username, userId, null); + + const indexMsg = await transport.fetchIndex(); + const roots = { + videoRoot: ack.video_root || '', + audioRoot: ack.audio_root || '', + photoRoots: ack.photo_roots || [], + }; + return { entries: indexMsg.entries || [], roots }; + } finally { + try { transport.close(); } catch {} + } +} + +async function fetchAllIndexes(groups, token, username, userId, onProgress, onBatch) { + const bundleKey = session.bundleKey || await _loadBundleKey(); + if (bundleKey) session.bundleKey = bundleKey; + + const total = groups.length; + let done = 0; + const unreachable = []; + const results = new Map(); + + for (let i = 0; i < groups.length; i += BATCH_SIZE) { + const batch = groups.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async (g) => { + try { + const result = await fetchGroupIndex(g.id, token, bundleKey, username, userId); + if (result) { + results.set(g.id, { + ...result, + groupName: g.name, + groupOwner: g.owner_username, + }); + cacheGroupIndex(g.id, g.name, g.owner_username, result.entries, result.roots); + } else { + unreachable.push(g.name || g.id); + } + } catch { + unreachable.push(g.name || g.id); + } + done++; + onProgress({ done, total, unreachable: [...unreachable] }); + })); + onBatch(new Map(results)); + } + return { results, unreachable }; +} + +// -- SearchPage --------------------------------------------------------------- + +function underRoot(entry, root) { + if (!root) return false; + const p = entry.path || ''; + return p === root || p.startsWith(root + '/'); +} + +function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) { + const [indexedGroups, setIndexedGroups] = useState(new Map()); + const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] }); + const [fetching, setFetching] = useState(false); + const [query, setQuery] = useState(''); + const [viewMode, setViewMode] = useState('files'); + const [videoEntry, setVideoEntry] = useState(null); + const [previewEntry, setPreviewEntry] = useState(null); + const [connecting, setConnecting] = useState(false); + const poolRef = useRef(null); + const modalTransportRef = useRef(null); + const modalGekRef = useRef(null); + const groupConns = useRef(new Map()); + const [connectionGen, setConnectionGen] = useState(0); + const debounceRef = useRef(null); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + useEffect(() => { + poolRef.current = new ConnectionPool(HUB); + return () => { if (poolRef.current) poolRef.current.closeAll(); }; + }, []); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS); + return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; + }, [query]); + + useEffect(() => { + if (!groups || !groups.length || !token || !window.MeshBayTransport) return; + let cancelled = false; + + (async () => { + setFetching(true); + setProgress({ done: 0, total: groups.length, unreachable: [] }); + + await fetchAllIndexes( + groups, token, username, userId, + (p) => { if (!cancelled) setProgress(p); }, + (results) => { if (!cancelled) setIndexedGroups(new Map(results)); }, + ); + + if (!cancelled) setFetching(false); + })(); + + return () => { cancelled = true; }; + }, [groups, token]); + + // -- Connection management -- + + const connectGroup = useCallback(async (groupId) => { + if (groupConns.current.has(groupId)) { + const c = groupConns.current.get(groupId); + if (c.transport && c.transport.connected) return c; + } + if (!poolRef.current) throw new Error('no pool'); + const bundleKey = session.bundleKey || await _loadBundleKey(); + const conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId); + const entry = { + transport: conn.transport, + gek: conn.gek, + tRef: { current: conn.transport }, + gRef: { current: conn.gek }, + }; + groupConns.current.set(groupId, entry); + setConnectionGen((g) => g + 1); + bumpMediaMetaGeneration(); + bumpThumbGeneration(); + bumpMusicMetaGeneration(); + return entry; + }, [token, username, userId]); + + // Eagerly connect to groups when entering Videos or Music view + useEffect(() => { + if (viewMode !== 'videos' && viewMode !== 'music') return; + let cancelled = false; + + (async () => { + const groupIds = [...indexedGroups.keys()].filter((gid) => { + const data = indexedGroups.get(gid); + if (viewMode === 'videos') return !!data.roots.videoRoot; + return !!data.roots.audioRoot; + }); + + for (let i = 0; i < groupIds.length; i += BATCH_SIZE) { + if (cancelled) break; + const batch = groupIds.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async (gid) => { + try { await connectGroup(gid); } catch { /* skip */ } + })); + } + })(); + + return () => { cancelled = true; }; + }, [viewMode, indexedGroups, connectGroup]); + + // -- Entry preparation -- + + const q = debouncedQuery.trim().toLowerCase(); + + const matchesQuery = useCallback((e) => { + if (!q) return true; + return (e.name || '').toLowerCase().includes(q) + || (e.path || '').toLowerCase().includes(q) + || (e.display_title || '').toLowerCase().includes(q) + || (e.artist || '').toLowerCase().includes(q) + || (e.album || '').toLowerCase().includes(q); + }, [q]); + + const allEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + for (const e of data.entries) { + result.push({ + ...e, groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + }); + } + } + return result; + }, [indexedGroups]); + + // Files view: path-prefixed entries for FilesPanel directory navigation + const fileEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + for (const e of data.entries) { + if (q && !matchesQuery(e)) continue; + const conn = groupConns.current.get(groupId); + result.push({ + ...e, + path: data.groupName + (e.path ? '/' + e.path : ''), + _origPath: e.path, + groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + _tRef: conn ? conn.tRef : null, + _gRef: conn ? conn.gRef : null, + }); + } + } + return result; + }, [indexedGroups, q, matchesQuery, connectionGen]); + + const fileNodeDirs = useMemo(() => { + const dirs = []; + for (const [, data] of indexedGroups) dirs.push(data.groupName); + return dirs; + }, [indexedGroups]); + + // Videos view: pre-filtered by videoRoot, path-prefixed + const videoEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + const root = data.roots.videoRoot; + if (!root) continue; + const conn = groupConns.current.get(groupId); + for (const e of data.entries) { + if (e.type !== 'video') continue; + if (!underRoot(e, root)) continue; + if (q && !matchesQuery(e)) continue; + result.push({ + ...e, + path: SEARCH_VIDEO_ROOT + '/' + e.path, + groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + _tRef: conn ? conn.tRef : null, + _gRef: conn ? conn.gRef : null, + }); + } + } + return result; + }, [indexedGroups, q, matchesQuery, connectionGen]); + + // Music view: pre-filtered by audioRoot, path-prefixed + const musicEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + const root = data.roots.audioRoot; + if (!root) continue; + const conn = groupConns.current.get(groupId); + for (const e of data.entries) { + if (e.type !== 'audio') continue; + if (!underRoot(e, root)) continue; + if (q && !matchesQuery(e)) continue; + result.push({ + ...e, + path: SEARCH_AUDIO_ROOT + '/' + e.path, + groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + _tRef: conn ? conn.tRef : null, + _gRef: conn ? conn.gRef : null, + }); + } + } + return result; + }, [indexedGroups, q, matchesQuery, connectionGen]); + + // -- Callbacks -- + + const onPreview = useCallback(async (entry) => { + const groupId = entry.groupId; + if (!groupId) return; + + if (entry.type === 'video') { + setConnecting(true); + try { + const conn = await connectGroup(groupId); + modalTransportRef.current = conn.transport; + modalGekRef.current = conn.gek; + setVideoEntry(entry); + } catch { /* ignore */ } + setConnecting(false); + return; + } + + if (entry.type === 'audio' && onPlayQueue) { + const origPath = entry._origPath != null ? entry._origPath : entry.path; + const siblings = allEntries + .filter((e) => e.type === 'audio' && e.path === origPath && e.groupId === groupId) + .sort((a, b) => + ((a.track_no == null ? 9999 : a.track_no) - (b.track_no == null ? 9999 : b.track_no)) + || (a.name || '').localeCompare(b.name || '')); + const startIndex = Math.max(0, siblings.findIndex((e) => e.id === entry.id)); + onPlayQueue(siblings, startIndex); + return; + } + + if (canPreview(entry)) { + setConnecting(true); + try { + const conn = await connectGroup(groupId); + modalTransportRef.current = conn.transport; + modalGekRef.current = conn.gek; + setPreviewEntry(entry); + } catch { /* ignore */ } + setConnecting(false); + } + }, [allEntries, onPlayQueue, connectGroup]); + + const handleMusicPlay = useCallback((tracks, startIndex) => { + setVideoEntry(null); + if (onPlayQueue) onPlayQueue(tracks, startIndex); + }, [onPlayQueue]); + + const downloadForModal = useCallback(async (entry) => { + const transport = modalTransportRef.current; + if (!transport || !transport.connected) return; + await downloadEntry(transfers, transport, modalGekRef.current, entry); + }, []); + + // No-op setters for FilesPanel (read-only mode) + const noop = useCallback(() => {}, []); + + // -- Render -- + + const totalEntries = allEntries.length; + const hasResults = totalEntries > 0; + const defaultTRef = useRef(null); + const defaultGRef = useRef(null); + + return html` +
+

${t('search.title')}

+ + + + ${fetching && html` +
+ + ${t('search.indexing', { done: progress.done, total: progress.total })} +
+
+
+
+ `} + + ${!fetching && progress.unreachable.length > 0 && html` +

+ ${t('search.unreachable', { n: progress.unreachable.length })} +

+ `} + + ${viewMode === 'files' && hasResults && html` + <${FilesPanel} + groupId="search" + transportRef=${defaultTRef} + gekRef=${defaultGRef} + status="connected" + entries=${fileEntries} + nodeDirs=${fileNodeDirs} + nodeRoots=${[]} + setEntries=${noop} + setNodeDirs=${noop} + setNodeRoots=${noop} + applyIndex=${noop} + isNodeAdmin=${false} + operatorPaired=${false} + mayUpload=${false} + userId=${userId} + setError=${noop} + onPreview=${onPreview} + showGroup=${true} + readOnly=${true} /> + `} + + ${viewMode === 'videos' && hasResults && html` + <${VideoApp} + groupId="search" + transportRef=${defaultTRef} + gekRef=${defaultGRef} + status="connected" + entries=${videoEntries} + onPreview=${onPreview} + videoRoot=${SEARCH_VIDEO_ROOT} + tmdbConfig=${{ enabled: true }} + isNodeAdmin=${false} + hideFilter=${true} /> + `} + + ${viewMode === 'music' && hasResults && html` + <${MusicApp} + groupId="search" + transportRef=${defaultTRef} + gekRef=${defaultGRef} + status="connected" + entries=${musicEntries} + audioRoot=${SEARCH_AUDIO_ROOT} + musicbrainzConfig=${{ enabled: true }} + onPlayQueue=${handleMusicPlay} + hideFilter=${true} /> + `} + + ${!hasResults && !fetching && html` +

${t('search.hint')}

+ `} + + ${connecting && html` +
+ +
+ `} + + ${previewEntry && html` + <${FilePreview} + entry=${previewEntry} + transportRef=${modalTransportRef} + gekRef=${modalGekRef} + onClose=${() => setPreviewEntry(null)} + onDownload=${() => downloadForModal(previewEntry)} /> + `} + ${videoEntry && html` + <${VideoPlayer} + entry=${videoEntry} + transportRef=${modalTransportRef} + gekRef=${modalGekRef} + onClose=${() => setVideoEntry(null)} + onDownload=${() => downloadForModal(videoEntry)} /> + `} +
+ `; +} + +export { SearchPage, ConnectionPool }; -- cgit v1.2.3