aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-02 16:21:48 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-02 16:21:48 +0200
commit10f8266e7152d7dc38dbfe2449327829bf020ad1 (patch)
tree5e72ac45b2e79008812663e251a4fcab62dc0650 /packages/meshbay-hub/src/meshbay_hub/static/search-page.js
parent313b72f15e8788ba3abcd3e44b5f7785fbc779fe (diff)
downloadmeshbay-10f8266e7152d7dc38dbfe2449327829bf020ad1.tar.gz
fix(hub): merge duplicate sources in Search's Music and Photos too
Phases 5-8 of docs/refactoring-search.md, extending the Videos merge outward. A library shared by two groups now lists each track once inside an album and each photo once inside a photo album, and a card served by several groups says "N sources" instead of naming one of them. Units come from each application's own grouping, never a copy of its keys. For Music that meant exporting foldKey: groupMusicEntries folds case to group but keeps the first-seen spelling to display, and which group is seen first is whichever index arrived first — so keying a unit on the display strings would let the chosen source change between page loads. A group whose connection fails is marked down and stops being chosen, so a unit fails over to another group that has the file. Eviction is not a failure. Every source being down still yields an entry: a tile that fails to load beats a film that vanished from the grid. sourceLabel now takes the whole unit rather than one entry. A show's poster entry is picked for its thumbnail, so a show in two groups whose cover episode sits in only one of them would have claimed a single source. SourceTag lives in group-name.js — source-merge.js must keep importing nothing (its test executes it standalone), and a copy in each of the three apps is three chances to disagree. test_search_files_unmerged.py holds the one thing that must not change: the Files explorer is not merged, because there each group is a folder and merging would remove a file from one of them. It also asserts the other three lists are merged, or deleting the merge outright would leave it passing and saying nothing. One plan item was dropped as wrong rather than built: the Music queue in onPreview needed no change. It filters by groupId and is reachable only from FilesPanel, which is not merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbwJDbNTkiRUh7HTWEoyss
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 --