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, groupVideoEntries } from './video-app.js'; import { MusicApp, groupMusicEntries, foldKey } from './music-app.js'; import { PhotosApp, groupPhotoAlbums } from './photos-app.js'; import { VideoPlayer } from './video-player.js'; import { transfers } from './transfers.js'; import { mergeUnitEntries } from './source-merge.js'; const BATCH_SIZE = 3; // One WebRTC peer connection per group the search view touches. The cap bounds // how many a busy account (many groups on the hub) keeps open at once; groups // past it connect lazily when a tile of theirs scrolls into view (video-app.js's // LazyTile → onNeedConn). Was 3, which meant any account with more than three // groups thrashed the pool: an evicted transport left a stale `_tRef` on every // tile of that group, and MediaThumb / useMediaMeta gave up on it with no // retry — so posters rendered for a moment, then fell back to a spinner for // good. grenet (one shared group) never hit it; cbesson (many) always did. const MAX_POOL_SIZE = 12; 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, onEvict) { this._hubBase = hubBase; this._connections = new Map(); this._connecting = new Map(); // Called with a groupId whenever this pool closes that group's connection // (eviction or closeAll). SearchPage uses it to drop its own record so it // never hands a tile a `_tRef` pointing at a transport just closed here. this._onEvict = onEvict || (() => {}); } 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); this._onEvict(oldestId); } } closeAll() { for (const [id, conn] of this._connections) { try { conn.transport.close(); } catch {} this._onEvict(id); } 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(); // Plural, with the old scalars as the fallback for a node still speaking // MNP 1.0 — the same reading group-page.js does on its own handshake. const roots = { video: ack.video_directories || (ack.video_root ? [ack.video_root] : []), music: ack.music_directories || (ack.audio_root ? [ack.audio_root] : []), photo: ack.photo_directories || ack.photo_roots || [], }; // Which of the reader's groups sit on their own node — the tie-breaker // when the same file is announced by several of them // (docs/refactoring-search.md §5.3). Computed by the node from its own // record of who it belongs to (webrtc_server.py's _is_node_admin), never // from a hub claim, and deliberately not written to the index cache: it // describes this connection, not the group's content. return { entries: indexMsg.entries || [], roots, isNodeAdmin: !!ack.is_node_admin }; } 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, directories) { const dirs = directories || []; if (!dirs.length) return false; const p = entry.path || ''; return dirs.some((d) => p === d || p.startsWith(d + '/')); } /** * An app's directories out of a cached group, in either shape. * * The cache lives in IndexedDB and outlives a deploy, so a reader opening * Search after this ships still has entries written by the previous version — * `{videoRoot: 'X'}` where this now writes `{video: ['X']}`. Reading only the * new shape would empty their Videos results with no explanation and no way * to tell it from "nothing matched". */ function cachedDirs(roots, appKey, legacyKey) { if (!roots) return []; const fresh = roots[appKey]; if (Array.isArray(fresh)) return fresh; const legacy = roots[legacyKey]; if (Array.isArray(legacy)) return legacy; return legacy ? [legacy] : []; } // -- Merging the same file announced by several groups ------------------------ // // A directory shared by two groups — the reason two groups exist at all: // different people invited to different libraries — arrived here as two // entries per file, so a film showed as two poster cards and every episode // twice inside a show. `source-merge.js` folds them on the content hash and // resolves one source per *unit*. See docs/refactoring-search.md. // // The units come from video-app.js's own `groupVideoEntries`, never from a // second copy of its keys here: a copy would keep agreeing with the original // right up until one of them changed, and the symptom would be a show whose // episodes stream from two different nodes. Running it twice per recompute (it // runs again inside VideoApp) is a linear pass over an index already in memory // and already re-walked on every keystroke of the filter. // // One naive unit per copy of a film rather than a pre-grouped one: // `mergeUnitEntries` folds lists that share a key, so the two copies become // one unit without this having to group them first. function videoUnits(entries) { const { movies, shows } = groupVideoEntries(entries, [SEARCH_VIDEO_ROOT]); return [ ...movies.map((e) => ({ key: `movie:${e.id}`, entries: [e] })), ...shows.map((s) => ({ key: `show:${s.title}`, entries: s.episodes })), ]; } // An album is a unit, and so is a track loose enough to have no artist at all. // `groupMusicEntries` has already folded the two groups' copies into one album // object, so the key only has to name it stably — hence `foldKey` over the // display strings, which are whichever spelling arrived first. function musicUnits(entries) { const { tracks, albums } = groupMusicEntries(entries, [SEARCH_AUDIO_ROOT]); return [ ...albums.map((a) => ({ key: `album:${foldKey(a.artist)}/${foldKey(a.album)}`, entries: a.tracks, })), ...tracks.map((e) => ({ key: `track:${e.id}`, entries: [e] })), ]; } // A photo album is its directory, which is already prefixed per group when the // entries are built — so two groups whose roots have different basenames stay // two albums, and the same photo belongs in both. Only same-named albums // collapse, which is the reported shape. function photoUnits(entries) { return groupPhotoAlbums(entries, SEARCH_PHOTO_ROOTS) .map((a) => ({ key: `album:${a.dir}`, entries: a.photos })); } 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()); // groupId -> how many times its connection has been (re)established. Threaded // into each entry as `_connGen` and used by video-app.js's tiles as a refetch // key, so a group's posters/thumbnails recover the moment it reconnects // (after a pool eviction) instead of staying stuck on a spinner. const connGenRef = useRef(new Map()); // groupIds whose connection failed — see markGroupDown below. const downGroups = useRef(new Set()); const mountedRef = useRef(true); const [connectionGen, setConnectionGen] = useState(0); const debounceRef = useRef(null); const [debouncedQuery, setDebouncedQuery] = useState(''); useEffect(() => { mountedRef.current = true; poolRef.current = new ConnectionPool(HUB, (evictedId) => { groupConns.current.delete(evictedId); // Rebuild the entry lists so tiles of the evicted group fall back to a // null `_tRef` (and pick a live one up again once reconnected). if (mountedRef.current) setConnectionGen((g) => g + 1); }); return () => { mountedRef.current = false; 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 -- // A group whose connection failed stops being chosen as a merged entry's // source, so a unit fails over to another group that has the file // (docs/refactoring-search.md §5.4). Without this the merge could make a // file *less* available than it was before it, which would be a regression // dressed as a feature. // // Held in a ref and read live by `mergeOpts.isDown`; what actually rebuilds // the entry lists is the `connectionGen` bump, which they already depend on. // Eviction is deliberately not a failure — `_onEvict` only drops the recorded // connection, and the group is picked again as readily as before. const markGroupDown = useCallback((groupId, down) => { let changed; if (down) { changed = !downGroups.current.has(groupId); downGroups.current.add(groupId); } else { changed = downGroups.current.delete(groupId); } if (changed && mountedRef.current) setConnectionGen((g) => g + 1); }, []); 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(); let conn; try { conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId); } catch (e) { markGroupDown(groupId, true); throw e; } markGroupDown(groupId, false); // A concurrent caller for the same group (several tiles mounting at once) // may already have recorded this exact transport while we awaited. Only // treat it as new — bump the per-group generation, force a rebuild — when // it genuinely is, so a group's tiles refetch once per (re)connect rather // than once per mounting tile. const existing = groupConns.current.get(groupId); if (existing && existing.transport === conn.transport) { existing.lastUsed = Date.now(); return existing; } const gen = (connGenRef.current.get(groupId) || 0) + 1; connGenRef.current.set(groupId, gen); const entry = { transport: conn.transport, gek: conn.gek, gen, tRef: { current: conn.transport }, gRef: { current: conn.gek }, }; groupConns.current.set(groupId, entry); // No bumpMediaMetaGeneration() / bumpThumbGeneration() here: a fresh // transport does not invalidate metadata another group already resolved, // and firing the module-wide reset on every connect is what made the whole // grid flicker through the pre-connect walk. Recovery for *this* group's // tiles comes from `_connGen` (threaded into its entries) instead; the // module-wide bumps stay for an operator's TMDB override/rematch only. setConnectionGen((g) => g + 1); return entry; }, [token, username, userId, markGroupDown]); // Warm up connections as soon as indexing finishes so thumbnails start // loading before the user switches views — but only up to the pool's // capacity. Warming every group would just evict the earlier ones before // the user ever gets there; groups past the cap connect lazily when a tile // of theirs scrolls into view (onNeedConn, below). _thumbBlobCache keeps // fetched thumbnails across evictions. useEffect(() => { if (fetching || indexedGroups.size === 0) return; let cancelled = false; (async () => { const groupIds = [...indexedGroups.keys()].slice(0, MAX_POOL_SIZE); 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, _connGen: conn ? conn.gen : 0, }); } } return result; }, [indexedGroups, q, matchesQuery, connectionGen]); const fileNodeDirs = useMemo(() => { const dirs = []; for (const [, data] of indexedGroups) dirs.push(data.groupName); return dirs; }, [indexedGroups]); // How a unit's source is chosen, shared by every merged view // (docs/refactoring-search.md §5.3). `isLocal` reads the flag the node itself // put in the handshake ack — computed from its own record of who it belongs // to (webrtc_server.py's `_is_node_admin`), never from a hub claim. const mergeOpts = useMemo(() => ({ salt: userId || '', isLocal: (gid) => { const data = indexedGroups.get(gid); return !!(data && data.isNodeAdmin); }, // Read live off the ref rather than captured: what rebuilds the lists is // the `connectionGen` bump markGroupDown fires, and they already depend on // it. Putting the set in this memo's own dependencies would only add a // second reason to rebuild the same thing. isDown: (gid) => downGroups.current.has(gid), }), [indexedGroups, userId]); // Videos view: pre-filtered by videoRoot, path-prefixed, then merged so a // file several groups share is one card and one list entry. const videoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { const dirs = cachedDirs(data.roots, 'video', 'videoRoot'); if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'video') continue; if (!underRoot(e, dirs)) 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, _connGen: conn ? conn.gen : 0, }); } } return mergeUnitEntries(videoUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // Music view: pre-filtered by audioRoot, path-prefixed, then merged per album const musicEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { const dirs = cachedDirs(data.roots, 'music', 'audioRoot'); if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'audio') continue; if (!underRoot(e, dirs)) 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, _connGen: conn ? conn.gen : 0, }); } } return mergeUnitEntries(musicUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // Photos view: pre-filtered by photoRoots, path-prefixed, then merged per album const photoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { const dirs = cachedDirs(data.roots, 'photo', 'photoRoots'); if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'image') continue; const p = e.path || ''; if (!dirs.some((d) => p === d || p.startsWith(d + '/'))) 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, _connGen: conn ? conn.gen : 0, }); } } return mergeUnitEntries(photoUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // -- 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} userId=${userId} setError=${noop} onPreview=${onPreview} showGroup=${true} readOnly=${true} getTransport=${getTransport} showRefresh=${true} onRefreshIndex=${() => setRefreshTick((n) => n + 1)} /> `} ${viewMode === 'videos' && hasResults && html` <${VideoApp} groupId="search" transportRef=${defaultTRef} gekRef=${defaultGRef} status="connected" entries=${videoEntries} onPreview=${onPreview} videoDirectories=${[SEARCH_VIDEO_ROOT]} tmdbConfig=${{ enabled: true }} isNodeAdmin=${false} onNeedConn=${connectGroup} hideFilter=${true} /> `} ${viewMode === 'music' && hasResults && html` <${MusicApp} groupId="search" transportRef=${defaultTRef} gekRef=${defaultGRef} status="connected" entries=${musicEntries} musicDirectories=${[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} photoDirectories=${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 };