aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-28 15:35:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-28 15:35:58 +0200
commiteba2e6b14484c124f3c87ff95cd7ee640833e5d3 (patch)
treeba109b719c879df3ea40090e4fd6ff80b403a3a5 /packages/meshbay-hub/src/meshbay_hub/static/search-page.js
parente6917349c47cc4b7e04e1ecfab27c5291a5fe522 (diff)
downloadmeshbay-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.js560
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 };