diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-02 15:23:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-02 15:23:30 +0200 |
| commit | 313b72f15e8788ba3abcd3e44b5f7785fbc779fe (patch) | |
| tree | 1c8e9b024d78384da950730b37b8d41c2789e010 /packages/meshbay-hub/tests/test_search_video_merge.py | |
| parent | 15d3eec914cf5e474e66f615c9fbaf602eebe575 (diff) | |
| download | meshbay-313b72f15e8788ba3abcd3e44b5f7785fbc779fe.tar.gz | |
fix(hub): one entry per file in the Search view's Videos grid
A library shared by two groups arrived in the cross-group Search view as
two entries per file: every film was two poster cards, every episode was
listed twice in the season list under the synopsis. Inside one group
this cannot happen — GroupIndex is keyed by blake3 — so the duplication
was the Search page's own, from concatenating N independently keyed
indexes.
source-merge.js folds entries on the content hash and resolves one
source per *unit* (a film, a whole show), so a season does not scatter
across two nodes. A group hosted by the reader's own node wins; failing
that the pick is a hash of the unit key and the reader's id, stable
across renders and reloads — a source that changed mid-stream would tear
down the connection under a film that is playing — and spread across
readers and units.
The units come from video-app.js's own groupVideoEntries rather than a
second copy of its keys here. Only the Videos view is wired up so far;
Music, Photos, failover and the "N sources" badge are phases 5-8 of
docs/refactoring-search.md.
Every test was checked against the fix removed. That is how the first
version of "a unit's files share its source" turned out to prove
nothing: with every episode in every group, per-file and per-unit
picking give the same answer, so it passed against a per-file
implementation. It now uses a unit whose files have unequal sources.
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, 185 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_search_video_merge.py b/packages/meshbay-hub/tests/test_search_video_merge.py new file mode 100644 index 0000000..e9ab258 --- /dev/null +++ b/packages/meshbay-hub/tests/test_search_video_merge.py @@ -0,0 +1,185 @@ +""" +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"] |