aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
diff options
context:
space:
mode:
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.js79
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 --