1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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"]
|