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 { PhotosApp } from './photos-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_TIMEOUT = 10000; const SEARCH_VIDEO_ROOT = '__search__'; const SEARCH_AUDIO_ROOT = '__search__'; const SEARCH_PHOTO_ROOTS = ['__search_photos__']; // -- 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; let timer; try { await Promise.race([ transport.connect( nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, username, userId, null), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); }), ]); clearTimeout(timer); } catch (e) { clearTimeout(timer); try { transport.close(); } catch {} throw e; } 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 { let timer; const ack = await Promise.race([ transport.connect( nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, username, userId, null), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); }), ]); clearTimeout(timer); 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); // Bumped by the Files breadcrumb refresh button — re-runs the all-groups // index fetch below, the only "cache" this page has. const [refreshTick, setRefreshTick] = useState(0); 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, refreshTick]); // -- 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]); // Pre-connect to all groups as soon as indexing finishes so thumbnails // start loading before the user switches views. The pool evicts old // connections but _thumbBlobCache keeps fetched thumbnails across evictions. useEffect(() => { if (fetching || indexedGroups.size === 0) return; let cancelled = false; (async () => { const groupIds = [...indexedGroups.keys()]; 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; }; }, [fetching, 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]); // Photos view: pre-filtered by photoRoots, path-prefixed const photoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { const roots = data.roots.photoRoots; if (!roots || !roots.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'image') continue; const p = e.path || ''; if (!roots.some((r) => p === r || p.startsWith(r + '/'))) continue; if (q && !matchesQuery(e)) continue; result.push({ ...e, path: '__search_photos__/' + 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); }, []); const getTransport = useCallback(async (entry) => { const conn = await connectGroup(entry.groupId); return { transport: conn.transport, gek: conn.gek }; }, [connectGroup]); // No-op setters for FilesPanel const noop = useCallback(() => {}, []); // -- Render -- const totalEntries = allEntries.length; const hasResults = totalEntries > 0; const defaultTRef = useRef(null); const defaultGRef = useRef(null); return html`
${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} getTransport=${getTransport} onRefreshIndex=${() => setRefreshTick((n) => n + 1)} /> `} ${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} /> `} ${viewMode === 'photos' && hasResults && html` <${PhotosApp} groupId="search" transportRef=${defaultTRef} gekRef=${defaultGRef} status="connected" entries=${photoEntries} photoRoots=${SEARCH_PHOTO_ROOTS} setError=${noop} hideFilter=${true} readOnly=${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 };