diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-28 15:35:20 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-28 15:35:58 +0200 |
| commit | eba2e6b14484c124f3c87ff95cd7ee640833e5d3 (patch) | |
| tree | ba109b719c879df3ea40090e4fd6ff80b403a3a5 /packages/meshbay-hub/src/meshbay_hub/static/search-page.js | |
| parent | e6917349c47cc4b7e04e1ecfab27c5291a5fe522 (diff) | |
| download | meshbay-eba2e6b14484c124f3c87ff95cd7ee640833e5d3.tar.gz | |
feat(hub): cross-group search with reuse of existing views
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/search-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/search-page.js | 560 |
1 files changed, 560 insertions, 0 deletions
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` + <div> + <h2>${t('search.title')}</h2> + + <div class="search-bar"> + <${Icon} name="search" /> + <input type="text" + placeholder=${t('search.placeholder')} + value=${query} + onInput=${(e) => setQuery(e.target.value)} + autofocus /> + ${query && html` + <button class="search-bar-clear" onClick=${() => setQuery('')}> + <${Icon} name="close" /></button> + `} + ${hasResults && html` + <div class="view-toggle"> + <button class=${viewMode === 'files' ? 'active' : ''} + onClick=${() => setViewMode('files')} + title=${t('search.view_files')}> + <${Icon} name="folder" /></button> + <button class=${viewMode === 'videos' ? 'active' : ''} + onClick=${() => setViewMode('videos')} + title=${t('search.view_videos')}> + <${Icon} name="video" /></button> + <button class=${viewMode === 'music' ? 'active' : ''} + onClick=${() => setViewMode('music')} + title=${t('search.view_music')}> + <${Icon} name="music" /></button> + </div> + `} + </div> + + ${fetching && html` + <div class="search-progress"> + <span class="spinner"></span> + <span>${t('search.indexing', { done: progress.done, total: progress.total })}</span> + <div class="search-progress-bar"> + <div class="search-progress-fill" + style="width:${progress.total ? Math.round(100 * progress.done / progress.total) : 0}%"></div> + </div> + </div> + `} + + ${!fetching && progress.unreachable.length > 0 && html` + <p class="search-unreachable"> + ${t('search.unreachable', { n: progress.unreachable.length })} + </p> + `} + + ${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` + <p class="page-message">${t('search.hint')}</p> + `} + + ${connecting && html` + <div class="video-overlay" style="background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center"> + <span class="spinner"></span> + </div> + `} + + ${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)} /> + `} + </div> + `; +} + +export { SearchPage, ConnectionPool }; |