summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 15:12:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 15:12:32 +0200
commit5a88f8d7a0a35b0c75b0a56d0f6fce1d0a495c12 (patch)
treecbc0a8207885476cd0d331db399536c259ca4f80 /packages/meshbay-node/src/meshbay_node
parentbe57cf9c3b499c8e59a94d13059f16f1456fcae1 (diff)
parent71b7a310ce938f072fe20f27eeeadd40685f1ad1 (diff)
downloadmeshbay-5a88f8d7a0a35b0c75b0a56d0f6fce1d0a495c12.tar.gz
Merge branch 'fix/videos-tmdb-matching'
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py38
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py10
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py49
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py103
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py4
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", {})