diff options
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 | 79 |
1 files changed, 69 insertions, 10 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 index 5897acb..608bb81 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -9,8 +9,8 @@ import { } from './hub-client.js'; import { FilesPanel, FilePreview } from './files-app.js'; import { VideoApp, groupVideoEntries } from './video-app.js'; -import { MusicApp } from './music-app.js'; -import { PhotosApp } from './photos-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'; @@ -233,6 +233,30 @@ function videoUnits(entries) { ]; } +// 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: [] }); @@ -254,6 +278,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // 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); @@ -301,6 +327,27 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // -- 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); @@ -308,7 +355,14 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) } 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); + 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 @@ -339,7 +393,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // module-wide bumps stay for an operator's TMDB override/rematch only. setConnectionGen((g) => g + 1); return entry; - }, [token, username, userId]); + }, [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 @@ -431,6 +485,11 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) 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 @@ -460,7 +519,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) return mergeUnitEntries(videoUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); - // Music view: pre-filtered by audioRoot, path-prefixed + // Music view: pre-filtered by audioRoot, path-prefixed, then merged per album const musicEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { @@ -483,10 +542,10 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) }); } } - return result; - }, [indexedGroups, q, matchesQuery, connectionGen]); + return mergeUnitEntries(musicUnits(result), mergeOpts); + }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); - // Photos view: pre-filtered by photoRoots, path-prefixed + // Photos view: pre-filtered by photoRoots, path-prefixed, then merged per album const photoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { @@ -510,8 +569,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) }); } } - return result; - }, [indexedGroups, q, matchesQuery, connectionGen]); + return mergeUnitEntries(photoUnits(result), mergeOpts); + }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // -- Callbacks -- |