aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 15:57:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 15:57:06 +0200
commit4d167e9f958d26c1db920274974ae446426928e3 (patch)
treee056e6b4fceca76ab67586548f0aa6ed80f925bc /packages/meshbay-node
parentbc40ab2c4486cca4ae63e33976151b5d7f856a84 (diff)
downloadmeshbay-4d167e9f958d26c1db920274974ae446426928e3.tar.gz
feat: V13 — per-card "re-match this one file" (MNP 0.13)
A one-click alternative to the full "Fix match" search-and-pick flow, and reachable without SSH (`meshbay-node video rematch` clears a whole group). - MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node logs "unknown type", the button just does nothing). OP_TMDB_REMATCH, signed like tmdb_override (media_cache is shared node-wide). - media_cache.drop_tmdb_match(file_id): forgets the match AND the override marker — deliberately stronger than clear_file_tmdb, since the operator is explicitly asking for a fresh resolution. - webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch + admin-response routing, broadcasts tmdb_rematch_ack. - transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack. - video-app.js: a "Re-match" button beside "Fix match" in the detail modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one key in all ten locales. docs/mediacenter.md §10.1: V8–V13 marked done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py49
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py13
-rw-r--r--packages/meshbay-node/tests/test_tmdb_rematch_policy.py147
4 files changed, 221 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 4600a09..8233270 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -175,6 +175,18 @@ class MediaCache:
"(SELECT file_id FROM tmdb_override)", (file_id,))
await self._db.commit()
+ async def drop_tmdb_match(self, file_id: str) -> None:
+ """
+ Full per-file reset: forget the match *and* any manual override
+ marker, so the next `media_meta_req` re-resolves from scratch with
+ the current matcher. This is the explicit operator "re-match this
+ one" action (§10.1/V13) — deliberately stronger than
+ `clear_file_tmdb`, which spares an override.
+ """
+ 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.commit()
+
async def clear_tmdb_matches(self, file_ids: list[str]) -> int:
"""
Drop the auto-resolved file->tmdb mappings for these files so the
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 c75819e..af6bf08 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -70,6 +70,7 @@ from meshbay_common.adminop import (
OP_TMDB_ENABLED,
OP_VIDEO_ROOT,
OP_TMDB_OVERRIDE,
+ OP_TMDB_REMATCH,
OP_MUSICBRAINZ_ENABLED,
OP_AUDIO_ROOT,
OP_PHOTO_ROOTS,
@@ -475,6 +476,8 @@ class WebRTCPeerSession:
self._spawn(self._do_tmdb_search_request(msg))
elif mtype == MNP.TMDB_OVERRIDE:
self._do_tmdb_override(msg)
+ elif mtype == MNP.TMDB_REMATCH:
+ self._do_tmdb_rematch(msg)
elif mtype == MNP.MUSICBRAINZ_ENABLED:
self._do_musicbrainz_enabled(msg)
elif mtype == MNP.MUSIC_META_REQ:
@@ -3171,6 +3174,49 @@ class WebRTCPeerSession:
except Exception:
pass
+ def _do_tmdb_rematch(self, msg: dict) -> None:
+ """
+ An operator dropping one file's cached TMDB match so it re-resolves
+ with the current matcher (§10.1/V13) — the one-click alternative to
+ the full search-and-pick "Fix match" flow, and reachable without
+ SSH (`meshbay-node video rematch` clears a whole group). Signed like
+ `tmdb_override`: `media_cache` is shared node-wide.
+ """
+ file_id = msg.get("file_id")
+ if not isinstance(file_id, str) or not file_id:
+ self._send({"type": "error", "detail": "Missing file_id"})
+ return
+ if not self._group_ctx()["index"].get_entry(file_id):
+ self._send({"type": "error", "detail": "File not found"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_TMDB_REMATCH, f"file_id={file_id}")
+
+ async def _admin_exec_tmdb_rematch(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ subject = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"tmdb_rematch:{subject}")
+ return
+ file_id = dict(part.split("=", 1) for part in subject.split(","))["file_id"]
+ media_cache = self._ctx.get("media_cache")
+ if media_cache is None:
+ self._send({"type": "error", "detail": "Media cache not available"})
+ return
+ await media_cache.drop_tmdb_match(file_id)
+ self._audit("tmdb_rematch", subject)
+
+ notice = {"type": MNP.TMDB_REMATCH_ACK, "v": MNP_VERSION, "file_id": file_id}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
async def _tmdb_search(self, tmdb_client, entry, is_show: bool):
"""
§3.3's retry ladder — same shape for movies and shows (§10.1/V8).
@@ -3874,6 +3920,9 @@ class WebRTCPeerSession:
elif pending["op"] == OP_TMDB_OVERRIDE:
self._spawn(
self._admin_exec_tmdb_override(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_TMDB_REMATCH:
+ self._spawn(
+ self._admin_exec_tmdb_rematch(pending, transcript, sig_bytes))
elif pending["op"] == OP_MUSICBRAINZ_ENABLED:
self._spawn(
self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes))
diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py
index 4e65b24..f12a366 100644
--- a/packages/meshbay-node/tests/test_media_cache.py
+++ b/packages/meshbay-node/tests/test_media_cache.py
@@ -107,6 +107,19 @@ async def test_clear_file_tmdb_drops_one_auto_match_but_keeps_an_override(cache)
@pytest.mark.asyncio
+async def test_drop_tmdb_match_forgets_both_the_match_and_the_override(cache):
+ await cache.set_file_tmdb("f", "10", "movie")
+ await cache.mark_tmdb_override("f")
+
+ await cache.drop_tmdb_match("f")
+
+ assert await cache.get_file_tmdb("f") is None
+ # override marker is gone: a fresh match is now treated as ordinary
+ await cache.set_file_tmdb("f", "20", "movie")
+ assert await cache.clear_tmdb_matches(["f"]) == 1
+
+
+@pytest.mark.asyncio
async def test_prune_file_also_clears_the_override_marker(cache):
await cache.set_file_tmdb("gone", "10", "movie")
await cache.mark_tmdb_override("gone")
diff --git a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
new file mode 100644
index 0000000..ff259c1
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
@@ -0,0 +1,147 @@
+"""
+`tmdb_rematch` (§10.1/V13) — an operator dropping one file's cached TMDB
+match so it re-resolves with the current matcher. Signed like
+`tmdb_override` (media_cache is shared node-wide); unlike `clear_file_tmdb`
+it forgets a manual override marker too, since the operator is explicitly
+asking for a fresh resolution.
+"""
+
+import hashlib
+
+import pytest
+from conftest import one_root
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.adminop import OP_TMDB_REMATCH
+from meshbay_common.protocol import MNP, IndexEntry
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path, user_id, *, operator=None):
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ s = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ s._ctx = {"roots": one_root(shared), "index": index, "sk_node": index.sk_node,
+ "node_user_id": operator}
+ s._group_id = None
+ s._user_id = user_id
+ s._pk_user = ""
+ s.sent = []
+ s._send = s.sent.append
+ s._audit = lambda *a, **k: None
+ return s
+
+
+def _entry(name):
+ digest = hashlib.sha256(name.encode()).hexdigest()
+ return IndexEntry(id=digest, name=name, path="movies", size=1, type="video",
+ added_at=0, display_title="Some Film")
+
+
+async def _true():
+ return True
+
+
+async def test_missing_file_id_is_refused(tmp_path):
+ s = _session(tmp_path, "op", operator="op")
+ s._has_admin_authority = lambda: True
+ issued = []
+ s._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ s._do_tmdb_rematch({})
+
+ assert not issued
+ assert [m for m in s.sent if m.get("type") == "error"]
+
+
+async def test_unknown_file_id_is_refused(tmp_path):
+ s = _session(tmp_path, "op", operator="op")
+ s._has_admin_authority = lambda: True
+ issued = []
+ s._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ s._do_tmdb_rematch({"file_id": "nope"})
+
+ assert not issued
+ assert [m for m in s.sent if m.get("type") == "error"]
+
+
+async def test_no_authorized_key_is_refused(tmp_path):
+ s = _session(tmp_path, "member", operator="the-operator")
+ e = _entry("some.film.2001.mkv")
+ s._ctx["index"].add_entry(e)
+ s._has_admin_authority = lambda: False
+
+ s._do_tmdb_rematch({"file_id": e.id})
+
+ assert [m for m in s.sent if m.get("type") == "error"]
+
+
+async def test_a_valid_request_is_signed(tmp_path):
+ s = _session(tmp_path, "op", operator="op")
+ e = _entry("some.film.2001.mkv")
+ s._ctx["index"].add_entry(e)
+ s._has_admin_authority = lambda: True
+ issued = []
+ s._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ s._do_tmdb_rematch({"file_id": e.id})
+
+ assert issued == [(OP_TMDB_REMATCH, f"file_id={e.id}")]
+
+
+async def test_exec_drops_the_match_and_the_override_marker(tmp_path):
+ s = _session(tmp_path, "op", operator="op")
+ e = _entry("some.film.2001.mkv")
+ s._ctx["index"].add_entry(e)
+
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ try:
+ s._ctx["media_cache"] = media_cache
+ await media_cache.set_file_tmdb(e.id, "wrong-id", "movie")
+ await media_cache.mark_tmdb_override(e.id)
+ s._verify_admin_sig = lambda transcript, sig: _true()
+ peer = type("Peer", (), {"sent": []})()
+ peer._send = peer.sent.append
+ s._peer_registry = lambda: {"peer-1": peer}
+
+ await s._admin_exec_tmdb_rematch(
+ {"subject": f"file_id={e.id}"}, b"transcript", b"sig")
+
+ assert await media_cache.get_file_tmdb(e.id) is None
+ # the override marker is gone too, so a later group-wide rematch
+ # would treat a fresh match as ordinary
+ await media_cache.set_file_tmdb(e.id, "fresh", "movie")
+ assert await media_cache.clear_tmdb_matches([e.id]) == 1
+ assert [m for m in peer.sent if m.get("type") == MNP.TMDB_REMATCH_ACK]
+ finally:
+ await media_cache.close()
+
+
+async def test_exec_refuses_a_bad_signature(tmp_path):
+ s = _session(tmp_path, "op", operator="op")
+ e = _entry("some.film.2001.mkv")
+ s._ctx["index"].add_entry(e)
+
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ try:
+ s._ctx["media_cache"] = media_cache
+ await media_cache.set_file_tmdb(e.id, "keep-me", "movie")
+
+ async def _false():
+ return False
+ s._verify_admin_sig = lambda transcript, sig: _false()
+
+ await s._admin_exec_tmdb_rematch(
+ {"subject": f"file_id={e.id}"}, b"transcript", b"badsig")
+
+ assert await media_cache.get_file_tmdb(e.id) == ("keep-me", "movie")
+ assert [m for m in s.sent if m.get("type") == "error"]
+ finally:
+ await media_cache.close()