aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py
blob: 9222ad8fb2dbe70374075cc1fc12ff2c72eab46d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
"""What the node does for the Videos app's catalogue: TMDB matching, posters,
season pages, and the operator's TMDB switches."""

import logging
import time

import blake3
from meshbay_common import MNP_VERSION
from meshbay_common.adminop import (
    OP_TMDB_CONFIG,
    OP_TMDB_ENABLED,
    OP_TMDB_OVERRIDE,
    OP_TMDB_REMATCH,
)
from meshbay_common.protocol import MNP

from meshbay_node import ops

log = logging.getLogger("meshbay_node.transport.webrtc_server")


# A free-text TMDB search spends the *operator's* credential, which is rated by
# TMDB and shared by everyone in the group: one member typing in the search box
# can exhaust what every other member's automatic matching depends on, and the
# operator is the one who has to notice. §6.5's rule is a bound and a named
# adversary in the same commit; this one arrived without either.
#
# Per member rather than per connection, unlike link previews: three tabs
# is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the
# group context so it survives a reconnect, which is the other thing a per-session
# count cannot do.
#
# Generous next to what a person types — ten searches a minute is a search every
# six seconds, sustained — and small next to a loop.
_TMDB_SEARCH_WINDOW = 60.0
_TMDB_SEARCH_PER_MEMBER = 10
_TMDB_SEARCH_NODE = 30


