summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_search_media_merge.py
blob: 65f85aaaadc79db4383970301e8a9ebe614cef3c (plain) (blame)
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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
"""
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:

  * the application's own grouping (`groupVideoEntries`, `groupMusicEntries`,
    `groupPhotoAlbums`) turns entries into films, shows, albums;
  * `videoUnits` / `musicUnits` / `photoUnits` (search-page.js) turn those into
    merge units;
  * `mergeUnitEntries` (source-merge.js) folds them on the content hash.

All of them are read out of their real sources here rather than restated. Each
pipeline is assembled the way `search-page.js` assembles it, and the result is
passed through the grouping a second time — which is what the application does
with it — so what this counts is what the grid renders.

`t()` is stubbed to return its key: `groupMusicEntries` uses it for the two
placeholder album names, and a string is not what is under test here.

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"
MUSIC_APP = STATIC / "music-app.js"
PHOTOS_APP = STATIC / "photos-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)


def _const(name):
    m = re.search(r"^const " + re.escape(name) + r" = .*?;$",
                  SEARCH_PAGE.read_text(), re.M)
    assert m, f"{name} moved — the search-page units cannot be lifted"
    return m.group(0)


@pytest.fixture(scope="module")
def pipeline():
    """Everything the three views need, in one script."""
    return "\n".join([
        "const t = (k) => k;",
        EXPORT.sub("", MERGE.read_text()),
        _block(VIDEO_APP, "function underVideoRoot(entry, directories) {"),
        _block(VIDEO_APP, "function buildSeasons(episodes) {"),
        _block(VIDEO_APP, "function groupVideoEntries(entries, videoDirectories) {"),
        _block(MUSIC_APP, "function foldKey(s) {"),
        _block(MUSIC_APP, "function underAudioRoot(entry, directories) {"),
        _block(MUSIC_APP, "function groupMusicEntries(entries, musicDirectories) {"),
        _block(PHOTOS_APP, "function underAnyPhotoRoot(entry, photoRoots) {"),
        _block(PHOTOS_APP, "function groupPhotoAlbums(entries, photoRoots) {"),
        _const("SEARCH_VIDEO_ROOT"),
        _const("SEARCH_AUDIO_ROOT"),
        _const("SEARCH_PHOTO_ROOTS"),
        _block(SEARCH_PAGE, "function videoUnits(entries) {"),
        _block(SEARCH_PAGE, "function musicUnits(entries) {"),
        _block(SEARCH_PAGE, "function photoUnits(entries) {"),
    ])


def _node(tmp_path, pipeline, body):
    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)


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}}`),
          }})),
        }})),
      }}));
    """
    return _node(tmp_path, pipeline, body)


def _albums(tmp_path, pipeline, entries, salt="reader", local=()):
    """What the album grid ends up with, after the merge and MusicApp's own
    regrouping."""
    body = f"""
      const raw = {json.dumps(entries)};
      const local = new Set({json.dumps(list(local))});
      const merged = mergeUnitEntries(musicUnits(raw), {{
        salt: {json.dumps(salt)},
        isLocal: (g) => local.has(g),
      }});
      const {{ albums, tracks }} = groupMusicEntries(merged, [SEARCH_AUDIO_ROOT]);
      console.log(JSON.stringify({{
        albums: albums.map((a) => ({{
          artist: a.artist, album: a.album,
          groups: [...new Set(a.tracks.map((e) => e.groupId))].sort(),
          tracks: a.tracks.map((e) => e.name),
        }})),
        loose: tracks.map((e) => e.name),
      }}));
    """
    return _node(tmp_path, pipeline, body)


def _photo_albums(tmp_path, pipeline, entries, salt="reader", local=()):
    """What the photo album grid ends up with, after the merge and PhotosApp's
    own regrouping."""
    body = f"""
      const raw = {json.dumps(entries)};
      const local = new Set({json.dumps(list(local))});
      const merged = mergeUnitEntries(photoUnits(raw), {{
        salt: {json.dumps(salt)},
        isLocal: (g) => local.has(g),
      }});
      console.log(JSON.stringify(
        groupPhotoAlbums(merged, SEARCH_PHOTO_ROOTS).map((a) => ({{
          dir: a.dir,
          groups: [...new Set(a.photos.map((e) => e.groupId))].sort(),
          photos: a.photos.map((e) => e.name),
        }}))));
    """
    return _node(tmp_path, pipeline, body)


# 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"]


# ── Music ────────────────────────────────────────────────────────────────────

