diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-29 15:12:32 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-29 15:12:32 +0200 |
| commit | 5a88f8d7a0a35b0c75b0a56d0f6fce1d0a495c12 (patch) | |
| tree | cbc0a8207885476cd0d331db399536c259ca4f80 /packages/meshbay-node/src | |
| parent | be57cf9c3b499c8e59a94d13059f16f1456fcae1 (diff) | |
| parent | 71b7a310ce938f072fe20f27eeeadd40685f1ad1 (diff) | |
| download | meshbay-5a88f8d7a0a35b0c75b0a56d0f6fce1d0a495c12.tar.gz | |
Merge branch 'fix/videos-tmdb-matching'
Diffstat (limited to 'packages/meshbay-node/src')
6 files changed, 205 insertions, 24 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index c708ec6..266693a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -448,6 +448,7 @@ class NodeDaemon: # enricher (Pillow, not ffmpeg/mutagen). No credential, no # third-party client to construct: EXIF is read locally. self._photo_enricher = PhotoEnricher(self._media_cache) + self._state["media_cache"] = self._media_cache log.info("Media cache opened: %s", media_cache_db) # 5. Denylist @@ -1223,6 +1224,14 @@ class NodeDaemon: if old is None or (old.name == entry.name and old.path == entry.path): continue self._enriched_attempted.discard((indexer.group_id, entry.id)) + # The rename re-derives the title (the whole point of this + # method), which can change the correct TMDB match — but + # file_tmdb is keyed by content hash, unchanged by a rename, so + # nothing else would ever dislodge the old name's match. A + # manual "Fix match" correction is kept (clear_file_tmdb skips + # anything in media_cache.tmdb_override). + if self._media_cache is not None: + await self._media_cache.clear_file_tmdb(entry.id) await self._enrich_new_video_entries(indexer, updates) async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None: @@ -1581,21 +1590,22 @@ def main() -> None: parser.add_argument("command", nargs="?", choices=["init", "reset", "status", "ui", "gek-init", "gek", "operator", "member", "group", "file", - "denylist", "reload", "restart-daemon", + "video", "denylist", "reload", "restart-daemon", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " - "| denylist show|clear | reload: re-read node.toml " + "| video rematch: re-resolve TMDB matches for a group's " + "videos | denylist show|clear | reload: re-read node.toml " "(systemctl --user reload) | restart-daemon: restart " "the systemd unit (systemctl --user restart) " "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " "member; list|add|remove for group; init|rotate for gek; " - "list|rm for file; show|clear for denylist") + "list|rm for file; rematch for video; show|clear for denylist") parser.add_argument("target", nargs="?", help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " @@ -1620,8 +1630,8 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", - "member", "group", "file", "denylist", "reload", - "restart-daemon", "reset") + "member", "group", "file", "video", "denylist", + "reload", "restart-daemon", "reset") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -2112,6 +2122,24 @@ def main() -> None: print("usage: meshbay-node file list|rm <id> [--group NAME] [--yes]") sys.exit(1) + if args.command == "video": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + if (args.subcommand or "") != "rematch": + print("usage: meshbay-node video rematch [--group NAME] [--yes]") + sys.exit(1) + group_id = _resolve_group(cfg, args.group) + if not args.yes: + print("Re-resolve every automatic TMDB match for this group's videos?") + print("Manual 'Fix match' corrections are kept. Re-resolution is lazy —") + print("each poster re-queries TMDB the next time it is opened.") + if input("proceed? [y/N] ").strip().lower() not in ("y", "yes"): + print("cancelled") + return + out = _daemon_api(cfg, f"/api/groups/{group_id}/video/rematch", method="POST") + print(f"cleared {out.get('removed', 0)} automatic match(es) " + f"across {out.get('videos', 0)} video file(s)") + return + if args.command == "group": if args.subcommand in (None, "list"): # Milestone 14.2. 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 24b423e..24c23bc 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py @@ -146,6 +146,16 @@ def parse_movie_filename(filename: str) -> ParsedName: title = str(title).strip() if title else None if title: title = _strip_editions(title) + # guessit peels a "Volume 2"/"Part 2" token off the title into its + # own field, so both parts of a two-part film parse to the same bare + # title. That collapsed the two on one TMDB search (the more popular + # first part won for both), and — since "Fix match" groups every + # entry sharing a display_title — left no way to correct one without + # the other. Fold the number back on so the two stay distinct in the + # query, the card and the override. + part = g.get("part") or g.get("volume") + if isinstance(part, int) and not isinstance(part, bool): + title = f"{title} {part}" alt = g.get("alternative_title") alt = _strip_editions(str(alt).strip()) if alt else None year = g.get("year") diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 707a046..4600a09 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -34,6 +34,14 @@ CREATE TABLE IF NOT EXISTS file_tmdb ( tmdb_id TEXT NOT NULL, media_type TEXT NOT NULL ); +-- Files whose match was set by an explicit operator "Fix match" +-- correction, not by the automatic matcher. `ops.rematch_video` and a +-- rename's re-enrichment wipe the *auto-resolved* file->tmdb mappings so +-- they re-resolve against the current matcher; a manual correction must +-- survive that, so it is recorded here and skipped. +CREATE TABLE IF NOT EXISTS tmdb_override ( + file_id TEXT PRIMARY KEY +); CREATE TABLE IF NOT EXISTS tmdb_meta ( tmdb_id TEXT NOT NULL, media_type TEXT NOT NULL, @@ -144,6 +152,46 @@ class MediaCache: ) await self._db.commit() + # ── manual "Fix match" corrections vs auto-resolved matches ────────────── + + async def mark_tmdb_override(self, file_id: str) -> None: + """Record that this file's current match is an explicit operator + correction — `clear_file_tmdb` / `clear_tmdb_matches` skip it.""" + await self._db.execute( + "INSERT OR IGNORE INTO tmdb_override (file_id) VALUES (?)", (file_id,)) + await self._db.commit() + + async def clear_file_tmdb(self, file_id: str) -> None: + """ + Drop one file's *auto-resolved* match. Used on rename: the new name + re-derives the title, so the old name's match no longer applies — + but file_tmdb is keyed by content hash, unchanged by a rename, so + nothing else would ever dislodge it. A manual "Fix match" + correction is kept: the content, hence what the operator corrected, + is the same. + """ + await self._db.execute( + "DELETE FROM file_tmdb WHERE file_id = ? AND file_id NOT IN " + "(SELECT file_id FROM tmdb_override)", (file_id,)) + await self._db.commit() + + async def clear_tmdb_matches(self, file_ids: list[str]) -> int: + """ + Drop the auto-resolved file->tmdb mappings for these files so the + next `media_meta_req` re-resolves each against the current matcher + (`ops.rematch_video`, run by the operator after a matcher/parser + fix). Manual "Fix match" corrections (`tmdb_override`) are left in + place. Returns the number of rows removed. + """ + if not self._db or not file_ids: + return 0 + marks = ",".join("?" * len(file_ids)) + cur = await self._db.execute( + f"DELETE FROM file_tmdb WHERE file_id IN ({marks}) AND file_id NOT IN " + "(SELECT file_id FROM tmdb_override)", file_ids) + await self._db.commit() + return cur.rowcount + # ── tmdb id -> metadata json ───────────────────────────────────────────── async def get_tmdb_meta(self, tmdb_id: str, media_type: str) -> dict | None: @@ -324,5 +372,6 @@ class MediaCache: await self._db.execute("DELETE FROM photo_meta WHERE file_id = ?", (file_id,)) await self._db.execute("DELETE FROM video_meta WHERE file_id = ?", (file_id,)) await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,)) + await self._db.execute("DELETE FROM tmdb_override WHERE file_id = ?", (file_id,)) await self._db.execute("DELETE FROM file_mbid WHERE file_id = ?", (file_id,)) await self._db.commit() diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index cb8e01e..c921b05 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -974,6 +974,31 @@ async def prune_index_cache(state: dict) -> dict: return {"status": "pruned", "removed": len(stale), "kept": len(paths) - len(stale)} +# ── Videos: force TMDB re-matching ─────────────────────────────────────────── +# +# `media_cache.file_tmdb` is keyed by a file's content hash and is otherwise +# only pruned on deletion, so a fixed matcher/parser never dislodges a match +# already in cache. This drops a group's *auto-resolved* mappings so the +# next `media_meta_req` for each poster tile re-resolves against the current +# code. Re-resolution is lazy and calls TMDB once per unique title — real +# API budget — so this is an explicit operator action, never a background job. +# Manual "Fix match" corrections (media_cache.tmdb_override) are kept. + +async def rematch_video(state: dict, group_id: str) -> dict: + media_cache = state.get("media_cache") + if media_cache is None: + raise OpError("No media cache in this process", status=503) + indexer = (state.get("indexers") or {}).get(group_id) + if indexer is None: + raise OpError("Unknown group", status=404) + file_ids = [e.id for e in indexer.index.entries if e.type == "video"] + removed = await media_cache.clear_tmdb_matches(file_ids) + log.info("Video rematch for group %s: %d auto match(es) cleared across %d video file(s)", + group_id[:8], removed, len(file_ids)) + return {"status": "cleared", "removed": removed, "videos": len(file_ids), + "group_id": group_id} + + # ── Reload ────────────────────────────────────────────────────────────────── async def reload_config(state: dict) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index dd68e18..788a8f1 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -3076,11 +3076,13 @@ class WebRTCPeerSession: video_root/tmdb_config: it replaces what every member sees for a show/movie, node-wide (media_cache is shared, not per-viewer). - Applied to every entry sharing the representative file's - display_title — the same grouping the poster grid itself uses - (§3.4/§V6) — not just the one file the operator happened to be - looking at, so the correction actually sticks regardless of which - episode a future render picks as representative. + For a **show**, applied to every entry sharing the representative + file's display_title — the same grouping the poster grid uses + (§3.4/§V6) — so the correction sticks regardless of which episode a + future render picks as representative. For a **movie** it is applied + to that one file only: guessit gives a whole franchise the same + display_title, and a fan-out there corrected the wrong films (found + live, 2026-08-29). See `_admin_exec_tmdb_override`. Keyed by `file_id`, not `path` — see `_do_media_meta_request`'s docstring for why a folder-level path cannot name one file. @@ -3140,11 +3142,25 @@ class WebRTCPeerSession: if tmdb_client is not None: meta = await self._tmdb_build_meta(tmdb_client, tmdb_id, media_type, {}) await media_cache.set_tmdb_meta(tmdb_id, media_type, meta) - target_title = entry.display_title or entry.name - matched = [e for e in ctx["index"].entries - if e.type == "video" and (e.display_title or e.name) == target_title] + # A show's episodes are many files that legitimately share one match, + # and which episode a render picks as representative rotates — so a + # show override fans out across every entry with the same + # display_title. A *movie* is one file: fanning out by display_title + # there is a bug — guessit gives every + # "<franchise> - <year> - <subtitle>.mkv" the same display_title, so + # "Fix match" on one entry rewrote the whole franchise (found live, + # 2026-08-29). Each corrected file is also marked as a manual + # override so ops.rematch_video / a rename never wipe it. + is_show = entry.season is not None and entry.episode is not None + if is_show: + target_title = entry.display_title or entry.name + matched = [e for e in ctx["index"].entries + if e.type == "video" and (e.display_title or e.name) == target_title] + else: + matched = [entry] for e in matched: await media_cache.set_file_tmdb(e.id, tmdb_id, media_type) + await media_cache.mark_tmdb_override(e.id) self._audit("tmdb_override", subject) notice = {"type": MNP.TMDB_OVERRIDE_ACK, "v": MNP_VERSION, "file_id": file_id, @@ -3157,9 +3173,22 @@ class WebRTCPeerSession: async def _tmdb_search(self, tmdb_client, entry, is_show: bool): """ - §3.3's retry ladder: the parsed title first, then a couple of - generic, non-per-title fallbacks — never re-ranking TMDB's own - top result locally (§3.3's last row). + §3.3's retry ladder. TMDB's own top result is still trusted per + query (§3.3's last row — no local re-ranking of *its* list); what + changed is that the ladder now *scores every candidate query* and + keeps the best, instead of returning the first that merely clears + 0.6. + + The bare parsed title is the weakest query: guessit drops a + "Volume 2", strips a real subtitle into `alternative_title`, and + renders a sequel number where TMDB uses a Roman numeral. A wrong + film that happened to score ~0.7 against that weak query — a + same-year making-of documentary, or a franchise entry whose + localized TMDB title *is* the franchise name — used to win outright + before `alternative_title` or the Roman-numeral variant was ever + tried. Found live (2026-08-29): a numbered sequel matched a + same-year documentary; a two-volume film's second part matched the + first; several franchise entries matched one early entry. """ from meshbay_node.indexer import title_parse @@ -3172,21 +3201,57 @@ class WebRTCPeerSession: result, ratio = await tmdb_client.search_tv(naive) return result, ratio + def _release_year(res: dict) -> int | None: + d = str(res.get("release_date") or res.get("first_air_date") or "") + return int(d[:4]) if d[:4].isdigit() else None + parsed = title_parse.parse_movie_filename(entry.name) title = entry.display_title or parsed.display_title or parsed.naive_title + + # Fast path, unchanged in effect: a strong direct hit still returns + # on the first call, so the common case costs exactly one request + # and the new ladder below only engages in the ambiguous 0<ratio<0.85 + # zone where every one of the live bugs lived. result, ratio = await tmdb_client.search_movie(title, parsed.year) - if result is not None and ratio >= 0.6: + if result is not None and ratio >= 0.85: return result, ratio - for candidate in filter(None, [parsed.alt_title, parsed.naive_title, - *title_parse.sequel_variants(title)]): + + best_result, best_score = (result, ratio) if result is not None else (None, 0.0) + + def _apply_year_rescue(res: dict, r: float) -> float: + # Only for a query whose own top hit is weak on its face + # (ratio < 0.6): TMDB already year-filtered the search, so its + # top result landing exactly on the filename's year is a hard + # corroborating signal that the low string ratio is a + # localized/rearranged title, not a wrong film. Never lets year + # equality outrank a genuinely strong textual match elsewhere. + if r < 0.6 and parsed.year and _release_year(res) == parsed.year: + return max(r, 0.6) + return r + + if best_result is not None: + best_score = _apply_year_rescue(best_result, best_score) + + for candidate in filter(None, [parsed.alt_title, + *title_parse.sequel_variants(title), + parsed.naive_title]): if candidate == title: continue - result2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year) - if result2 is not None and ratio2 > ratio: - result, ratio = result2, ratio2 - if ratio >= 0.6: + r2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year) + if r2 is None and parsed.year: + # A year-filtered search that finds nothing: the filename's + # year tag may be an edition/regional year TMDB doesn't + # carry. Retry the same candidate unconstrained before + # dropping it. + r2, ratio2 = await tmdb_client.search_movie(candidate) + if r2 is None: + continue + score2 = _apply_year_rescue(r2, ratio2) + if score2 > best_score: + best_result, best_score = r2, score2 + if best_score >= 0.85: break - return result, ratio + return best_result, best_score @staticmethod async def _tmdb_build_meta(tmdb_client, tmdb_id: str, media_type: str, result: dict) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index dfd9f68..514a143 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -207,6 +207,10 @@ def create_ui_app(state: dict) -> FastAPI: async def api_index_cache_prune(): return await _op(lambda: ops.prune_index_cache(state)) + @app.post("/api/groups/{group_id}/video/rematch") + async def api_video_rematch(group_id: str): + return await _op(lambda: ops.rematch_video(state, group_id)) + @app.get("/api/groups/{group_id}/files") async def api_group_files(group_id: str): groups_ctx = state.get("groups_ctx", {}) |