diff options
| -rw-r--r-- | CLAUDE.md | 10 | ||||
| -rw-r--r-- | docs/mediacenter.md | 27 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-app.js | 4 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/title_parse.py | 11 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_media_meta_request.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_season_and_search_requests.py | 10 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_title_parse.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_tmdb_language_fallback.py | 10 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_tmdb_override_policy.py | 12 |
9 files changed, 55 insertions, 45 deletions
@@ -780,3 +780,13 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: ## new rules, from now Documents and demo/comments are written in english unless requested in french. +No copyrighted names. Never put real brand names, trademarks, artist names, or +copyrighted titles (film titles, show titles, song/artist names, character +names, release-group handles) into code, comments, docstrings, test fixtures, +documentation, or commit messages — even when the bug being fixed or documented +was genuinely found against real content with real names. Describe the *shape* +instead ("a franchise-origin film", "a two-part saga", "a 3-season show") and +use invented placeholders in fixtures ("Some Saga", "A Different Show"). This +holds for every file, including throwaway test data and one-line commit +subjects. + diff --git a/docs/mediacenter.md b/docs/mediacenter.md index 3a6b733..7a712d9 100644 --- a/docs/mediacenter.md +++ b/docs/mediacenter.md @@ -317,7 +317,7 @@ real, currently-readable directory, or the request is refused immediately signing prompt. The client-side filter is a plain path-prefix test against `IndexEntry.path` -(already root-relative, e.g. `"Séries/OVNI/S1/ep01.mkv"`) — no new index +(already root-relative, e.g. `"Series/SomeShow/S1/ep01.mkv"`) — no new index field, no server-side filtering; the node still sends the whole index, and narrowing to the configured folder is exactly the same kind of view-over- the-index work Files' own current-folder filter already does. @@ -510,7 +510,7 @@ used *at all* used to live here too — moved per-group, below.) **Added 2026-08-24**: a third field, `tmdb_language` (e.g. `"fr-FR"`), travels the same way — one node-wide setting, not a per-viewer request. Live against a real French-language show, the node's TMDB matches came back -in English by default (`"UFOs"` for a show whose real title and audience +in English by default (an English title for a show whose real title and audience are French) purely because no language was ever requested; TMDB honors a `language` parameter on every search/details call, so `tmdb.py`'s `_get()` now attaches whichever language is configured (or none, TMDB's own English @@ -740,8 +740,8 @@ centralization was for (one computation, reused by every member). | V3 | TMDB metadata cache TTL (30 days proposed) | Needs no more than a config constant; not worth deciding without seeing real staleness complaints | | V4 | Multi-audio-track / subtitle-track surfacing in the detail view | Out of scope for this pass — `video-player.js`'s existing track handling is unchanged; Videos only adds discovery and metadata | | V5 | `Music`/`Photos` apps | Explicitly out of scope, per `apps.md` — this document only builds `video-app.js` and the shared node-side machinery (TMDB client, thumbnail cache, title parser) that a future audio/photo app could also reuse | -| ~~V6~~ | ~~Two folders of the same show, named by different release groups, can produce two separate poster-grid cards~~ | **Closed, 2026-08-24.** Rather than fuzzy title matching (real design decision, still deferred), `PosterGrid` now merges raw show-groups client-side once each group's TMDB lookup resolves to the same confident `tmdb_id` (`onMetaResolved` reports each `PosterCard`'s resolved meta upward; a `useMemo` groups by id, combining episodes/seasons into one card). This only merges what TMDB already agrees is one show — a genuinely unmatched show still gets its own card, which is correct. Confirmed live: `OVNIs.S01...`/`Ovnis-S01...` now render as a single "OVNI(s)" card with both seasons | -| ~~V7~~ | ~~A show's automatic TMDB match can be wrong at the show level (not just mis-ranked locally), and a wrong match's `overview` can read as scoped to one season~~ | **Closed, 2026-08-24 — §5.7.** Per-season tabs (own `overview`/`air_date`, falling back to the show-level text when empty) plus an operator-only "Fix match…" search-and-correct affordance, applied to every file sharing the resolved `display_title`. Confirmed live on the operator's real "War of the Worlds" 3-season show, itself matched to a wrong season-3-specific 1988 promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction | +| ~~V6~~ | ~~Two folders of the same show, named by different release groups, can produce two separate poster-grid cards~~ | **Closed, 2026-08-24.** Rather than fuzzy title matching (real design decision, still deferred), `PosterGrid` now merges raw show-groups client-side once each group's TMDB lookup resolves to the same confident `tmdb_id` (`onMetaResolved` reports each `PosterCard`'s resolved meta upward; a `useMemo` groups by id, combining episodes/seasons into one card). This only merges what TMDB already agrees is one show — a genuinely unmatched show still gets its own card, which is correct. Confirmed live: two release-group spellings of one show's season folders now render as a single card with both seasons | +| ~~V7~~ | ~~A show's automatic TMDB match can be wrong at the show level (not just mis-ranked locally), and a wrong match's `overview` can read as scoped to one season~~ | **Closed, 2026-08-24 — §5.7.** Per-season tabs (own `overview`/`air_date`, falling back to the show-level text when empty) plus an operator-only "Fix match…" search-and-correct affordance, applied to every file sharing the resolved `display_title`. Confirmed live on a real 3-season show, itself matched to a wrong season-specific promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction | ### 10.1 Movie-matching bugs — a batch found live (2026-08-29) @@ -788,16 +788,15 @@ file. A "this is a movie / a show" toggle would close that. ### 10.3 A whole saga matched to its first film (2026-08-30) -Every `Star Wars Episode <N> - <subtitle>` file resolved to the 1977 original (TMDB id 11). -Cause: `sequel_variants` stripped `Episode <N>` and offered the bare `Star Wars` as a -candidate query — which matches the 1977 film's `original_title` at ratio 1.0, beating -PASS 1's correct (but lower-ratio) hit. A franchise's bare name is very often a real, -different film. **Fixed** (`fix/starwars-saga-match`): when a `Part`/`Episode`/… keyword is -what carried the index, `sequel_variants` no longer emits the bare base — only -`<base> <digit>` and `<base> <roman>`. Without a keyword (`Jurassic Park 3`) the bare base -is still offered. Verified live: Star Wars I–VI now each resolve to their own episode; -Jurassic Park / Kill Bill / Bond regressions all hold. V12's movie merge, reverted the -same day, would have compounded this into one card for the saga. +Every `<Saga> Episode <N> - <subtitle>` file in a numbered franchise resolved to the +series' first entry. Cause: `sequel_variants` stripped `Episode <N>` and offered the bare +`<Saga>` as a candidate query — which matches the first film's `original_title` at ratio +1.0, beating PASS 1's correct (but lower-ratio) hit. A franchise's bare name is very often +a real, different film. **Fixed** (`fix/saga-match`): when a `Part`/`Episode`/… +keyword is what carried the index, `sequel_variants` no longer emits the bare base — only +`<base> <digit>` and `<base> <roman>`. Without a keyword (`<Franchise> 3`) the bare base is +still offered. Verified live against a numbered franchise's episodes plus the earlier +numbered-sequel / two-part-film / franchise-subtitle regressions. ## 11. Acceptance before shipping diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 5222afb..c0102e5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -627,7 +627,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable // Two raw groups (grouped by parsed display_title, §4.1) resolving to the // same confident TMDB id are almost certainly one show whose seasons // were released under differently-named folders — confirmed live: one - // operator's show had its two seasons parsed as "Ovni" and "OVNIs" by + // operator's show had its two seasons parsed as two spellings by // two different release groups, showing as two identical-looking cards // once both matched the same real show (§3.4/V6). Merged here once both // are actually known — never required for the fallback to work: a group @@ -735,7 +735,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable // Within a season group, every episode's own display_title is usually // just the show name again (guessit rarely finds a per-episode subtitle -// for this kind of release) — repeating "OVNI" twelve times in a row said +// for this kind of release) — repeating the show name twelve times in a row said // nothing an episode number wouldn't say better. Shown only when this row // is actually inside a season group (`seasonContext` set); a real, // distinct per-episode title (a show that *does* carry one) still wins diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py index 032dc58..beb0d3f 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py @@ -154,11 +154,12 @@ def sequel_variants(title: str) -> list[str]: index re-rendered as digit and as Roman numeral, plus (only when there is no "Part"/"Episode"/… keyword) the bare base. - The bare base is withheld for a keyword'd index — "Star Wars Episode - III" → "Star Wars" — because a franchise's bare name is very often a - real, *different* film (the 1977 original), and that variant matched - every episode to it (§10.1/V14). Without the keyword ("Jurassic Park 3") - the number is decoration and "Jurassic Park" is the right base to try. + The bare base is withheld for a keyword'd index — "<Saga> Chapter III" + → "<Saga>" — because a franchise's bare name is very often a real, + *different* film (the series' first entry), and that variant matched + every later entry to it (§10.1/V14). Without the keyword ("<Franchise> + 3") the number is decoration and the bare base is the right thing to + try. """ m = _TRAILING_INDEX_RE.match(title.strip()) if not m: diff --git a/packages/meshbay-node/tests/test_media_meta_request.py b/packages/meshbay-node/tests/test_media_meta_request.py index 0a3df12..9a1c5aa 100644 --- a/packages/meshbay-node/tests/test_media_meta_request.py +++ b/packages/meshbay-node/tests/test_media_meta_request.py @@ -93,7 +93,7 @@ async def test_two_episodes_in_the_same_season_folder_each_get_their_own_metadat season folder, and each must resolve against its own entry — not whichever one the index happens to return first for that folder.""" index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) - ep1 = _entry("shows/Show/Season 1", "s01e01.mkv", "id-1", "War of the Worlds") + ep1 = _entry("shows/Show/Season 1", "s01e01.mkv", "id-1", "Some Show") ep2 = _entry("shows/Show/Season 1", "s01e02.mkv", "id-2", "A Different Show") index.add_entry(ep1) index.add_entry(ep2) @@ -105,7 +105,7 @@ async def test_two_episodes_in_the_same_season_folder_each_get_their_own_metadat resp_1, resp_2 = session.sent assert resp_1["file_id"] == "id-1" - assert resp_1["title"] == "War of the Worlds" + assert resp_1["title"] == "Some Show" assert resp_2["file_id"] == "id-2" assert resp_2["title"] == "A Different Show" assert resp_1["tmdb_id"] != resp_2["tmdb_id"], ( diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py index 856797e..44d7da6 100644 --- a/packages/meshbay-node/tests/test_season_and_search_requests.py +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -161,9 +161,9 @@ async def test_search_movie_calls_movie_search_and_echoes_media_type(media_cache client = FakeTmdbClient() session = _session(media_cache=media_cache, tmdb_client=client) - await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "movie"}) + await session._do_tmdb_search_request({"query": "Some Show", "media_type": "movie"}) - assert client.movie_search_calls == ["War of the Worlds"] + assert client.movie_search_calls == ["Some Show"] assert client.tv_search_calls == [] resp = session.sent[0] assert resp["type"] == MNP.TMDB_SEARCH_RESP @@ -172,7 +172,7 @@ async def test_search_movie_calls_movie_search_and_echoes_media_type(media_cache "search for the same query are indistinguishable to the client's " "keyed response matching (transport.js tmdb_search_resp handler)") assert resp["results"] == [{ - "tmdb_id": "111", "title": "War of the Worlds", "year": "2019", + "tmdb_id": "111", "title": "Some Show", "year": "2019", "poster_thumb_hash": resp["results"][0]["poster_thumb_hash"], }] @@ -181,8 +181,8 @@ async def test_search_tv_calls_tv_search(media_cache): client = FakeTmdbClient() session = _session(media_cache=media_cache, tmdb_client=client) - await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "tv"}) + await session._do_tmdb_search_request({"query": "Some Show", "media_type": "tv"}) - assert client.tv_search_calls == ["War of the Worlds"] + assert client.tv_search_calls == ["Some Show"] assert client.movie_search_calls == [] assert session.sent[0]["media_type"] == "tv" diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py index 56f4e20..bbe9afa 100644 --- a/packages/meshbay-node/tests/test_title_parse.py +++ b/packages/meshbay-node/tests/test_title_parse.py @@ -95,12 +95,12 @@ def test_sequel_variants_reads_a_spelled_out_index(): assert "Story" not in v # keyword present -> no bare base -def test_sequel_variants_star_wars_shape_does_not_offer_the_bare_franchise(): - # Every "<Franchise> Episode <N>" was matching the 1977 original - # because the "Franchise" variant hit it at ratio 1.0 (§10.1/V14). - v = sequel_variants("Star Wars Episode III") - assert "Star Wars" not in v - assert "Star Wars 3" in v +def test_sequel_variants_saga_shape_does_not_offer_the_bare_franchise(): + # Every "<Saga> Chapter <N>" was matching the franchise's first entry + # because the bare "<Saga>" variant hit it at ratio 1.0 (§10.1/V14). + v = sequel_variants("Some Saga Chapter III") + assert "Some Saga" not in v + assert "Some Saga 3" in v def test_sequel_variants_ignores_a_trailing_word_that_is_not_an_index(): diff --git a/packages/meshbay-node/tests/test_tmdb_language_fallback.py b/packages/meshbay-node/tests/test_tmdb_language_fallback.py index 1a85531..52fe4c4 100644 --- a/packages/meshbay-node/tests/test_tmdb_language_fallback.py +++ b/packages/meshbay-node/tests/test_tmdb_language_fallback.py @@ -39,14 +39,14 @@ class FakeTmdbClient: async def test_empty_overview_falls_back_to_english_but_keeps_localized_poster(): localized = { - "title": "OVNI(s)", "original_title": "OVNI(s)", + "title": "Une Série", "original_title": "Une Série", "overview": "", # no French translation on TMDB for this field "poster_path": "/fr-poster.jpg", "backdrop_path": "/fr-backdrop.jpg", "genres": [{"name": "Comédie"}], "vote_average": 7.2, "first_air_date": "2016-01-01", } english = { - "title": "UFOs", "original_title": "OVNI(s)", + "title": "A Series", "original_title": "Une Série", "overview": "A real English overview.", "poster_path": "/en-poster.jpg", "backdrop_path": "/en-backdrop.jpg", "genres": [{"name": "Comedy"}], "vote_average": 7.2, @@ -67,7 +67,7 @@ async def test_empty_overview_falls_back_to_english_but_keeps_localized_poster() async def test_fully_populated_localized_response_never_triggers_a_fallback_call(): localized = { - "title": "OVNI(s)", "overview": "Un résumé complet en français.", + "title": "Une Série", "overview": "Un résumé complet en français.", "poster_path": "/fr-poster.jpg", "genres": [{"name": "Comédie"}], "vote_average": 7.2, } @@ -83,7 +83,7 @@ async def test_fully_populated_localized_response_never_triggers_a_fallback_call async def test_completely_untranslated_response_falls_back_entirely(): localized = {"overview": "", "poster_path": None, "genres": []} english = { - "title": "UFOs", "original_title": "OVNI(s)", + "title": "A Series", "original_title": "Une Série", "overview": "A real English overview.", "poster_path": "/en-poster.jpg", "genres": [{"name": "Comedy"}], "vote_average": 7.2, "release_date": "2016-01-01", @@ -95,4 +95,4 @@ async def test_completely_untranslated_response_falls_back_entirely(): assert meta["overview"] == "A real English overview." assert meta["poster_path"] == "/en-poster.jpg" assert meta["genres"] == ["Comedy"] - assert meta["title"] == "UFOs" + assert meta["title"] == "A Series" diff --git a/packages/meshbay-node/tests/test_tmdb_override_policy.py b/packages/meshbay-node/tests/test_tmdb_override_policy.py index 0717a0c..d434e1a 100644 --- a/packages/meshbay-node/tests/test_tmdb_override_policy.py +++ b/packages/meshbay-node/tests/test_tmdb_override_policy.py @@ -116,7 +116,7 @@ async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): async def test_a_valid_request_is_signed(tmp_path): session = _session(tmp_path, "op", operator="op") - entry = _entry("shared", "ep.mkv", "War of the Worlds") + entry = _entry("shared", "ep.mkv", "Some Show") session._ctx["index"].add_entry(entry) session._has_admin_authority = lambda: True issued = [] @@ -138,8 +138,8 @@ async def test_two_files_in_the_same_folder_are_told_apart(tmp_path): own, distinct entries. """ session = _session(tmp_path, "op", operator="op") - e1 = _entry("shared/Season 1", "s01e01.mkv", "War of the Worlds") - e2 = _entry("shared/Season 1", "s01e02.mkv", "War of the Worlds") + e1 = _entry("shared/Season 1", "s01e01.mkv", "Some Show") + e2 = _entry("shared/Season 1", "s01e02.mkv", "Some Show") session._ctx["index"].add_entry(e1) session._ctx["index"].add_entry(e2) session._has_admin_authority = lambda: True @@ -157,9 +157,9 @@ async def test_two_files_in_the_same_folder_are_told_apart(tmp_path): async def test_override_updates_every_entry_sharing_the_display_title(tmp_path): session = _session(tmp_path, "op", operator="op") index = session._ctx["index"] - s1 = _entry("shared/S1", "s01e01.mkv", "War of the Worlds") - s2 = _entry("shared/S2", "s02e01.mkv", "War of the Worlds") - s3 = _entry("shared/S3", "s03e02.mkv", "War of the Worlds") + s1 = _entry("shared/S1", "s01e01.mkv", "Some Show") + s2 = _entry("shared/S2", "s02e01.mkv", "Some Show") + s3 = _entry("shared/S3", "s03e02.mkv", "Some Show") other_show = _entry("shared/Other", "ep.mkv", "A Different Show") for e in (s1, s2, s3, other_show): index.add_entry(e) |