""" 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/MESHBAY_DESIGN.md §9.11. """ 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