def _record(group, tracks=5):
    """One album, tagged, under the search audio root."""
    return [{
        "id": f"t{n}", "name": f"{n:02d}-track.flac", "type": "audio",
        "path": "__search__/music/some-band/a-record", "size": 1,
        "artist": "Some Band", "album": "A Record", "track_no": n,
        "groupId": group, "groupName": group.upper(), "groupOwner": "someone",
        "_tRef": f"t:{group}", "_gRef": f"g:{group}", "_connGen": 1,
    } for n in range(1, tracks + 1)]


def test_an_album_shared_by_two_groups_lists_each_track_once(tmp_path, pipeline):
    grid = _albums(tmp_path, pipeline, _record("demo35") + _record("media"))
    assert len(grid["albums"]) == 1
    album = grid["albums"][0]
    assert len(album["tracks"]) == 5, (
        "a track is listed more than once — the same bug as the Videos view, "
        "inside an album")
    assert album["groups"] == ["demo35"] or album["groups"] == ["media"]


def test_one_group_of_music_is_unchanged(tmp_path, pipeline):
    grid = _albums(tmp_path, pipeline, _record("solo"))
    assert len(grid["albums"]) == 1
    assert len(grid["albums"][0]["tracks"]) == 5
    assert grid["albums"][0]["groups"] == ["solo"]


def test_a_differently_cased_tag_does_not_split_the_unit(tmp_path, pipeline):
    """
    `groupMusicEntries` folds case for grouping but keeps the first-seen
    spelling for display, and which group is seen first is whichever index
    arrived first. `musicUnits` keys on the folded form for exactly that
    reason — on the display strings, the chosen source could change between
    page loads.
    """
    other = _record("media")
    for track in other:
        track["artist"] = "SOME BAND"
        track["album"] = "a record"
    grid = _albums(tmp_path, pipeline, _record("demo35") + other)
    assert len(grid["albums"]) == 1
    assert len(grid["albums"][0]["tracks"]) == 5


def test_a_track_only_one_group_has_is_kept(tmp_path, pipeline):
    extra = _record("media")
    extra.append({
        "id": "t9", "name": "09-bonus.flac", "type": "audio",
        "path": "__search__/music/some-band/a-record", "size": 1,
        "artist": "Some Band", "album": "A Record", "track_no": 9,
        "groupId": "media", "groupName": "MEDIA", "groupOwner": "someone",
    })
    grid = _albums(tmp_path, pipeline, _record("demo35") + extra)
    assert len(grid["albums"][0]["tracks"]) == 6


def test_an_untagged_track_is_its_own_unit(tmp_path, pipeline):
    """A track with no artist at all never reaches an album bucket, so it has
    to be a unit of its own or it would be dropped from the merge entirely."""
    loose = [{
        "id": "x1", "name": "unknown.mp3", "type": "audio",
        "path": "__search__/music", "size": 1,
        "groupId": g, "groupName": g.upper(),
    } for g in ("demo35", "media")]
    grid = _albums(tmp_path, pipeline, _record("demo35") + loose)
    assert grid["loose"] == ["unknown.mp3"]


# ── Photos ───────────────────────────────────────────────────────────────────

def _photos(group, root="pics", album="a-trip", n=4):
    return [{
        "id": f"p{i}", "name": f"IMG_{i:04d}.jpg", "type": "image",
        "path": f"__search_photos__/{root}/{album}", "size": 1,
        "groupId": group, "groupName": group.upper(), "groupOwner": "someone",
        "_tRef": f"t:{group}", "_gRef": f"g:{group}", "_connGen": 1,
    } for i in range(1, n + 1)]


def test_a_photo_album_shared_by_two_groups_shows_each_photo_once(tmp_path, pipeline):
    albums = _photo_albums(tmp_path, pipeline, _photos("demo35") + _photos("media"))
    assert len(albums) == 1
    assert len(albums[0]["photos"]) == 4
    assert len(albums[0]["groups"]) == 1


def test_two_differently_named_albums_both_keep_the_photo(tmp_path, pipeline):
    """
    Documented consequence, not a bug: photo albums are keyed by directory, so
    two groups whose roots have different basenames are two albums, and a
    photo in both belongs in both.
    """
    albums = _photo_albums(
        tmp_path, pipeline, _photos("demo35", root="pics") + _photos("media", root="images"))
    assert len(albums) == 2
    assert all(len(a["photos"]) == 4 for a in albums)


def test_one_group_of_photos_is_unchanged(tmp_path, pipeline):
    albums = _photo_albums(tmp_path, pipeline, _photos("solo"))
    assert len(albums) == 1
    assert albums[0]["groups"] == ["solo"]
    assert len(albums[0]["photos"]) == 4