diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-02 16:21:48 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-02 16:21:48 +0200 |
| commit | 10f8266e7152d7dc38dbfe2449327829bf020ad1 (patch) | |
| tree | 5e72ac45b2e79008812663e251a4fcab62dc0650 /packages/meshbay-hub/tests/test_search_video_merge.py | |
| parent | 313b72f15e8788ba3abcd3e44b5f7785fbc779fe (diff) | |
| download | meshbay-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/tests/test_search_video_merge.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_search_video_merge.py | 185 |
1 files changed, 0 insertions, 185 deletions
diff --git a/packages/meshbay-hub/tests/test_search_video_merge.py b/packages/meshbay-hub/tests/test_search_video_merge.py deleted file mode 100644 index e9ab258..0000000 --- a/packages/meshbay-hub/tests/test_search_video_merge.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -The reported symptom, end to end: one library shared by two groups. - -`test_search_source_merge.py` holds the merging rules in isolation. This holds -the thing an operator actually saw — a node hosting two groups that were given -the same video directory, and a Search view showing every film as two poster -cards and every episode twice inside a show. - -Three pieces have to agree for that to come out right, and each lives in a -different file: - - * `groupVideoEntries` (video-app.js) turns entries into films and shows; - * `videoUnits` (search-page.js) turns those into merge units; - * `mergeUnitEntries` (source-merge.js) folds them on the content hash. - -All three are read out of their real sources here rather than restated. The -pipeline is assembled the way `search-page.js` assembles it, and then the -result is passed through `groupVideoEntries` a second time — which is what -`VideoApp` does with it — so what this counts is what the grid renders. - -See docs/refactoring-search.md. -""" - -import json -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -VIDEO_APP = STATIC / "video-app.js" -SEARCH_PAGE = STATIC / "search-page.js" -MERGE = STATIC / "source-merge.js" - -pytestmark = pytest.mark.skipif( - shutil.which("node") is None or not MERGE.exists(), - reason="node or the SPA sources are not available") - -EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M) - - -def _block(path, header): - """One top-level `function name(...) {` ... `}` read out of a module.""" - src = path.read_text() - m = re.search(r"^" + re.escape(header) + r".*?^\}", src, re.M | re.S) - assert m, ( - f"{header} is no longer where this test reads it from in {path.name} — " - "the Search view's de-duplication is untested until this is fixed") - return m.group(0) - - -@pytest.fixture(scope="module") -def pipeline(): - root = re.search(r"^const SEARCH_VIDEO_ROOT = .*?;$", SEARCH_PAGE.read_text(), re.M) - assert root, "SEARCH_VIDEO_ROOT moved — videoUnits cannot be lifted" - return "\n".join([ - EXPORT.sub("", MERGE.read_text()), - _block(VIDEO_APP, "function underVideoRoot(entry, videoRoot) {"), - _block(VIDEO_APP, "function buildSeasons(episodes) {"), - _block(VIDEO_APP, "function groupVideoEntries(entries, videoRoot) {"), - root.group(0), - _block(SEARCH_PAGE, "function videoUnits(entries) {"), - ]) - - -def _grid(tmp_path, pipeline, entries, salt="reader", local=()): - """What the poster grid ends up with, after the merge and VideoApp's own - regrouping.""" - body = f""" - const raw = {json.dumps(entries)}; - const local = new Set({json.dumps(list(local))}); - const merged = mergeUnitEntries(videoUnits(raw), {{ - salt: {json.dumps(salt)}, - isLocal: (g) => local.has(g), - }}); - const {{ movies, shows }} = groupVideoEntries(merged, SEARCH_VIDEO_ROOT); - console.log(JSON.stringify({{ - movies: movies.map((e) => ({{ - id: e.id, title: e.display_title || e.name, groupId: e.groupId, - sources: e._sources.length, - }})), - shows: shows.map((s) => ({{ - title: s.title, - groups: [...new Set(s.episodes.map((e) => e.groupId))].sort(), - seasons: s.seasons.map((x) => ({{ - season: x.season, - episodes: x.episodes.map((e) => `S${{e.season}}E${{e.episode}}`), - }})), - }})), - }})); - """ - script = tmp_path / "case.js" - script.write_text(f"{pipeline}\n{body}\n") - out = subprocess.run( - ["node", str(script)], capture_output=True, text=True, timeout=30) - assert out.returncode == 0, out.stderr - return json.loads(out.stdout) - - -# The shape of one shared library: a film, and a two-season show. Invented -# titles — the real one this was found against is nobody's business here. -def _library(group): - """`path` is already prefixed the way search-page.js prefixes it.""" - def entry(file_id, name, **kw): - return { - "id": file_id, "name": name, "type": "video", - "path": "__search__/shows", "size": 1, - "groupId": group, "groupName": group.upper(), "groupOwner": "someone", - "_tRef": f"t:{group}", "_gRef": f"g:{group}", "_connGen": 1, - **kw, - } - files = [entry("film1", "a-film.mkv", display_title="Some Film")] - for season in (1, 2): - for ep in (1, 2, 3): - files.append(entry( - f"s{season}e{ep}", f"show.s0{season}e0{ep}.mkv", - display_title="Some Saga", season=season, episode=ep)) - return files - - -def test_a_shared_library_is_listed_once(tmp_path, pipeline): - """ - The bug as reported: two groups, one directory, everything twice. - """ - both = _library("demo35") + _library("media") - grid = _grid(tmp_path, pipeline, both) - - assert [m["title"] for m in grid["movies"]] == ["Some Film"] - assert grid["movies"][0]["sources"] == 2 - - assert len(grid["shows"]) == 1 - show = grid["shows"][0] - assert [s["season"] for s in show["seasons"]] == [1, 2] - for season in show["seasons"]: - assert season["episodes"] == [ - f"S{season['season']}E{n}" for n in (1, 2, 3)], ( - "an episode is listed more than once — this is the reported bug, " - "in the season list under the synopsis") - - -def test_a_show_streams_from_one_source(tmp_path, pipeline): - """A season split across two nodes would open two connections and two - metadata lookups for one show.""" - both = _library("demo35") + _library("media") - show = _grid(tmp_path, pipeline, both)["shows"][0] - assert len(show["groups"]) == 1 - - -def test_the_operators_own_node_serves_it(tmp_path, pipeline): - """Both groups are on the operator's node in the reported case; when only - one is, that one is the source.""" - both = _library("remote") + _library("mine") - grid = _grid(tmp_path, pipeline, both, local=["mine"]) - assert grid["movies"][0]["groupId"] == "mine" - assert grid["shows"][0]["groups"] == ["mine"] - - -def test_one_group_is_unchanged(tmp_path, pipeline): - """The overwhelmingly common case: nothing to merge, nothing different.""" - grid = _grid(tmp_path, pipeline, _library("solo")) - assert [m["title"] for m in grid["movies"]] == ["Some Film"] - assert grid["movies"][0]["sources"] == 1 - assert grid["movies"][0]["groupId"] == "solo" - show = grid["shows"][0] - assert show["groups"] == ["solo"] - assert sum(len(s["episodes"]) for s in show["seasons"]) == 6 - - -def test_an_episode_only_one_group_has_is_kept(tmp_path, pipeline): - """ - Merging must never subtract. A group holding one extra episode contributes - it, whichever source the show settled on. - """ - extra = _library("media") - extra.append({ - "id": "s2e4", "name": "show.s02e04.mkv", "type": "video", - "path": "__search__/shows", "size": 1, - "groupId": "media", "groupName": "MEDIA", "groupOwner": "someone", - "display_title": "Some Saga", "season": 2, "episode": 4, - }) - grid = _grid(tmp_path, pipeline, _library("demo35") + extra) - season2 = [s for s in grid["shows"][0]["seasons"] if s["season"] == 2][0] - assert season2["episodes"] == ["S2E1", "S2E2", "S2E3", "S2E4"] |