class VideoMetaMixin:
    def _do_tmdb_config(self, msg: dict) -> None:
        """
        Optionally set (or clear) a custom TMDB API token, and optionally set
        the language TMDB is queried in (e.g. "fr-FR") — one for the whole
        node, since both are one operator's shared credential/cache, not a
        per-group concern (see _do_tmdb_enabled for the per-group on/off
        switch). Signed like the rest: this changes outbound third-party
        network traffic the node did not have before the Videos app
        (docs/MESHBAY_DESIGN.md §9.7, §6.5) — an unsigned change would let any
        member alter egress the operator never agreed to.
        """
        token = msg.get("token")
        if token is not None and not isinstance(token, str):
            self._send({"type": "error", "detail": "Invalid 'token'"})
            return
        language = msg.get("language")
        if language is not None and not isinstance(language, str):
            self._send({"type": "error", "detail": "Invalid 'language'"})
            return
        if not self._has_admin_authority():
            self._send({"type": "error", "detail": "No authorized key for this"})
            return
        # The subject is the signed, audited, human-shown string — it must
        # never contain the token itself (it would end up in the audit log
        # in plaintext). The actual token travels only in `payload`, which
        # is node-side context, never re-sent or re-verified from the wire.
        # The language is not a secret, so it travels in the subject itself.
        subject = f"custom_token={'yes' if token else 'no'},language={language or 'default'}"
        self._issue_admin_challenge(
            OP_TMDB_CONFIG, subject,
            payload={"token": token, "language": language},
            group_id="")

    async def _admin_exec_tmdb_config(
        self, pending: dict, transcript: bytes, sig: bytes,
    ) -> None:
        if not await self._verify_admin_sig(transcript, sig):
            self._send({"type": "error", "detail": "Signature verification failed"})
            self._audit("admin_auth_failed", f"tmdb_config:{pending['subject']}")
            return
        p = pending.get("payload") or {}
        try:
            result = await self._run_op(
                ops.set_tmdb_config, p.get("token"), p.get("language"))
        except ops.OpError as e:
            self._send({"type": "error", "detail": e.message})
            return
        self._audit("tmdb_config", pending["subject"])

        # Node-wide setting: every connected peer in every group is told, not
        # just this group's peers (unlike apps_enabled/the root ops/the
        # per-group tmdb_enabled below).
        notice = {
            "type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION,
            "token_customized": result["token_customized"],
            "language": result["language"],
        }
        for gctx in self._ctx.get("groups", {}).values():
            for session in list(gctx.get("_peers", {}).values()):
                try:
                    session._send(notice)
                except Exception:
                    pass

    def _do_tmdb_enabled(self, msg: dict) -> None:
        """
        Whether TMDB lookups run for this group at all. Per-group, unlike
        tmdb_config's token/language — see ops.set_tmdb_enabled. Signed like
        app_directories: it decides whether this group's members' Videos tab ever
        makes outbound TMDB traffic.
        """
        enabled = msg.get("enabled")
        if not isinstance(enabled, bool):
            self._send({"type": "error", "detail": "Missing or invalid 'enabled'"})
            return
        if not self._has_admin_authority():
            self._send({"type": "error", "detail": "No authorized key for this"})
            return
        self._issue_admin_challenge(OP_TMDB_ENABLED, str(enabled))

    async def _admin_exec_tmdb_enabled(
        self, pending: dict, transcript: bytes, sig: bytes,
    ) -> None:
        enabled = pending["subject"] == "True"
        if not await self._verify_admin_sig(transcript, sig):
            self._send({"type": "error", "detail": "Signature verification failed"})
            self._audit("admin_auth_failed", f"tmdb_enabled:{pending['subject']}")
            return
        try:
            await self._run_op(ops.set_tmdb_enabled, self._group_id or "", enabled)
        except ops.OpError as e:
            self._send({"type": "error", "detail": e.message})
            return
        self._audit("tmdb_enabled", pending["subject"])

        notice = {"type": MNP.TMDB_ENABLED_ACK, "v": MNP_VERSION, "enabled": enabled}
        for uid, session in list(self._peer_registry().items()):
            try:
                session._send(notice)
            except Exception:
                pass

    @staticmethod
    async def _fetch_and_cache_poster(media_cache, tmdb_client,
                                      poster_path: str | None) -> str | None:
        """
        Downloads a TMDB poster/backdrop once, caches it under its own
        blake3 like a video thumbnail (docs/MESHBAY_DESIGN.md §9.7), and
        returns the hash a client then fetches via the normal file_req/
        chunk path (§5.3) — no client ever contacts image.tmdb.org directly.

        Checked by the synthetic `tmdb:{poster_path}` id *before* touching
        the network: without this, every `media_meta_req` for an
        already-cached file re-downloaded the same poster from TMDB (found
        live — a poster grid re-fetched both a show's poster and backdrop
        from TMDB on every single visit, real added latency and needless
        outbound traffic for an image that never changes).
        """
        if not poster_path:
            return None
        synthetic_id = f"tmdb:{poster_path}"
        cached_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id)
        if cached_hash is not None:
            return cached_hash
        content = await tmdb_client.fetch_image(tmdb_client.poster_url(poster_path))
        if content is None:
            return None
        thumb_hash = blake3.blake3(content).hexdigest()
        await media_cache.put_thumb(thumb_hash, synthetic_id, content)
        return thumb_hash

    async def _do_media_meta_request(self, msg: dict) -> None:
        """
        docs/MESHBAY_DESIGN.md §9.7: TMDB metadata for one file, resolved from
        the group's index by its content id (root+relpath the client already
        knows from index_sync/index_delta identify the entry; its own `id`
        is what actually names one file — never a raw filesystem path off
        the wire).

        Keyed by `file_id`, not `path`: `IndexEntry.path` is the *folder* a
        file is in (indexer.py's `_virtual_dir`), so two files in the same
        folder — any multi-episode season, routinely — shared the same
        `.path`, and a lookup by it could silently resolve to the wrong
        entry (found live via the Music app's identical bug, 2026-08-25).
        """
        file_id = msg.get("file_id")
        log.debug("media_meta_req file_id=%r", file_id)
        if not isinstance(file_id, str) or not file_id:
            self._send({"type": "error", "detail": "Missing file_id"})
            return
        ctx = self._group_ctx()
        entry = ctx["index"].get_entry(file_id)
        if not entry:
            self._send({"type": "error", "detail": "File not found"})
            return

        media_cache = self._ctx.get("media_cache")
        tmdb_client = self._ctx.get("tmdb_client")
        # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24):
        # treated exactly like "no client configured" — same silent, no-error
        # degradation, since a member's Videos tab already has to handle "no
        # TMDB match" as the ordinary case.
        if media_cache is None or tmdb_client is None or not ctx.get("tmdb_enabled", True):
            self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION,
                       "file_id": file_id, "confidence": 0})
            return

        # A video the indexer has seen but not yet *enriched* has no
        # display_title (enrich.py always sets one) and season/episode still
        # None — so the movie/show split reads "movie" and would hand its raw
        # filename to TMDB's movie search. During a slow initial scan with a
        # browser on the Videos tab that is a storm of
        # `search/movie?query=<raw filename>` (found live 2026-08-29, an
        # 8-minute scan). While un-enriched we never *search*: we serve a
        # cached match if there is one (§ below), else confidence 0 and the
        # client refetches once the index delta carries the enriched fields.
        enriched = bool(entry.display_title)

        is_show = entry.season is not None and entry.episode is not None
        media_type = "tv" if is_show else "movie"

        cached = await media_cache.get_file_tmdb(entry.id)
        meta = None
        tmdb_id = None
        if cached is not None:
            cached_tmdb_id, cached_media_type = cached
            # Serve the cached match when its kind still agrees with the
            # entry's current classification — OR when the entry is not
            # enriched yet: its season/episode aren't populated, so the
            # movie/show split above is not meaningful, and the cached kind
            # (set when this file WAS enriched) is the reliable one. This is
            # what keeps a restart from re-querying TMDB for everything
            # already resolved: the storm was an un-enriched show episode
            # looking like a "movie" and treating its own valid "tv" match
            # as stale.
            #
            # Once enriched, the strict `cached_media_type == media_type`
            # check still stands: an enrichment fix that reclassifies a
            # folder movie->tv must drop the stale movie-era match and
            # re-resolve (found live — a Specials-folder fix left hundreds
            # of files answering with their wrong-kind match forever).
            if cached_media_type == media_type or not enriched:
                media_type = cached_media_type
                is_show = media_type == "tv"
                tmdb_id = cached_tmdb_id
                meta = await media_cache.get_tmdb_meta(tmdb_id, media_type)

        if meta is None:
            if not enriched:
                self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION,
                           "file_id": file_id, "confidence": 0})
                return
            result, ratio = await self._tmdb_search(tmdb_client, entry, is_show)
            if result is None or ratio < 0.6:
                self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION,
                           "file_id": file_id, "confidence": 0})
                return
            tmdb_id = str(result["id"])
            meta = await self._tmdb_build_meta(tmdb_client, tmdb_id, media_type, result)
            await media_cache.set_file_tmdb(entry.id, tmdb_id, media_type)
            await media_cache.set_tmdb_meta(tmdb_id, media_type, meta)

        poster_thumb_hash = await self._fetch_and_cache_poster(
            media_cache, tmdb_client, meta.get("poster_path"))
        backdrop_thumb_hash = await self._fetch_and_cache_poster(
            media_cache, tmdb_client, meta.get("backdrop_path"))
        log.debug("media_meta_req file_id=%r: replying tmdb_id=%s poster=%s backdrop=%s",
                  file_id, tmdb_id, poster_thumb_hash, backdrop_thumb_hash)

        resp = {
            "type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, "file_id": file_id,
            "tmdb_id": tmdb_id, "title": meta.get("title"),
            "original_title": meta.get("original_title"),
            "overview": meta.get("overview"),
            "poster_thumb_hash": poster_thumb_hash,
            "backdrop_thumb_hash": backdrop_thumb_hash,
            "release_date": meta.get("release_date"),
            "first_air_date": meta.get("first_air_date"),
            "genres": meta.get("genres", []),
            "vote_average": meta.get("vote_average"),
            "runtime": meta.get("runtime"),
            "cast": meta.get("cast", []),
            "director": meta.get("director"),
            "confidence": meta.get("confidence", 1.0),
        }
        if is_show:
            resp["season"] = entry.season
            resp["episode"] = entry.episode
        self._send(resp)

    async def _do_season_meta_request(self, msg: dict) -> None:
        """
        Per-season TMDB overview/poster/air_date for a multi-season show —
        found live: `media_meta_resp`'s one static show-level overview does
        not necessarily describe every season alike (a season-3-specific
        promotional summary applied to all three seasons of a show).
        `tmdb_id` is whatever the client's own prior `media_meta_resp`
        already resolved — never re-derived from a path here, so this
        never re-runs a TMDB search of its own.
        """
        tmdb_id = msg.get("tmdb_id")
        season = msg.get("season")
        if not isinstance(tmdb_id, str) or not tmdb_id or not isinstance(season, int):
            self._send({"type": "error", "detail": "Missing tmdb_id or season"})
            return
        media_cache = self._ctx.get("media_cache")
        tmdb_client = self._ctx.get("tmdb_client")
        # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24) —
        # same silent zero-confidence degradation as "no client configured".
        if (media_cache is None or tmdb_client is None
                or not self._group_ctx().get("tmdb_enabled", True)):
            self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
                       "tmdb_id": tmdb_id, "season": season, "confidence": 0})
            return

        details = await media_cache.get_season_meta(tmdb_id, season)
        if details is None:
            fetched = await tmdb_client.tv_season(tmdb_id, season)
            if fetched is None:
                self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
                           "tmdb_id": tmdb_id, "season": season, "confidence": 0})
                return
            # Same per-field English fallback as _tmdb_build_meta: TMDB
            # returns "" for an untranslated field rather than falling back
            # itself.
            if not fetched.get("overview"):
                fallback = await tmdb_client.tv_season(tmdb_id, season, language="en-US") or {}
                fetched = {**fallback,
                           **{k: v for k, v in fetched.items()
                              if v not in (None, "", [])}}
            await media_cache.set_season_meta(tmdb_id, season, fetched)
            details = fetched

        poster_thumb_hash = await self._fetch_and_cache_poster(
            media_cache, tmdb_client, details.get("poster_path"))
        self._send({
            "type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
            "tmdb_id": tmdb_id, "season": season, "confidence": 1.0,
            "name": details.get("name"),
            "overview": details.get("overview"),
            "air_date": details.get("air_date"),
            "poster_thumb_hash": poster_thumb_hash,
        })

    async def _do_tmdb_search_request(self, msg: dict) -> None:
        """
        Candidate TMDB matches for an operator correcting a wrong automatic
        match (docs/MESHBAY_DESIGN.md §9.7, §V-whatever this becomes) — a plain
        lookup, not a mutation, so unlike `tmdb_override` this needs no
        admin authority: any member can see what TMDB itself would offer,
        the same as the automatic search already silently does on their
        behalf. Only `tmdb_override` actually changes what everyone sees.
        """
        query = msg.get("query")
        media_type = msg.get("media_type")
        if not isinstance(query, str) or not query.strip() or media_type not in ("movie", "tv"):
            self._send({"type": "error", "detail": "Missing query or media_type"})
            return
        media_cache = self._ctx.get("media_cache")
        tmdb_client = self._ctx.get("tmdb_client")
        # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24) —
        # same silent empty-results degradation as "no client configured":
        # a member with TMDB off for this group sees the same "type it in
        # yourself" affordance either way, never an error.
        if (media_cache is None or tmdb_client is None
                or not self._group_ctx().get("tmdb_enabled", True)):
            self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
                       "query": query, "media_type": media_type, "results": []})
            return

        # Refused out loud, not as an empty result: "no matches" is what the
        # client draws for an empty list, and telling somebody their film is
        # unknown when the node simply declined to ask is a worse answer than
        # the truth. `video-app.js`'s `runSearch` puts `detail` on screen.
        if not self._tmdb_search_rate_ok():
            log.info("tmdb_search_req: rate-limited (user=%s)", (self._user_id or "")[:8])
            self._send({
                "type": "error",
                "detail": "Too many searches in the last minute. This spends the "
                          "operator's search quota, which everyone in the group "
                          "shares — try again shortly.",
                "code": "tmdb_search_rate_limited",
            })
            return

        raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
              else await tmdb_client.search_tv_results(query))
        results = []
        for r in raw:
            poster_thumb_hash = await self._fetch_and_cache_poster(
                media_cache, tmdb_client, r.get("poster_path"))
            results.append({
                "tmdb_id": str(r.get("id")),
                "title": r.get("title") or r.get("name"),
                "year": (r.get("release_date") or r.get("first_air_date") or "")[:4],
                "poster_thumb_hash": poster_thumb_hash,
            })
        # media_type echoed back, not just query: a client can fire a "war"
        # tv search and a "war" movie search close together, and without it
        # the two responses are indistinguishable for keyed matching
        # (transport.js's tmdb_search_resp handler).
        self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
                   "query": query, "media_type": media_type, "results": results})

    def _do_tmdb_override(self, msg: dict) -> None:
        """
        An operator correcting a wrong automatic TMDB match. Signed like
        app_directories/tmdb_config: it replaces what every member sees for a
        show/movie, node-wide (media_cache is shared, not per-viewer).

        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.
        """
        file_id = msg.get("file_id")
        tmdb_id = msg.get("tmdb_id")
        media_type = msg.get("media_type")
        if not isinstance(file_id, str) or not file_id:
            self._send({"type": "error", "detail": "Missing file_id"})
            return
        if not isinstance(tmdb_id, str) or not tmdb_id or media_type not in ("movie", "tv"):
            self._send({"type": "error", "detail": "Missing tmdb_id or media_type"})
            return
        ctx = self._group_ctx()
        entry = ctx["index"].get_entry(file_id)
        if not entry:
            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
        subject = f"file_id={file_id},tmdb_id={tmdb_id},media_type={media_type}"
        self._issue_admin_challenge(OP_TMDB_OVERRIDE, subject)

    async def _admin_exec_tmdb_override(
        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_override:{subject}")
            return
        fields = dict(part.split("=", 1) for part in subject.split(","))
        file_id, tmdb_id, media_type = fields["file_id"], fields["tmdb_id"], fields["media_type"]

        ctx = self._group_ctx()
        entry = ctx["index"].get_entry(file_id)
        media_cache = self._ctx.get("media_cache")
        if entry is None or media_cache is None:
            self._send({"type": "error", "detail": "File or media cache not available"})
            return
        # This only ever recorded the file->tmdb_id mapping, never the
        # metadata tmdb_id names — _do_media_meta_request's cache check
        # (entry, cache) both agree on media_type, so it trusted the
        # mapping — but found nothing under this *new* id in tmdb_meta
        # (nothing had ever fetched it), and silently fell through to a
        # fresh search using the file's own title, exactly the one that
        # produced the wrong match in the first place. Confirmed live: an
        # override "stuck" for shows only because their own title happened
        # to be enough for that fallback search to land on the right
        # answer anyway, coincidentally — never because the override itself
        # was actually being honored — and was invisible until a movie
        # whose own title search kept landing on the same wrong result
        # exposed it. Fetching and storing the real metadata up front is
        # what makes the *override* the thing a later lookup finds.
        tmdb_client = self._ctx.get("tmdb_client")
        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)
        # 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,
                 "tmdb_id": tmdb_id, "media_type": media_type}
        for uid, session in list(self._peer_registry().items()):
            try:
                session._send(notice)
            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 (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):
        """
        docs/MESHBAY_DESIGN.md §9.7's scored ladder — same shape for movies
        and shows (V8).
        TMDB's own top result is still trusted per query (§3.3's last row —
        no local re-ranking of *its* list); what the ladder adds is that it
        *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`, renders
        a sequel index where TMDB spells it differently, and a show's folder
        name can carry a year or release-group noise. A wrong entry that
        scored ~0.7 against that weak query — a same-year making-of
        documentary, a franchise entry whose localized TMDB title *is* the
        franchise name, a season-specific promo entry standing in for a
        whole show — used to win outright before a stronger candidate was
        ever tried. Found live (2026-08-29).
        """
        from meshbay_node.indexer import title_parse

        if is_show:
            title = entry.display_title or title_parse.naive_title(entry.name)
            name_naive = title_parse.naive_title(entry.name)
            year = title_parse.year_in(title) or title_parse.year_in(entry.name)
            extra = [c for c in (name_naive, title_parse.clean_query(title))
                     if c and c != title]
            return await self._tmdb_ladder(
                tmdb_client.search_tv, title, extra, year, strong_extra=False)

        parsed = title_parse.parse_movie_filename(entry.name)
        title = entry.display_title or parsed.display_title or parsed.naive_title
        strong = [c for c in (parsed.alt_title, *title_parse.sequel_variants(title)) if c]
        extra = [c for c in (*strong, parsed.naive_title) if c and c != title]
        return await self._tmdb_ladder(
            tmdb_client.search_movie, title, extra, parsed.year,
            strong_extra=bool(strong))

    @staticmethod
    async def _tmdb_ladder(search_fn, primary: str, extra: list[str],
                           year: int | None, strong_extra: bool):
        """
        `search_fn(query, year) -> (result|None, ratio)`. Try `primary`,
        return at once on a confident hit (ratio >= 0.85 — the common case,
        one request). Otherwise score each `extra` candidate and keep the
        best. `strong_extra` says whether `extra` contains anything more
        specific than a punctuation-normalised restatement of `primary`
        (an alternative_title, a sequel variant); when it does not and the
        primary hit is already decent, the remaining calls are skipped
        (V11 — they almost never win and cost a round trip each).
        """
        def _year_of(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

        def _rescue(res: dict, r: float) -> float:
            # A sub-0.6 hit whose result lands on the exact requested year:
            # TMDB already year-filtered the search, so this is a hard
            # corroboration that the low ratio is a localised/rearranged
            # title, not a wrong entry. Never overrides a confident hit.
            if r < 0.6 and year and _year_of(res) == year:
                return max(r, 0.6)
            return r

        result, ratio = await search_fn(primary, year)
        if result is not None and ratio >= 0.85:
            return result, ratio
        best_result, best_score = (result, ratio) if result is not None else (None, 0.0)
        if best_result is not None:
            best_score = _rescue(best_result, best_score)
        if best_score >= 0.6 and not strong_extra:
            return best_result, best_score

        for candidate in extra:
            if candidate == primary:
                continue
            r2, ratio2 = await search_fn(candidate, year)
            if r2 is None and year:
                # A year-filtered search that finds nothing: the year tag
                # may be an edition/regional year TMDB doesn't carry. Retry
                # the candidate unconstrained before dropping it.
                r2, ratio2 = await search_fn(candidate, None)
            if r2 is None:
                continue
            score2 = _rescue(r2, ratio2)
            if score2 > best_score:
                best_result, best_score = r2, score2
            if best_score >= 0.85:
                break
        return best_result, best_score

    @staticmethod
    async def _tmdb_build_meta(tmdb_client, tmdb_id: str, media_type: str, result: dict) -> dict:
        """
        `result` (the search hit) only carries `genre_ids` and no `runtime`
        at all — the full details endpoint is the actual source for those,
        falling back to the search result for anything details somehow
        lacks (never expected in practice, just avoids a KeyError-shaped
        surprise if TMDB's response ever varies).
        """
        details = (await tmdb_client.tv_details(tmdb_id) if media_type == "tv"
                  else await tmdb_client.movie_details(tmdb_id)) or result
        # TMDB doesn't fall back server-side for a field with no translation
        # in the configured language — it returns "" (or an empty list) for
        # it, not the English text (confirmed live: a French query left
        # `overview` empty for a title TMDB has no French translation for).
        # The TMDB website covers exactly this gap client-side, by falling
        # back to English per field rather than discarding an otherwise-good
        # localized response over one empty one — mirrored here the same
        # way, at field granularity, not by abandoning the whole response.
        if (not details.get("overview") or not details.get("poster_path")
                or not details.get("genres")):
            fallback = (await tmdb_client.tv_details(tmdb_id, language="en-US")
                        if media_type == "tv"
                       else await tmdb_client.movie_details(tmdb_id, language="en-US")) or {}
            details = {**fallback, **{k: v for k, v in details.items() if v not in (None, "", [])}}
        credits = (await tmdb_client.tv_credits(tmdb_id) if media_type == "tv"
                  else await tmdb_client.movie_credits(tmdb_id))
        cast = [{"name": c.get("name"), "character": c.get("character")}
               for c in (credits or {}).get("cast", [])[:10]]
        director = None
        if media_type == "movie":
            director = next(
                (c.get("name") for c in (credits or {}).get("crew", [])
                 if c.get("job") == "Director"), None)
        else:
            # A series has no single director, and `tv_credits`' crew is the
            # aggregate one — routinely empty, and never a "Director" job.
            # TMDB models the equivalent credit as `created_by` on the show
            # itself, which is what its own page shows; several creators are
            # ordinary, and they read as one line in the detail modal.
            director = ", ".join(
                name for name in
                (c.get("name") for c in details.get("created_by") or [])
                if name) or None
        runtime = details.get("runtime")
        if runtime is None and media_type == "tv":
            episode_run_times = details.get("episode_run_time") or []
            runtime = episode_run_times[0] if episode_run_times else None
        return {
            "title": details.get("title") or details.get("name"),
            "original_title": details.get("original_title") or details.get("original_name"),
            "overview": details.get("overview"),
            "poster_path": details.get("poster_path"),
            "backdrop_path": details.get("backdrop_path"),
            "release_date": details.get("release_date"),
            "first_air_date": details.get("first_air_date"),
            "genres": [g.get("name") for g in details.get("genres", []) if g.get("name")],
            "vote_average": details.get("vote_average"),
            "runtime": runtime,
            "cast": cast,
            "director": director,
        }

    def _tmdb_search_rate_ok(self) -> bool:
        """
        True when this search is within both the member's window and the node's;
        records it when so, and trims both to the window on every call so neither
        list can grow without bound.

        Both are checked because they answer different questions: the member's
        keeps one person from spending everyone's quota, and the node's keeps a
        group of them from doing it together.
        """
        now = time.monotonic()
        w = _TMDB_SEARCH_WINDOW
        ctx = self._group_ctx()
        by_member = ctx.setdefault("tmdb_search_hits", {})
        who = self._user_id or ""
        mine = [t for t in by_member.get(who, []) if now - t < w]
        node = [t for t in self._ctx.get("tmdb_search_hits_node", []) if now - t < w]
        if len(mine) >= _TMDB_SEARCH_PER_MEMBER or len(node) >= _TMDB_SEARCH_NODE:
            by_member[who] = mine
            self._ctx["tmdb_search_hits_node"] = node
            return False
        mine.append(now)
        node.append(now)
        by_member[who] = mine
        self._ctx["tmdb_search_hits_node"] = node
        return True