diff options
51 files changed, 4433 insertions, 125 deletions
diff --git a/docs/apps.md b/docs/apps.md index 94116c6..931114d 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -6,7 +6,7 @@ > document is the how-to. A group has "applications" — Chat and Files today, Videos/Music/Photos planned -(Netflix-style browsing, Spotify-style playback, an album viewer). None of the +(a poster-grid browser, a music player, an album viewer). None of the planned ones need an MNP protocol change: video/audio/image files are already classified by the node's indexer (`meshbay_node/indexer/indexer.py`, `type: video|audio|image`) and flow through the same `index_sync`/`file_req`/ @@ -172,18 +172,24 @@ the only node-side touches, and both are allow-lists, not new wire messages. ## 5. What does not exist yet -- **Thumbnails/posters.** No generation mechanism, client or node side. A - Netflix-style grid or a photo album needs *something* here; the pragmatic - v1 (decided but not built) is lazy, client-side: decrypt the image, or a - video's first segment, only for tiles actually visible, with an in-memory - cache — no new MNP message, no node-side store. This is real per-tile cost - (a full chunk-pipelined decrypt per thumbnail), acceptable for a browsing - grid, not for hundreds of tiles rendered at once — a virtualized grid is - part of building Videos/Photos, not optional. -- **Videos, Music, Photos themselves.** Deliberately out of scope for the - refactor that built this — see `meshbay-draft-v6.md` §2.7. The - infrastructure above is proven end-to-end with the two apps that already - existed (Chat, Files); a new one is additive. +- **Thumbnails/posters — built for Videos, 2026-08-23, see `docs/mediacenter.md`.** + The plan below (lazy, client-side, no node-side store) turned out to be + wrong once a real design pass ran the numbers: `docs/mediacenter.md` §2 + revises `desktop-client-v1.md`'s O12 and has the node generate thumbnails + (an `ffmpeg` frame grab, its own bounded worker pool) and cache them + durably in its own `data_dir`, delivered over the existing `file_req`/ + chunk path addressed by their own blake3 hash. TMDB posters/metadata are + fetched and cached by the node the same way — no client ever talks to + TMDB directly. A virtualized grid (`IntersectionObserver`-based lazy + mount) is built in `video-app.js`, per the note below. A future Photos + app can reuse the same node-side machinery (thumbnail cache, chunk-path + delivery) without re-deciding any of this. +- **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`, + `docs/mediacenter.md`). Music and Photos remain deliberately out of scope + — see `meshbay-draft-v6.md` §2.7. The infrastructure in this document was + proven end-to-end first with Chat/Files, then with Videos; Music/Photos + are additive from here, and can reuse Videos' thumbnail/chunk-delivery + machinery rather than re-deciding it. - **The offline/loopback settings path.** `member_upload` can be toggled two ways: over a live MNP connection, or (Electron only) via the node's local HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member- diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md index 08dd548..c11837c 100644 --- a/docs/desktop-client-v1.md +++ b/docs/desktop-client-v1.md @@ -733,6 +733,14 @@ Detecting it differs by platform, and neither method is exotic: ### 6.10 The planned libraries are views, not a catalogue +> **Revised 2026-08-23 for the Videos group app** — see `docs/mediacenter.md` §2. +> The "no thumbnail store, ever" conclusion below (O12) was an extension by +> analogy to draft-v5 §5.2, not a direct consequence of it. The Videos app +> now caches thumbnails and TMDB metadata durably in the node's own +> `data_dir` — still never inside a shared root, and still no per-file +> identity beyond the file's own content hash. Everything else in this +> section stands: views over the index, not a catalogue. + **Scope settled 2026-08-17, and it is deliberately small.** The planned "video library" and "audio library" are **alternative views over the existing file index** — a variant of the explorer. Presentation rests on the filename and the directory structure, plus a @@ -1124,7 +1132,7 @@ Two rules came out of building it and both are easy to undo by accident: | O10 | Canonical file identity across filesystems | Case folding and NFC normalization decide when two names are "the same file". It affects the index, the no-overwrite rule and cross-platform replication, so it must be defined once and shared (§6.8) | | O11 | Root alias when the basename cannot be used | Duplicate basenames across volumes will be common, and a basename can fail the portability rules. Refusing is correct; "rename the folder on your disk" is a poor answer, so an explicit alias is the escape hatch (§6.7) | | O13 | Hub identity pinning | The client points at a hub by URL and nothing pins that hub's identity. `GET /v1/hub/pubkey` exists; pinning it on first use — as `pk_node` already is (11.5.8) — would stop an account's hub being silently swapped. Bounded today, because a substituted hub cannot read content and no longer ships the code; worth doing all the same | -| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10) | +| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17, revised 2026-08-23** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10). **Revision:** for the Videos group app, the node now also caches thumbnails and TMDB metadata durably in its own `data_dir` (not a shared root) — see `docs/mediacenter.md` §2 for the reasoning (TMDB quota is per-node not per-device; thin clients benefit from a node that does more) | --- diff --git a/docs/mediacenter.md b/docs/mediacenter.md index e3054f7..a165d64 100644 --- a/docs/mediacenter.md +++ b/docs/mediacenter.md @@ -283,6 +283,45 @@ preference with no security or authority implication, so it does not need the `roster.db` + signed-op treatment `member_upload`/`apps_enabled` get. Default: Mode A, per the operator's own spec. +### 4.4 Scoping to a folder — added 2026-08-24, this was in §0 and got missed + +The operator's original ask was explicit: pick an already-shared directory, +possibly a subdirectory of one already exposed — not necessarily the whole +group. This did not make it into the plan when the rest of this document was +written; caught only once the feature was in real use against an existing +group. + +**Unlike the mode toggle above, this is not a per-device display +preference** — it decides what *every* member's Videos tab shows, the same +class of decision `apps_enabled`/`member_upload` already are. So it gets the +same treatment: a per-group setting in `roster.db` (`video_root`, empty +string meaning "the whole group index" — today's behaviour, unchanged), +changed by a new signed op (`OP_VIDEO_ROOT`, `video_root`/`video_root_ack` +on the wire) that any already-connected member is told about without +reconnecting, exactly like `apps_enabled_ack`. + +**Where the operator sets it: Files, not Settings.** The folder being +scoped is something the operator is already looking at while browsing — +Files gets a toolbar action that toggles between "Set as Videos root" (any +other folder) and "Clear Videos root" (the folder that already is one, or +the top level when one is set deeper in the tree). No separate path-picker +UI was built; browsing *to* the folder in the browser used for browsing +everything else in the group is the picker. + +**Validation happens before a signature is ever asked for**, the same +principle as apps_enabled's "empty set refused up front": a candidate path +is resolved against the group's actual `RootSet` (the same traversal-safe +resolution `entry_abs_path`/directory creation already use) and must name a +real, currently-readable directory, or the request is refused immediately +— a stale or mistyped path never reaches the operator's browser as a +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 +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. + --- ## 5. Protocol and index changes @@ -356,6 +395,80 @@ own blake3 in `data_dir`, and clients pull it exactly like a video thumbnail. No client ever contacts `image.tmdb.org` or `api.themoviedb.org` directly (§2). +**Bug found live, 2026-08-24**: `transport.js`'s generic reply routing +resolves an untyped response against whichever request is *oldest pending* +— correct for every request the client only ever has one of in flight, but +`media_meta_req` is fired once per visible poster-grid tile, several at a +time (apps.md §5's virtualization), exactly the shape `file_req` already +special-cases with a `chunk:{file_id}:{chunk_index}` key. Without the same +treatment, two concurrent lookups could resolve to each other's answer +whenever their responses arrived out of order — reproduced live as two +shows' posters/season lists swapping across reloads. Fixed by keying +`media_meta_req`/`media_meta_resp` by `path`, the same way `file_req` is +keyed by id+chunk. + +**Bug found live, 2026-08-24**: a poster-grid tile used to render its raw +per-file frame immediately (already known from the index, no network round +trip needed), then swap it for the TMDB poster once `media_meta_resp` +arrived — and, for a §V6 merge candidate, briefly render as its own +standalone card before collapsing into its sibling once both resolved to +the same `tmdb_id`. Individually harmless, but visibly distracting in +sequence (reported as "hurts the eyes"). `video-app.js`'s `PosterCard` now +withholds a tile's content entirely — title, subtitle, and image alike — +until both the metadata lookup and the chosen image (poster or fallback +frame) have actually settled, showing a spinner in the meantime; a slow +lookup (a very large, freshly-scanned library) just means the spinner +stays a little longer. This does not (and structurally cannot, without +giving up per-tile virtualization, apps.md §5) suppress every trace of a +§V6 merge: a rare split-release show can still show two fully-resolved +cards for an instant before they collapse into one, since the merge is +only known once both siblings' independent lookups return. + +**Bug found live, 2026-08-24**: an operator reported the poster grid +hanging on spinners forever on a *second* visit to the same group's Videos +tab within one page session (revisit the tab, or a full reload — either +way, reproducibly). Two real, separate causes: + +1. `_fetch_and_cache_poster` (`webrtc_server.py`) downloaded a TMDB poster/ + backdrop from `image.tmdb.org` on *every* `media_meta_req`, even for a + file whose TMDB match was already cached — nothing had ever checked "is + this poster_path already fetched" before making the network call, since + the content-addressed `thumb_hash` isn't known until the bytes already + are. Fixed by keying the `thumbs` cache on a synthetic `tmdb:{poster_path}` + id, checked before the request (`media_cache.get_thumb_hash_by_file_id`). +2. §V6's merge used to mint a brand-new key (every constituent title, + joined) the instant two raw groups turned out to be the same show — + destroying an already-visible, already-resolved card and rebuilding one + from scratch. Fixed by keying the merged entry as `groups[0].title` + instead: the overwhelmingly common (unmerged) case is unaffected, and a + merge now updates an existing card's props rather than replacing it. + +Neither of those was the actual hang, though both were real, worth fixing, +and made it worse. The hang itself was a race in `PosterCard`'s own +"nothing shown until ready" gate (§ above): `imageReady` was a `useState` +reset to `false` by its *own* `useEffect` keyed on `posterHash`, and set to +`true` by `MediaThumb`'s `onReady` callback. The instant `metaReady` flips +true, `posterHash` jumps from the raw fallback frame to the resolved +poster **in the same commit** that mounts `MediaThumb` for it. If that +poster's bytes are already in `MediaThumb`'s session-lifetime blob cache — +exactly true on a same-tab revisit, since that cache is never cleared — +its `onReady` fires *synchronously* from that mount effect. Effects run +children-first, so `PosterCard`'s own "reset on posterHash change" effect +fired *after* it in that same commit, unconditionally overwriting the flag +back to `false` — with nothing left to ever set it `true` again, since +`posterHash` doesn't change a second time. The card was stuck as a +spinner forever despite the image already being fully loaded and cached. +Fixed by deriving `imageReady` from a direct comparison — +`readyHash === posterHash`, where `onReady` sets `readyHash` — rather than +a second, separately-timed writer of the same boolean: whichever of +"meta resolves" and "image confirms ready" happens first, in whichever +order effects run, the next render sees the same, correct answer. A cold +fetch (no reload, or a genuinely new poster) never hit this at all, +because `onReady` there fires asynchronously, in a later commit, well +after the reset had already settled — which is exactly why it only ever +showed up on a revisit, and only after the two fixes above had already +made a revisit's fetch fast enough to *be* synchronous. + ### 5.5 Node-wide config, not per-group TMDB is one operator's budget and one credential, not a per-group concern, @@ -373,6 +486,41 @@ means the shipped default token, present with a token means "use this one instead" — the same "absent means the old behaviour" discipline `member_upload`/`apps_enabled` already follow. +**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 +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 +default, if unset) to every request transparently — no change needed at +any call site. The Settings UI pre-fills this field from the operator's own +current browser language (a sensible default, not an attempt to read their +hub-account profile, which the node has no way to learn), and the operator +can still change it. This does not by itself fix an outright wrong match +(a real, separate accuracy question, §3) — only which language a *correct* +match's title/overview/poster come back in. + +**Correction, same day**: it turned out to fix more than that. Live, the +French query didn't just translate the operator's example show — it +resolved season 1's match correctly for the first time (previously a wrong, +unrelated film in the same fallback slot, §3's kind of miss). A likely +explanation, not a guaranteed one: TMDB's own search ranking can differ by +language, and the French title happened to disambiguate what the English +query could not. Not a substitute for §3's accuracy work — a French-only +title with no ambiguity would not be helped this way — but a real, +observed improvement worth having independent of translation. + +**Per-field English fallback, added same day**: TMDB does not fall back to +English server-side for a field with no translation in the requested +language — it returns `""` (or `[]`) for that one field, confirmed live +(a French query left `overview` empty for a title TMDB has no French copy +of, despite a good poster and cast). The TMDB *website* papers over exactly +this gap client-side; `_tmdb_build_meta` (webrtc_server.py) now does the +same — a second, English-forced details fetch only when a checked field +(`overview`, `poster_path`, `genres`) comes back empty, merged field by +field so a good localized field is never overwritten by the fallback. + ### 5.6 `ALLOWED_APPS` `webrtc_server.py:1629` — add `"video"` to the frozenset. `DEFAULT_APPS` @@ -471,6 +619,7 @@ 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 | ## 11. Acceptance before shipping @@ -484,4 +633,19 @@ centralization was for (one computation, reused by every member). 3. Verify cache pruning on file deletion actually fires (§2, §8) before calling the durable-cache reversal complete — an untested claim here would repeat the exact "documented but not built" pattern this project's - own history (CLAUDE.md) warns about repeatedly. + own history (CLAUDE.md) warns about repeatedly. Covered by an automated + test (`test_media_cache.py`), not just argued. + +**Done, 2026-08-24**: the full pipeline ran live against the operator's real +node and library (a real show, real TMDB token) — probe/parse/thumbnail +enrichment, TMDB search/match/credits, poster fetch-and-cache, chunk-path +delivery, and the poster-grid/flat-list/detail-modal UI, end to end, no +errors. One real bug was found and fixed this way that no unit test would +have caught: `DirectoryIndexer.initial_scan()` (run once at startup) never +itself triggers the `on_change` callback enrichment hangs off — daemon.py's +`_bg_scan` now explicitly calls `_on_index_change` after the startup scan +completes, or every file already on disk at boot (the common case) would +never get enriched, only files added later while the node runs. Covered by +`test_startup_scan_enrichment.py`. The exhaustive §3 corpus re-run (item 1 +above, item 2's >95% bar) is still open — this was a live smoke test of the +mechanism, not the accuracy study. diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index a622623..4c3bea0 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -8,5 +8,10 @@ __version__ = "0.6.0" # handshake ack, for the group-applications registry. Additive — a node that # predates it is never sent the op, and a client that predates it never looks # for the field — so this is a MINOR bump too. -MNP_VERSION = "0.4" +# 0.5: added `width`/`height`/`display_title`/`season`/`episode` to +# `IndexEntry`, started populating the already-declared `duration`/ +# `thumb_hash`, and added `media_meta_req`/`media_meta_resp`, for the Videos +# group app. Additive — an older client simply doesn't render the new +# fields — so this is a MINOR bump too. +MNP_VERSION = "0.5" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index c48f011..e86f322 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -61,6 +61,16 @@ OP_APPS_ENABLED = "apps_enabled" # security property in itself, but the pattern (every operator setting is # signed) is what keeps the authorization model simple to reason about. OP_SET_SCAN_SETTINGS = "set_scan_settings" +# Whether the node calls TMDB at all, and whether it uses the operator's own +# API token instead of the shipped default. Signed like the rest: it turns +# on outbound third-party network traffic that did not exist before the +# Videos app (docs/mediacenter.md §5.5, §8) — an unsigned toggle would let +# any member turn on egress the operator never agreed to. +OP_TMDB_CONFIG = "tmdb_config" +# Which folder (possibly a subfolder of a shared root) is the Videos app's +# entry point for this group — per-group, unlike tmdb_config. Signed like +# the rest: it decides what every member's Videos tab shows. +OP_VIDEO_ROOT = "video_root" OP_ROOT_ADD = "root_add" OP_ROOT_REMOVE = "root_remove" OP_GROUP_ATTACH = "group_attach" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index e1bfb8e..246bd00 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -90,6 +90,12 @@ class MNP: APPS_ENABLED_ACK = "apps_enabled_ack" SET_SCAN_SETTINGS = "set_scan_settings" # operator → node: reconcile/debounce timing SET_SCAN_SETTINGS_ACK = "set_scan_settings_ack" + MEDIA_META_REQ = "media_meta_req" # client → node: TMDB metadata for a path + MEDIA_META_RESP = "media_meta_resp" # node → client: TMDB metadata (or none) + VIDEO_ROOT = "video_root" # operator → node: which folder is the Videos entry point + VIDEO_ROOT_ACK = "video_root_ack" + TMDB_CONFIG = "tmdb_config" # operator → node: enable/disable TMDB, set token + TMDB_CONFIG_ACK = "tmdb_config_ack" # node → everyone: new TMDB config (never the token) # Device linking. A new device files a request bound to a code it displays; # an already-pinned device of the same account approves it. Neither the hub # nor the node can produce the countersignature. @@ -141,6 +147,30 @@ class IndexEntry: thumb_hash: str | None = None # blake3 of thumbnail uploader_id: str | None = None # user_id of who uploaded (None = pre-existing on disk) uploader_pk: str | None = None # Ed25519 public key of uploader (base64 raw 32 bytes) + width: int | None = None # pixels, video only + height: int | None = None # pixels, video only + display_title: str | None = None # parsed or cleaned-filename title, Videos app + season: int | None = None # parsed season number, Videos app + episode: int | None = None # parsed episode number, Videos app + + +def index_entry_wire(e: IndexEntry) -> dict: + """ + The wire-dict shape used by INDEX_SYNC/INDEX_DELTA hand-built messages + (as opposed to GroupIndex.serialize()'s asdict() encoding of the whole + index). Centralized so the three call sites that build these + (webrtc_server._do_index_sync, daemon._broadcast_index_change's two + branches) can't drift from each other as fields are added. + """ + return { + "id": e.id, "name": e.name, "path": e.path, + "size": e.size, "type": e.type, "added_at": e.added_at, + "uploader_id": e.uploader_id, + "duration": e.duration, "thumb_hash": e.thumb_hash, + "width": e.width, "height": e.height, + "display_title": e.display_title, + "season": e.season, "episode": e.episode, + } @dataclass @@ -149,6 +179,12 @@ class IndexDelta: version: int additions: list[IndexEntry] = field(default_factory=list) deletions: list[str] = field(default_factory=list) # list of ids + # Same id as before (same file, same content hash), different field + # values — e.g. the Videos app's async enrichment filling in duration/ + # thumb_hash/etc. after the file was already indexed with hash+size only. + # A distinct list from `additions`: `GroupIndex.diff()` only ever adds an + # id here once it has already appeared unchanged in a prior snapshot. + updates: list[IndexEntry] = field(default_factory=list) # ── Chunk request/response ──────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index e11e718..36d94bb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -33,7 +33,7 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js", # app.js or group-page.js, so a change to any of them is a change # to what the browser must fetch. "icon.js", "file-utils.js", "hub-client.js", "apps.js", - "chat-app.js", "files-app.js", "video-player.js", + "chat-app.js", "files-app.js", "video-player.js", "video-app.js", "group-settings.js", "group-page.js") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 044457b..58ffe8e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -16,6 +16,7 @@ import { refreshAccessToken, } from './hub-client.js'; import { GroupPage } from './group-page.js'; +import { APPS } from './apps.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -1731,8 +1732,7 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) { <span class="settings-label">${t('settings.default_tab')}</span> <select class="settings-select" value=${defaultTab} onChange=${changeDefaultTab}> - <option value="chat">${t('group.tab_chat')}</option> - <option value="files">${t('group.tab_files')}</option> + ${APPS.map((a) => html`<option key=${a.key} value=${a.key}>${t(a.labelKey)}</option>`)} <option value="settings">${t('group.tab_settings')}</option> </select> </div> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js index 0db713c..88b4a0f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js @@ -1,5 +1,6 @@ import { ChatPanel } from './chat-app.js'; import { FilesPanel } from './files-app.js'; +import { VideoApp } from './video-app.js'; /** * Every group "application", in tab order. @@ -16,6 +17,7 @@ import { FilesPanel } from './files-app.js'; const APPS = [ { key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel }, { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel }, + { key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp }, ]; /** The registry filtered to what this group has enabled, in registry order. */ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 29f3602..55f8e36 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -80,6 +80,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // to the operator in Settings, not enforced from here (indexer.py owns // that). Null until the handshake ack arrives. const [scanSettings, setScanSettings] = useState(null); + // TMDB on/off + whether a custom token is set, node-wide (not per-group) — + // docs/mediacenter.md §5.5. Null until the handshake ack arrives. + const [tmdbConfig, setTmdbConfig] = useState(null); + // Which folder is the Videos app's entry point for this group — '' + // (the default) means the whole group index. Set from Files, per-group. + const [videoRoot, setVideoRoot] = useState(''); // Paired ≠ operator account. `is_node_admin` says the hub account owning this // node is the one connecting; this says the node pinned *this browser's* key // as an operator key. Only the second one lets you sign an invite, and only @@ -127,20 +133,24 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, cacheGroupIndex(groupId, group ? group.name : groupId, fresh); }, [groupId, group]); - // additions/deletions only (daemon.py _broadcast_index_change, once there - // is a previous snapshot to diff against) — applied on top of whatever - // applyIndex last put in `entries`, instead of replacing the whole table - // for one changed file. + // additions/deletions/updates (daemon.py _broadcast_index_change, once + // there is a previous snapshot to diff against) — applied on top of + // whatever applyIndex last put in `entries`, instead of replacing the + // whole table for one changed file. `updates` is the Videos app's async + // enrichment (duration/thumb_hash/display_title/...) arriving for a file + // already in the table — same id, new fields (see group_index.py diff()). const applyIndexDelta = useCallback((deltaMsg) => { setEntries((prev) => { const deletions = new Set(deltaMsg.deletions || []); const kept = prev.filter((e) => !deletions.has(e.id)); + const updates = new Map((deltaMsg.updates || []).map((e) => [e.id, e])); + const updated = kept.map((e) => updates.get(e.id) || e); // The index is keyed by content hash: an addition whose id is already // present is the same duplicate-content case indexer.py's own // reconcile sweep leaves alone, not a second row for one file. - const keptIds = new Set(kept.map((e) => e.id)); + const keptIds = new Set(updated.map((e) => e.id)); const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id)); - const fresh = kept.concat(additions); + const fresh = updated.concat(additions); cacheGroupIndex(groupId, group ? group.name : groupId, fresh); return fresh; }); @@ -196,11 +206,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setMemberUpload(ack.member_upload !== false); setEnabledApps(ack.enabled_apps || null); setScanSettings(ack.scan_settings || null); + setTmdbConfig({ + enabled: ack.tmdb_enabled !== false, + tokenCustomized: !!ack.tmdb_token_customized, + language: ack.tmdb_language || '', + }); + setVideoRoot(ack.video_root || ''); // Changed while we are connected, by an operator who may be someone // else entirely. Without this the button stays until a reconnection, // and a button that is still there is a button people press. transport.onUploadPolicy = (allowed) => setMemberUpload(allowed); transport.onAppsEnabled = (apps) => setEnabledApps(apps); + transport.onTmdbConfig = (cfg) => setTmdbConfig(cfg); + transport.onVideoRoot = (path) => setVideoRoot(path); // The node's own scan (a root added while we were already connected, // or reconcile catching one back up) — never the entries, just // enough to animate the sidebar dot. Guaranteed a final push at the @@ -390,6 +408,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, onRefreshIndex: refreshIndex, onActivity: touchActivity, + videoRoot, onVideoRoot: (path) => setVideoRoot(path), + tmdbConfig, }; return html` @@ -503,6 +523,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onEnabledApps=${(keys) => setEnabledApps(keys)} scanSettings=${scanSettings} onScanSettings=${(s) => setScanSettings(s)} + tmdbConfig=${tmdbConfig} + onTmdbConfig=${(cfg) => setTmdbConfig(cfg)} + entries=${entries} nodeDirs=${nodeDirs} + videoRoot=${videoRoot} + onVideoRoot=${(path) => setVideoRoot(path)} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> `} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 0b64a03..4ae832f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -1,12 +1,22 @@ import { - html, useState, useEffect, useCallback, + html, useState, useEffect, useCallback, useMemo, } from './vendor/htm-preact.js'; -import { t } from './i18n.js'; +import { t, getLocale, LOCALES } from './i18n.js'; import { Icon } from './icon.js'; import { hubFetch, navigate } from './hub-client.js'; import { APPS } from './apps.js'; import * as platform from './platform.js'; +// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB +// expects — the two don't share a format (MeshBay's "en" vs TMDB's +// required region, "en-US"). Used only to pre-fill the TMDB language field +// with the operator's own current UI language, a reasonable default they +// can still change; the node never guesses this on its own. +const TMDB_LANGUAGE_BY_LOCALE = { + en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN', + ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL', +}; + // ── Members Panel ──────────────────────────────────────────────────────── /** @@ -23,6 +33,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, memberUpload, onMemberUpload, enabledApps, onEnabledApps, scanSettings, onScanSettings, + tmdbConfig, onTmdbConfig, + entries, nodeDirs, videoRoot, onVideoRoot, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); @@ -244,6 +256,120 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]); + const [tmdbBusy, setTmdbBusy] = useState(false); + const [tmdbMsg, setTmdbMsg] = useState(''); + const [tmdbTokenDraft, setTmdbTokenDraft] = useState(''); + const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true; + // Pre-filled from the operator's own current UI language the first time + // this renders with nothing configured yet — a sensible default, not a + // claim about what the node is actually using until they hit Save. + const [tmdbLanguage, setTmdbLanguage] = useState( + () => (tmdbConfig && tmdbConfig.language) + || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US'); + useEffect(() => { + if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language); + }, [tmdbConfig && tmdbConfig.language]); + + /** + * TMDB on/off, an optional custom API token, and the language TMDB is + * queried in — node-wide, not per-group (docs/mediacenter.md §5.5). Same + * shape as saveScanSettings: signed, and the toggle does not claim + * success until the node confirms it. The token field is cleared after a + * save either way: it is never echoed back by the node (tmdb_config_ack + * carries only whether one is set, never the value), so there is + * nothing to keep showing. + */ + const saveTmdbConfig = useCallback(async (nextEnabled) => { + const transport = transportRef && transportRef.current; + setTmdbMsg(''); + setTmdbBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + const token = tmdbTokenDraft.trim(); + await transport.setTmdbConfig(nextEnabled, token || undefined, tmdbLanguage, signFn); + setTmdbTokenDraft(''); + if (onTmdbConfig) { + onTmdbConfig({ + enabled: nextEnabled, + tokenCustomized: token + ? true + : (tmdbConfig ? tmdbConfig.tokenCustomized : false), + language: tmdbLanguage, + }); + } + setTmdbMsg(t('settings_node.scan_saved')); + } catch (err) { + setTmdbMsg(err.message); + } finally { + setTmdbBusy(false); + } + }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]); + + // Every folder anywhere in the group's shared index, deepest included — + // `entries[].path` is each file's containing directory (files-app.js's own + // convention), so every ancestor prefix of it is a real folder, and + // `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented + // <select> rather than a live folder browser: choosing the Videos root is + // a rare, one-off decision, not something worth a whole navigable tree for. + const videoRootFolders = useMemo(() => { + const set = new Set(); + const addAncestors = (path) => { + if (!path) return; + const parts = path.split('/'); + for (let i = 1; i <= parts.length; i++) set.add(parts.slice(0, i).join('/')); + }; + for (const e of (entries || [])) addAncestors(e.path); + for (const d of (nodeDirs || [])) addAncestors(d); + return [...set].sort(); + }, [entries, nodeDirs]); + + const [videoRootDraft, setVideoRootDraft] = useState(videoRoot || ''); + useEffect(() => { setVideoRootDraft(videoRoot || ''); }, [videoRoot]); + const [videoRootBusy, setVideoRootBusy] = useState(false); + const [videoRootMsg, setVideoRootMsg] = useState(''); + + /** + * Which folder is the Videos app's entry point for this group — same + * shape as toggleApp/saveScanSettings: signed, and the picker does not + * claim success until the node confirms it. + * + * Changing an *already-set* root is destructive to every member's Videos + * tab (a different set of files, possibly none in common) — the operator + * confirms that explicitly. Setting it for the first time is not: there is + * nothing yet to lose. + */ + const saveVideoRoot = useCallback(async () => { + const next = videoRootDraft; + const current = videoRoot || ''; + if (next === current) return; + if (current && !confirm(t('settings_node.video_root_change_confirm'))) return; + const transport = transportRef && transportRef.current; + setVideoRootMsg(''); + setVideoRootBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setVideoRoot(next, signFn); + if (onVideoRoot) onVideoRoot(next); + setVideoRootMsg(t('settings_node.scan_saved')); + } catch (err) { + setVideoRootMsg(err.message); + } finally { + setVideoRootBusy(false); + } + }, [transportRef, onVideoRoot, videoRootDraft, videoRoot]); + const [removing, setRemoving] = useState(''); /** @@ -463,6 +589,87 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} + ${/* TMDB on/off + custom token, node-wide (docs/mediacenter.md §5.5) — + new outbound third-party traffic the node did not have before + the Videos app, so it is a signed operator setting like the + rest, not a display preference. */ + isNodeAdmin && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings_node.tmdb_title')}</h3> + <p class="settings-hint">${t('settings_node.tmdb_hint')}</p> + <div class="settings-row"> + <label class="settings-label"> + <input type="checkbox" checked=${tmdbEnabled} disabled=${tmdbBusy} + onChange=${(e) => saveTmdbConfig(e.target.checked)} /> + ${' '}${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} + </label> + </div> + <div class="settings-row"> + <label class="settings-label"> + ${t('settings_node.tmdb_token_label')} + <input type="password" placeholder=${t('settings_node.tmdb_token_placeholder')} + value=${tmdbTokenDraft} disabled=${tmdbBusy} + onInput=${e => setTmdbTokenDraft(e.target.value)} /> + </label> + <p class="settings-hint"> + ${tmdbConfig && tmdbConfig.tokenCustomized + ? t('settings_node.tmdb_token_customized') + : t('settings_node.tmdb_token_default')} + </p> + </div> + <div class="settings-row"> + <label class="settings-label"> + ${t('settings_node.tmdb_language_label')} + <select value=${tmdbLanguage} disabled=${tmdbBusy} + onChange=${e => setTmdbLanguage(e.target.value)}> + ${LOCALES.map(l => html` + <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}> + ${l.name} + </option> + `)} + </select> + </label> + <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p> + </div> + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${tmdbBusy} onClick=${() => saveTmdbConfig(tmdbEnabled)}> + ${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')} + </button> + ${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`} + </div> + `} + + ${/* Which folder is the Videos app's entry point for this group — + per-group like uploads, not node-wide like TMDB (mediacenter.md + §5.6). Until one is chosen, the Videos tab says so instead of + listing anything, and the node runs no TMDB/thumbnail work for + this group at all (daemon.py's _enrich_new_video_entries). */ + isNodeAdmin && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings_node.video_root_title')}</h3> + <p class="settings-hint">${t('settings_node.video_root_hint')}</p> + <div class="settings-row"> + <label class="settings-label"> + <select value=${videoRootDraft} disabled=${videoRootBusy} + onChange=${e => setVideoRootDraft(e.target.value)}> + <option value="">${t('settings_node.video_root_none')}</option> + ${videoRootFolders.map(p => html` + <option key=${p} value=${p}> + ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()} + </option> + `)} + </select> + </label> + </div> + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${videoRootBusy || videoRootDraft === (videoRoot || '')} + onClick=${saveVideoRoot}> + ${videoRootBusy ? t('settings_node.scan_saving') : t('settings_node.video_root_save')} + </button> + ${videoRootMsg && html`<p class="settings-hint">${videoRootMsg}</p>`} + </div> + `} + ${/* Roots management (Electron-only, when node is local) */ nodeDetected && nodeRoots.length > 0 && html` <div class="settings-section"> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index 4dc24c0..84a5195 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -69,6 +69,8 @@ const ICON_PATHS = { cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21', 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6', 'M2 21h.01'], + video: ['M3.5 6.5a1.5 1.5 0 0 1 1.5-1.5h14a1.5 1.5 0 0 1 1.5 1.5v11a1.5 1.5 0 0 1-1.5 1.5h-14a1.5 1.5 0 0 1-1.5-1.5z', + 'M10 9.5v5l4.5-2.5z'], }; // The M of the wordmark is a picture; the rest is text. Resolved from this diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 866212f..6634cfb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -76,6 +76,7 @@ export default { 'group.default_name': 'Gruppe', 'group.tab_files': 'Dateien', 'group.tab_chat': 'Chat', + 'group.tab_video': 'Videos', 'group.tab_members': 'Mitglieder', 'group.tab_settings': "Einstellungen", 'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.", @@ -156,6 +157,18 @@ export default { 'group.upload_indexing': 'wird indiziert …', 'video.err_transport': 'Transport nicht verbunden', 'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}', + 'video.mode_poster': 'Poster', + 'video.mode_flat': 'Flache Liste', + 'video.empty': 'Keine Videos gefunden.', + 'video.no_root_configured': 'Für diese Gruppe ist noch kein Videos-Stammordner festgelegt — ein Operator kann in den Einstellungen einen auswählen.', + 'video.director': 'Regie', + 'video.specials': 'Specials', + 'video.season_n': 'Staffel {n}', + 'video.episode_n': 'Episode {n}', + 'video.n_episodes': { + one: '{n} Episode', + other: '{n} Episoden', + }, // LAN-Cast 'cast.start': 'Auf Gerät übertragen', @@ -572,6 +585,22 @@ export default { 'settings_node.scan_save': 'Speichern', 'settings_node.scan_saving': 'Wird gespeichert…', 'settings_node.scan_saved': 'Gespeichert.', + 'settings_node.tmdb_title': 'TMDB-Metadaten', + 'settings_node.tmdb_hint': 'Ermöglicht der Videos-App, Poster, Beschreibungen und Besetzung von TMDB anzuzeigen. Deaktiviert bedeutet: nur Vorschaubilder, keine Anfrage an einen Drittanbieter.', + 'settings_node.tmdb_enabled': 'Aktiviert', + 'settings_node.tmdb_disabled': 'Deaktiviert', + 'settings_node.tmdb_token_label': 'Eigenes API-Token (optional)', + 'settings_node.tmdb_token_placeholder': 'Leer lassen, um das mitgelieferte Standard-Token zu verwenden', + 'settings_node.tmdb_token_customized': 'Ein eigenes Token ist gesetzt.', + 'settings_node.tmdb_token_default': 'Verwendet das mitgelieferte Standard-Token.', + 'settings_node.tmdb_save': 'Speichern', + 'settings_node.tmdb_language_label': 'Sprache', + 'settings_node.tmdb_language_hint': 'Gilt für alle — ein gemeinsamer Cache, keine Anfrage pro Betrachter.', + 'settings_node.video_root_title': 'Videos-Stammordner', + 'settings_node.video_root_hint': 'Welcher Ordner (oder Unterordner) als Einstiegspunkt der Videos-App für diese Gruppe dient. In Videos wird nichts angezeigt, und es werden keine TMDB-Abfragen ausgeführt, bis einer ausgewählt wurde.', + 'settings_node.video_root_none': '— keiner ausgewählt —', + 'settings_node.video_root_save': 'Speichern', + 'settings_node.video_root_change_confirm': 'Das Ändern des Videos-Stammordners ersetzt, was jedes Mitglied im Videos-Tab sieht. Fortfahren?', // Create-group wizard 'wizard.title': 'Gruppe erstellen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a83b067..ebdb3e2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -77,6 +77,7 @@ export default { 'group.default_name': 'Group', 'group.tab_files': 'Files', 'group.tab_chat': 'Chat', + 'group.tab_video': 'Videos', 'group.tab_members': 'Members', 'group.tab_settings': "Settings", 'members.danger_leave_hint': "You will lose access to this group's files and chat.", @@ -154,6 +155,18 @@ export default { 'group.upload_indexing': 'indexing…', 'video.err_transport': 'Transport not connected', 'video.err_mse': 'Codec not supported for streaming: {codec}', + 'video.mode_poster': 'Posters', + 'video.mode_flat': 'Flat list', + 'video.empty': 'No videos found.', + 'video.no_root_configured': 'No Videos root folder is set for this group yet — an operator can choose one in Settings.', + 'video.director': 'Director', + 'video.specials': 'Specials', + 'video.season_n': 'Season {n}', + 'video.episode_n': 'Episode {n}', + 'video.n_episodes': { + one: '{n} episode', + other: '{n} episodes', + }, // LAN cast 'cast.start': 'Cast to device', @@ -396,6 +409,22 @@ export default { 'settings_node.scan_save': 'Save', 'settings_node.scan_saving': 'Saving…', 'settings_node.scan_saved': 'Saved.', + 'settings_node.tmdb_title': 'TMDB metadata', + 'settings_node.tmdb_hint': 'Lets the Videos app show posters, overviews and cast from TMDB. Off means thumbnail-only browsing, with no request to a third party.', + 'settings_node.tmdb_enabled': 'Enabled', + 'settings_node.tmdb_disabled': 'Disabled', + 'settings_node.tmdb_token_label': 'Custom API token (optional)', + 'settings_node.tmdb_token_placeholder': 'Leave blank to use the shipped default', + 'settings_node.tmdb_token_customized': 'A custom token is set.', + 'settings_node.tmdb_token_default': 'Using the shipped default token.', + 'settings_node.tmdb_save': 'Save', + 'settings_node.tmdb_language_label': 'Language', + 'settings_node.tmdb_language_hint': 'Applies to everyone — one shared cache, not a per-viewer request.', + 'settings_node.video_root_title': 'Videos root folder', + 'settings_node.video_root_hint': 'Which folder (or subfolder) the Videos app treats as its entry point for this group. Nothing shows in Videos, and no TMDB lookups run, until one is chosen.', + 'settings_node.video_root_none': '— none chosen —', + 'settings_node.video_root_save': 'Save', + 'settings_node.video_root_change_confirm': 'Changing the Videos root replaces what every member sees in the Videos tab. Continue?', // Members 'members.col_role': 'Role', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 1621182..d028279 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -74,6 +74,7 @@ export default { 'group.default_name': 'Grupo', 'group.tab_files': 'Archivos', 'group.tab_chat': 'Chat', + 'group.tab_video': 'Vídeos', 'group.tab_members': 'Miembros', 'group.tab_settings': "Ajustes", 'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.", @@ -154,6 +155,18 @@ export default { 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte no conectado', 'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}', + 'video.mode_poster': 'Pósteres', + 'video.mode_flat': 'Lista plana', + 'video.empty': 'No se encontraron vídeos.', + 'video.no_root_configured': 'Aún no se ha definido una carpeta raíz de Vídeos para este grupo — un operador puede elegir una en Configuración.', + 'video.director': 'Director', + 'video.specials': 'Especiales', + 'video.season_n': 'Temporada {n}', + 'video.episode_n': 'Episodio {n}', + 'video.n_episodes': { + one: '{n} episodio', + other: '{n} episodios', + }, // LAN cast 'cast.start': 'Enviar a dispositivo', @@ -567,6 +580,22 @@ export default { 'settings_node.scan_save': 'Guardar', 'settings_node.scan_saving': 'Guardando…', 'settings_node.scan_saved': 'Guardado.', + 'settings_node.tmdb_title': 'Metadatos de TMDB', + 'settings_node.tmdb_hint': 'Permite que la aplicación de Vídeos muestre pósteres, sinopsis y reparto desde TMDB. Desactivado significa solo miniaturas, sin ninguna solicitud a un tercero.', + 'settings_node.tmdb_enabled': 'Activado', + 'settings_node.tmdb_disabled': 'Desactivado', + 'settings_node.tmdb_token_label': 'Token de API personalizado (opcional)', + 'settings_node.tmdb_token_placeholder': 'Déjelo en blanco para usar el token predeterminado', + 'settings_node.tmdb_token_customized': 'Hay un token personalizado configurado.', + 'settings_node.tmdb_token_default': 'Usando el token predeterminado.', + 'settings_node.tmdb_save': 'Guardar', + 'settings_node.tmdb_language_label': 'Idioma', + 'settings_node.tmdb_language_hint': 'Se aplica a todos — una caché compartida, no una solicitud por espectador.', + 'settings_node.video_root_title': 'Carpeta raíz de Vídeos', + 'settings_node.video_root_hint': 'Qué carpeta (o subcarpeta) trata la app Vídeos como su punto de entrada para este grupo. No se muestra nada en Vídeos, ni se realizan búsquedas en TMDB, hasta que se elija una.', + 'settings_node.video_root_none': '— ninguna elegida —', + 'settings_node.video_root_save': 'Guardar', + 'settings_node.video_root_change_confirm': 'Cambiar la raíz de Vídeos reemplaza lo que ve cada miembro en la pestaña Vídeos. ¿Continuar?', // Create group wizard 'wizard.title': 'Crear grupo', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 6e21a06..621650d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -75,6 +75,7 @@ export default { 'group.default_name': 'Groupe', 'group.tab_files': 'Fichiers', 'group.tab_chat': 'Discussion', + 'group.tab_video': 'Vidéos', 'group.tab_members': 'Membres', 'group.tab_settings': "Paramètres", 'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.", @@ -155,6 +156,18 @@ export default { 'group.upload_indexing': 'indexation…', 'video.err_transport': 'Transport non connecté', 'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}', + 'video.mode_poster': 'Affiches', + 'video.mode_flat': 'Liste à plat', + 'video.empty': 'Aucune vidéo trouvée.', + 'video.no_root_configured': "Aucun dossier racine des Vidéos n'est encore défini pour ce groupe — un opérateur peut en choisir un dans les Paramètres.", + 'video.director': 'Réalisateur', + 'video.specials': 'Bonus', + 'video.season_n': 'Saison {n}', + 'video.episode_n': 'Épisode {n}', + 'video.n_episodes': { + one: '{n} épisode', + other: '{n} épisodes', + }, // LAN cast 'cast.start': 'Diffuser sur un appareil', @@ -583,6 +596,22 @@ export default { 'settings_node.scan_save': 'Enregistrer', 'settings_node.scan_saving': 'Enregistrement…', 'settings_node.scan_saved': 'Enregistré.', + 'settings_node.tmdb_title': 'Métadonnées TMDB', + 'settings_node.tmdb_hint': "Permet à l'application Vidéos d'afficher affiches, résumés et distribution depuis TMDB. Désactivé signifie navigation par vignettes uniquement, sans aucune requête vers un tiers.", + 'settings_node.tmdb_enabled': 'Activé', + 'settings_node.tmdb_disabled': 'Désactivé', + 'settings_node.tmdb_token_label': "Jeton d'API personnalisé (facultatif)", + 'settings_node.tmdb_token_placeholder': 'Laisser vide pour utiliser le jeton fourni par défaut', + 'settings_node.tmdb_token_customized': 'Un jeton personnalisé est défini.', + 'settings_node.tmdb_token_default': 'Utilise le jeton fourni par défaut.', + 'settings_node.tmdb_save': 'Enregistrer', + 'settings_node.tmdb_language_label': 'Langue', + 'settings_node.tmdb_language_hint': "S'applique à tout le monde — un cache partagé, pas une requête par personne.", + 'settings_node.video_root_title': 'Dossier racine des Vidéos', + 'settings_node.video_root_hint': "Quel dossier (ou sous-dossier) sert de point d'entrée à l'application Vidéos pour ce groupe. Rien ne s'affiche dans Vidéos, et aucune recherche TMDB n'est effectuée, tant qu'aucun n'est choisi.", + 'settings_node.video_root_none': '— aucun choisi —', + 'settings_node.video_root_save': 'Enregistrer', + 'settings_node.video_root_change_confirm': "Changer la racine des Vidéos remplace ce que chaque membre voit dans l'onglet Vidéos. Continuer ?", // Create group wizard 'wizard.title': 'Créer un groupe', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index edd15ab..71fb9ba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -75,6 +75,7 @@ export default { 'group.default_name': 'Gruppo', 'group.tab_files': 'File', 'group.tab_chat': 'Chat', + 'group.tab_video': 'Video', 'group.tab_members': 'Membri', 'group.tab_settings': "Impostazioni", 'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.", @@ -155,6 +156,18 @@ export default { 'group.upload_indexing': 'indicizzazione…', 'video.err_transport': 'Trasporto non connesso', 'video.err_mse': 'Codec non supportato per lo streaming: {codec}', + 'video.mode_poster': 'Locandine', + 'video.mode_flat': 'Elenco semplice', + 'video.empty': 'Nessun video trovato.', + 'video.no_root_configured': 'Per questo gruppo non è ancora impostata una cartella radice di Video — un operatore può sceglierne una nelle Impostazioni.', + 'video.director': 'Regista', + 'video.specials': 'Speciali', + 'video.season_n': 'Stagione {n}', + 'video.episode_n': 'Episodio {n}', + 'video.n_episodes': { + one: '{n} episodio', + other: '{n} episodi', + }, // LAN cast 'cast.start': 'Trasmetti al dispositivo', @@ -581,6 +594,22 @@ export default { 'settings_node.scan_save': 'Salva', 'settings_node.scan_saving': 'Salvataggio…', 'settings_node.scan_saved': 'Salvato.', + 'settings_node.tmdb_title': 'Metadati TMDB', + 'settings_node.tmdb_hint': "Permette all'app Video di mostrare locandine, trame e cast da TMDB. Disattivato significa solo miniature, senza alcuna richiesta verso terzi.", + 'settings_node.tmdb_enabled': 'Attivato', + 'settings_node.tmdb_disabled': 'Disattivato', + 'settings_node.tmdb_token_label': 'Token API personalizzato (facoltativo)', + 'settings_node.tmdb_token_placeholder': 'Lascia vuoto per usare il token predefinito', + 'settings_node.tmdb_token_customized': 'È impostato un token personalizzato.', + 'settings_node.tmdb_token_default': 'In uso il token predefinito.', + 'settings_node.tmdb_save': 'Salva', + 'settings_node.tmdb_language_label': 'Lingua', + 'settings_node.tmdb_language_hint': 'Vale per tutti — una cache condivisa, non una richiesta per spettatore.', + 'settings_node.video_root_title': 'Cartella radice di Video', + 'settings_node.video_root_hint': "Quale cartella (o sottocartella) l'app Video considera come punto di ingresso per questo gruppo. In Video non viene mostrato nulla, e non viene eseguita alcuna ricerca TMDB, finché non ne viene scelta una.", + 'settings_node.video_root_none': '— nessuna scelta —', + 'settings_node.video_root_save': 'Salva', + 'settings_node.video_root_change_confirm': 'Cambiare la radice di Video sostituisce ciò che ogni membro vede nella scheda Video. Continuare?', // Create-group wizard 'wizard.title': 'Crea gruppo', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 00fffed..3336705 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -73,6 +73,7 @@ export default { 'group.default_name': 'グループ', 'group.tab_files': 'ファイル', 'group.tab_chat': 'チャット', + 'group.tab_video': '動画', 'group.tab_members': 'メンバー', 'group.tab_settings': "設定", 'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。", @@ -152,6 +153,18 @@ export default { 'group.upload_indexing': 'インデックスを作成中…', 'video.err_transport': 'トランスポートが接続されていません', 'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}', + 'video.mode_poster': 'ポスター表示', + 'video.mode_flat': 'フラット表示', + 'video.empty': '動画が見つかりません。', + 'video.no_root_configured': 'このグループにはまだ動画のルートフォルダが設定されていません — 操作者が設定画面で選択できます。', + 'video.director': '監督', + 'video.specials': '特別編', + 'video.season_n': 'シーズン{n}', + 'video.episode_n': '第{n}話', + 'video.n_episodes': { + one: '{n}話', + other: '{n}話', + }, // LAN cast 'cast.start': 'デバイスにキャスト', @@ -565,6 +578,22 @@ export default { 'settings_node.scan_save': '保存', 'settings_node.scan_saving': '保存中…', 'settings_node.scan_saved': '保存しました。', + 'settings_node.tmdb_title': 'TMDBメタデータ', + 'settings_node.tmdb_hint': '動画アプリでTMDBのポスター、あらすじ、キャストを表示できるようにします。無効にするとサムネイルのみの表示になり、第三者への通信は発生しません。', + 'settings_node.tmdb_enabled': '有効', + 'settings_node.tmdb_disabled': '無効', + 'settings_node.tmdb_token_label': 'カスタムAPIトークン(任意)', + 'settings_node.tmdb_token_placeholder': '空欄にすると同梱のデフォルトトークンを使用します', + 'settings_node.tmdb_token_customized': 'カスタムトークンが設定されています。', + 'settings_node.tmdb_token_default': '同梱のデフォルトトークンを使用しています。', + 'settings_node.tmdb_save': '保存', + 'settings_node.tmdb_language_label': '言語', + 'settings_node.tmdb_language_hint': '全員に適用されます — 共有キャッシュであり、視聴者ごとのリクエストではありません。', + 'settings_node.video_root_title': '動画のルートフォルダ', + 'settings_node.video_root_hint': 'このグループで動画アプリの起点とするフォルダ(またはサブフォルダ)です。選択されるまで、動画には何も表示されず、TMDB への問い合わせも行われません。', + 'settings_node.video_root_none': '— 未選択 —', + 'settings_node.video_root_save': '保存', + 'settings_node.video_root_change_confirm': '動画のルートフォルダを変更すると、全メンバーの動画タブの表示内容が変わります。続行しますか?', // Wizard 'wizard.title': 'グループを作成', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 2837ebb..28be024 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -76,6 +76,7 @@ export default { 'group.default_name': 'Groep', 'group.tab_files': 'Bestanden', 'group.tab_chat': 'Chat', + 'group.tab_video': "Video's", 'group.tab_members': 'Leden', 'group.tab_settings': "Instellingen", 'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.", @@ -156,6 +157,18 @@ export default { 'group.upload_indexing': 'indexeren…', 'video.err_transport': 'Transport niet verbonden', 'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}', + 'video.mode_poster': 'Posters', + 'video.mode_flat': 'Platte lijst', + 'video.empty': "Geen video's gevonden.", + 'video.no_root_configured': "Er is nog geen hoofdmap voor Video's ingesteld voor deze groep — een operator kan er een kiezen bij Instellingen.", + 'video.director': 'Regisseur', + 'video.specials': "Extra's", + 'video.season_n': 'Seizoen {n}', + 'video.episode_n': 'Aflevering {n}', + 'video.n_episodes': { + one: '{n} aflevering', + other: '{n} afleveringen', + }, // LAN cast 'cast.start': 'Naar apparaat casten', @@ -583,6 +596,22 @@ export default { 'settings_node.scan_save': 'Opslaan', 'settings_node.scan_saving': 'Bezig met opslaan…', 'settings_node.scan_saved': 'Opgeslagen.', + 'settings_node.tmdb_title': 'TMDB-metadata', + 'settings_node.tmdb_hint': "Laat de Video's-app posters, samenvattingen en cast van TMDB tonen. Uit betekent alleen miniaturen, zonder enig verzoek aan een derde partij.", + 'settings_node.tmdb_enabled': 'Ingeschakeld', + 'settings_node.tmdb_disabled': 'Uitgeschakeld', + 'settings_node.tmdb_token_label': 'Eigen API-token (optioneel)', + 'settings_node.tmdb_token_placeholder': 'Leeg laten om de meegeleverde standaard te gebruiken', + 'settings_node.tmdb_token_customized': 'Er is een eigen token ingesteld.', + 'settings_node.tmdb_token_default': 'Gebruikt de meegeleverde standaardtoken.', + 'settings_node.tmdb_save': 'Opslaan', + 'settings_node.tmdb_language_label': 'Taal', + 'settings_node.tmdb_language_hint': 'Geldt voor iedereen — één gedeelde cache, geen verzoek per kijker.', + 'settings_node.video_root_title': "Hoofdmap voor Video's", + 'settings_node.video_root_hint': "Welke map (of submap) de Video's-app als startpunt gebruikt voor deze groep. Er wordt niets getoond in Video's, en er worden geen TMDB-opzoekingen uitgevoerd, totdat er een gekozen is.", + 'settings_node.video_root_none': '— geen gekozen —', + 'settings_node.video_root_save': 'Opslaan', + 'settings_node.video_root_change_confirm': "Het wijzigen van de hoofdmap voor Video's vervangt wat elk lid ziet in het tabblad Video's. Doorgaan?", // Create group wizard 'wizard.title': 'Groep aanmaken', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 760f29c..d4eb325 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -80,6 +80,7 @@ export default { 'group.default_name': 'Grupa', 'group.tab_files': 'Pliki', 'group.tab_chat': 'Czat', + 'group.tab_video': 'Wideo', 'group.tab_members': 'Członkowie', 'group.tab_settings': "Ustawienia", 'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.", @@ -161,6 +162,20 @@ export default { 'group.upload_indexing': 'indeksowanie…', 'video.err_transport': 'Transport nie jest połączony', 'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}', + 'video.mode_poster': 'Plakaty', + 'video.mode_flat': 'Lista płaska', + 'video.empty': 'Nie znaleziono żadnych filmów.', + 'video.no_root_configured': 'Dla tej grupy nie wybrano jeszcze katalogu głównego Wideo — operator może go wybrać w Ustawieniach.', + 'video.director': 'Reżyser', + 'video.specials': 'Dodatki', + 'video.season_n': 'Sezon {n}', + 'video.episode_n': 'Odcinek {n}', + 'video.n_episodes': { + one: '{n} odcinek', + few: '{n} odcinki', + many: '{n} odcinków', + other: '{n} odcinka', + }, // LAN cast 'cast.start': 'Przesyłaj na urządzenie', @@ -604,6 +619,22 @@ export default { 'settings_node.scan_save': 'Zapisz', 'settings_node.scan_saving': 'Zapisywanie…', 'settings_node.scan_saved': 'Zapisano.', + 'settings_node.tmdb_title': 'Metadane TMDB', + 'settings_node.tmdb_hint': 'Pozwala aplikacji Wideo pokazywać plakaty, opisy i obsadę z TMDB. Wyłączone oznacza przeglądanie tylko z miniaturami, bez żadnego żądania do zewnętrznego serwisu.', + 'settings_node.tmdb_enabled': 'Włączone', + 'settings_node.tmdb_disabled': 'Wyłączone', + 'settings_node.tmdb_token_label': 'Własny token API (opcjonalnie)', + 'settings_node.tmdb_token_placeholder': 'Pozostaw puste, aby użyć domyślnego tokenu', + 'settings_node.tmdb_token_customized': 'Ustawiono własny token.', + 'settings_node.tmdb_token_default': 'Używany jest domyślny token.', + 'settings_node.tmdb_save': 'Zapisz', + 'settings_node.tmdb_language_label': 'Język', + 'settings_node.tmdb_language_hint': 'Dotyczy wszystkich — jedna współdzielona pamięć podręczna, bez osobnego żądania dla każdego widza.', + 'settings_node.video_root_title': 'Katalog główny Wideo', + 'settings_node.video_root_hint': 'Który katalog (lub podkatalog) aplikacja Wideo traktuje jako punkt wejścia dla tej grupy. W Wideo nic się nie wyświetla i nie są wykonywane żadne zapytania do TMDB, dopóki nie zostanie wybrany.', + 'settings_node.video_root_none': '— nie wybrano —', + 'settings_node.video_root_save': 'Zapisz', + 'settings_node.video_root_change_confirm': 'Zmiana katalogu głównego Wideo zastępuje to, co widzi każdy członek w karcie Wideo. Kontynuować?', // Create-group wizard 'wizard.title': 'Utwórz grupę', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 0001051..bba07a2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -76,6 +76,7 @@ export default { 'group.default_name': 'Grupo', 'group.tab_files': 'Arquivos', 'group.tab_chat': 'Conversa', + 'group.tab_video': 'Vídeos', 'group.tab_members': 'Membros', 'group.tab_settings': "Configurações", 'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.", @@ -156,6 +157,18 @@ export default { 'group.upload_indexing': 'indexando…', 'video.err_transport': 'Transporte não conectado', 'video.err_mse': 'Codec sem suporte para transmissão: {codec}', + 'video.mode_poster': 'Pôsteres', + 'video.mode_flat': 'Lista simples', + 'video.empty': 'Nenhum vídeo encontrado.', + 'video.no_root_configured': 'Ainda não há uma pasta raiz de Vídeos definida para este grupo — um operador pode escolher uma em Configurações.', + 'video.director': 'Diretor', + 'video.specials': 'Especiais', + 'video.season_n': 'Temporada {n}', + 'video.episode_n': 'Episódio {n}', + 'video.n_episodes': { + one: '{n} episódio', + other: '{n} episódios', + }, // LAN cast 'cast.start': 'Transmitir para dispositivo', @@ -568,6 +581,22 @@ export default { 'settings_node.scan_save': 'Salvar', 'settings_node.scan_saving': 'Salvando…', 'settings_node.scan_saved': 'Salvo.', + 'settings_node.tmdb_title': 'Metadados do TMDB', + 'settings_node.tmdb_hint': 'Permite que o app de Vídeos mostre pôsteres, sinopses e elenco do TMDB. Desativado significa navegação apenas com miniaturas, sem nenhuma solicitação a terceiros.', + 'settings_node.tmdb_enabled': 'Ativado', + 'settings_node.tmdb_disabled': 'Desativado', + 'settings_node.tmdb_token_label': 'Token de API personalizado (opcional)', + 'settings_node.tmdb_token_placeholder': 'Deixe em branco para usar o token padrão', + 'settings_node.tmdb_token_customized': 'Um token personalizado está definido.', + 'settings_node.tmdb_token_default': 'Usando o token padrão.', + 'settings_node.tmdb_save': 'Salvar', + 'settings_node.tmdb_language_label': 'Idioma', + 'settings_node.tmdb_language_hint': 'Aplica-se a todos — um cache compartilhado, não uma solicitação por espectador.', + 'settings_node.video_root_title': 'Pasta raiz de Vídeos', + 'settings_node.video_root_hint': 'Qual pasta (ou subpasta) o app Vídeos trata como ponto de entrada para este grupo. Nada é exibido em Vídeos, e nenhuma busca no TMDB é feita, até que uma seja escolhida.', + 'settings_node.video_root_none': '— nenhuma escolhida —', + 'settings_node.video_root_save': 'Salvar', + 'settings_node.video_root_change_confirm': 'Alterar a raiz de Vídeos substitui o que cada membro vê na aba Vídeos. Continuar?', // Create group wizard 'wizard.title': 'Criar grupo', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index ad8bf53..c9199c4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -73,6 +73,7 @@ export default { 'group.default_name': '群组', 'group.tab_files': '文件', 'group.tab_chat': '聊天', + 'group.tab_video': '视频', 'group.tab_members': '成员', 'group.tab_settings': "设置", 'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。", @@ -149,6 +150,18 @@ export default { 'group.upload_indexing': '建立索引中…', 'video.err_transport': '传输未连接', 'video.err_mse': '该编解码器不支持流式播放:{codec}', + 'video.mode_poster': '海报视图', + 'video.mode_flat': '平铺列表', + 'video.empty': '未找到视频。', + 'video.no_root_configured': '此群组尚未设置视频根目录 — 操作员可以在设置中选择一个。', + 'video.director': '导演', + 'video.specials': '特别篇', + 'video.season_n': '第 {n} 季', + 'video.episode_n': '第 {n} 集', + 'video.n_episodes': { + one: '{n} 集', + other: '{n} 集', + }, // LAN cast 'cast.start': '投射到设备', @@ -551,6 +564,22 @@ export default { 'settings_node.scan_save': '保存', 'settings_node.scan_saving': '保存中…', 'settings_node.scan_saved': '已保存。', + 'settings_node.tmdb_title': 'TMDB 元数据', + 'settings_node.tmdb_hint': '允许视频应用显示来自 TMDB 的海报、简介和演职人员。关闭后仅显示缩略图浏览,不会向第三方发送任何请求。', + 'settings_node.tmdb_enabled': '已启用', + 'settings_node.tmdb_disabled': '已禁用', + 'settings_node.tmdb_token_label': '自定义 API 令牌(可选)', + 'settings_node.tmdb_token_placeholder': '留空以使用内置的默认令牌', + 'settings_node.tmdb_token_customized': '已设置自定义令牌。', + 'settings_node.tmdb_token_default': '正在使用内置的默认令牌。', + 'settings_node.tmdb_save': '保存', + 'settings_node.tmdb_language_label': '语言', + 'settings_node.tmdb_language_hint': '适用于所有人 — 共享同一个缓存,不是按观看者分别请求。', + 'settings_node.video_root_title': '视频根目录', + 'settings_node.video_root_hint': '该文件夹(或子文件夹)将作为此群组"视频"应用的入口。在选择之前,"视频"中不会显示任何内容,也不会执行任何 TMDB 查询。', + 'settings_node.video_root_none': '— 未选择 —', + 'settings_node.video_root_save': '保存', + 'settings_node.video_root_change_confirm': '更改视频根目录会替换每位成员在"视频"标签页中看到的内容。是否继续?', // Create group wizard 'wizard.title': '创建群组', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2cb27d0..e1900d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1000,6 +1000,20 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: var(--text); font-size: 0.95em; } +.settings-label input[type="password"], +.settings-label input[type="text"], +.settings-label select { + display: block; + width: 100%; + max-width: 320px; + margin-top: 4px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-base); + color: var(--text); + font-size: 0.95em; +} .settings-value { font-size: 0.9em; @@ -2399,3 +2413,214 @@ a.transfer-name { border-radius: 8px; font-size: 0.85em; } + +/* ── Videos app (video-app.js) ──────────────────────────────────────────────── */ + +.video-toolbar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; +} +.video-toolbar .tb-search { margin-left: auto; } + +/* Mode A — poster grid */ + +.video-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; +} + +.video-tile-slot { + min-height: 260px; +} + +.video-card { + cursor: pointer; + border-radius: 8px; + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border); + transition: border-color 0.12s, transform 0.12s; +} +.video-card:hover { border-color: var(--accent); transform: translateY(-2px); } + +.video-poster { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + display: block; + background: var(--bg-surface); +} + +.video-poster-loading { + display: flex; + align-items: center; + justify-content: center; +} + +.video-poster-slot { display: contents; } + +.video-thumb { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + display: block; + background: var(--bg-surface); +} +.video-thumb-empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); +} +.video-thumb-empty .icon { width: 28px; height: 28px; } + +.video-card-info { padding: 8px 10px; } +.video-card-title { + font-size: 0.88em; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.video-card-sub { + font-size: 0.78em; + color: var(--text-dim); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Detail modal — sits inside the existing .video-overlay */ + +.video-detail { + width: min(720px, 92vw); + max-height: calc(100vh - 100px); + margin-top: 60px; + background: var(--bg-surface); + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; +} +.video-detail .video-top-bar { + position: static; + background: var(--bg-raised); + border-bottom: 1px solid var(--border); +} +.video-detail .video-title { color: var(--text); } +.video-detail .video-close { background: var(--bg-surface); color: var(--text); } +.video-detail .video-close:hover { background: var(--border); } + +.video-detail-body { + padding: 16px 20px; + overflow-y: auto; +} +.video-detail-overview { + font-size: 0.9em; + color: var(--text); + line-height: 1.5; +} +.video-detail-facts { + font-size: 0.82em; + color: var(--text-dim); + margin-top: 6px; +} +.video-detail-cast { + font-size: 0.82em; + color: var(--text-secondary); + margin-top: 6px; +} + +.video-season-list { margin-top: 14px; } +.video-season { margin-bottom: 14px; } +.video-season-header { + font-weight: 600; + font-size: 0.85em; + color: var(--text-dim); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.02em; +} +.video-episode-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 0.85em; + cursor: pointer; + text-align: left; + margin-bottom: 4px; +} +.video-episode-row:hover { border-color: var(--accent); color: var(--accent); } +.video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; } +.video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; } +.video-episode-thumb { + width: 64px; + height: 40px; + object-fit: cover; + border-radius: 4px; + display: block; + background: var(--bg-raised); +} +.video-episode-thumb.video-thumb-empty { border: 1px solid var(--border); } +.video-episode-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.video-episode-meta { + color: var(--text-dim); + font-size: 0.9em; + flex-shrink: 0; +} + +/* Mode B — flat list */ + +.video-flat-list { display: flex; flex-direction: column; gap: 4px; } + +.video-flat-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; +} +.video-flat-row:hover { background: var(--bg-raised); } + +.video-flat-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; } +.video-flat-thumb { + width: 64px; + height: 40px; + object-fit: cover; + border-radius: 4px; + display: block; + background: var(--bg-raised); +} +.video-flat-thumb.video-thumb-empty { border: 1px solid var(--border); } + +.video-flat-info { min-width: 0; flex: 1; } +.video-flat-title { + font-size: 0.88em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.video-flat-sub { font-size: 0.78em; color: var(--text-dim); margin-top: 1px; } + +.video-flat-folder { border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 4px; } +.video-flat-chevron { transition: transform 0.12s; flex-shrink: 0; } +.video-flat-chevron.open { transform: rotate(180deg); } +.video-flat-season { padding-left: 24px; margin-bottom: 8px; } +.video-flat-season .video-season-header { margin: 6px 0 4px; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 4f6b656..dd39df8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -106,6 +106,8 @@ class MeshBayTransport { set onIndexDelta(fn) { this._onIndexDelta = fn; } set onUploadPolicy(fn) { this._onUploadPolicy = fn; } set onAppsEnabled(fn) { this._onAppsEnabled = fn; } + set onTmdbConfig(fn) { this._onTmdbConfig = fn; } + set onVideoRoot(fn) { this._onVideoRoot = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } get sessionKeys() { return this._sessionKeys; } @@ -465,6 +467,66 @@ class MeshBayTransport { return msg; } + /** + * TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4). + * `path` is root+relpath, exactly what index_sync/index_delta already + * gave this browser — never a raw filesystem path constructed here. + * `confidence: 0` (no tmdb_id, no fields) means no confident match — + * the caller falls back to a thumbnail-only card (§4.1), not an error. + */ + async fetchMediaMeta(path) { + const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.5', path }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Turn TMDB lookups on/off node-wide, optionally set/clear a custom API + * token, and optionally set the language TMDB is queried in (e.g. + * "fr-FR") — one for the whole node, same reasoning as the token: one + * shared cache, not a per-viewer request. Signed like setAppsEnabled/ + * setMemberUpload — an unsigned toggle would let any member turn on + * outbound third-party network traffic the operator never agreed to + * (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears a + * previously-set custom token; omit it (undefined/null), like + * `language`, to leave whatever is stored unchanged. + */ + async setTmdbConfig(enabled, token, language, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_config', v: '0.5', enabled: Boolean(enabled), + token: token === undefined ? null : token, + language: language === undefined ? null : language, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // Must match the node's subject byte-for-byte (webrtc_server.py + // _do_tmdb_config): Python's f"{bool}" is "True"/"False", not JS's + // lowercase — and the token itself is never part of the subject + // (it would end up in the audit log in plaintext), only whether one + // was supplied. The language is not a secret, so it appears as-is. + const subject = `enabled=${enabled ? 'True' : 'False'},` + + `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`; + return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn); + } + return msg; + } + + /** + * Which folder (possibly a subfolder of a shared root) the Videos app + * treats as its entry point for this group. `path: ''` means the whole + * group index. Signed like setAppsEnabled — it decides what every + * member's Videos tab shows. + */ + async setVideoRoot(path, signFn) { + const clean = (path || '').replace(/^\/+|\/+$/g, ''); + const msg = await this._sendAndWait({ type: 'video_root', v: '0.5', path: clean }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'video_root', clean, signFn); + } + return msg; + } + async fetchStreamSegment(fileId, segmentIndex, segmentDuration) { const msg = await this._sendAndWait({ type: 'stream_seg', @@ -1173,9 +1235,15 @@ class MeshBayTransport { // *while* other traffic is in flight, so the fallback below would hand // a pong to whatever was waiting — resolving a history request with a // message that has no messages in it, and emptying the conversation. + // media_meta_req is the same shape as file_req: video-app.js fires + // one per visible poster-grid tile, several at a time — matching by + // arrival order handed one tile's TMDB result to a different tile + // whenever two responses reordered (reproduced live: which of two + // shows got the confident match flipped across reloads). _key: obj.type === 'file_req' ? `chunk:${obj.file_id}:${obj.chunk_index}` - : obj.type === 'ping' ? `ping:${obj.token}` : null, + : obj.type === 'ping' ? `ping:${obj.token}` + : obj.type === 'media_meta_req' ? `media_meta:${obj.path}` : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); @@ -1276,6 +1344,23 @@ class MeshBayTransport { this._onAppsEnabled(msg.apps || []); } + // Node-wide (not per-group) — the operator changed whether TMDB is + // called at all, or supplied/cleared a custom token. `token_customized` + // only says whether one is set, never the token itself. + if (msg.type === 'tmdb_config_ack' && this._onTmdbConfig) { + this._onTmdbConfig({ + enabled: Boolean(msg.enabled), + tokenCustomized: Boolean(msg.token_customized), + language: msg.language || '', + }); + } + + // Same shape: the operator changed which folder is the Videos app's + // entry point for this group. + if (msg.type === 'video_root_ack' && this._onVideoRoot) { + this._onVideoRoot(msg.path || ''); + } + // The operator's node is scanning — never the entries themselves, just // enough to animate a presence dot. Pushed periodically while it runs, // plus once more on the transition back to idle (daemon.py @@ -1316,11 +1401,11 @@ class MeshBayTransport { return; } - // Incremental update — additions/deletions only, never the whole index. - // Only ever arrives after the full index this browser already has (the - // node's first push to a newly connected peer is always index_sync, see - // daemon.py _broadcast_index_change), so there is always a base to - // apply it to. + // Incremental update — additions/deletions/updates, never the whole + // index. Only ever arrives after the full index this browser already + // has (the node's first push to a newly connected peer is always + // index_sync, see daemon.py _broadcast_index_change), so there is + // always a base to apply it to. if (msg.type === 'index_delta') { if (this._onIndexDelta) this._onIndexDelta(msg); return; @@ -1364,6 +1449,18 @@ class MeshBayTransport { return; } + if (msg.type === 'media_meta_resp') { + const key = `media_meta:${msg.path}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + // Nobody asked for this path any more (tile scrolled out and a fresh + // request superseded it, most likely) — must not fall through to the + // oldest pending request, which would hand a different tile's promise + // a TMDB result for a path it never asked about. + return; + } + // chat_hist_resp answers a `chat_hist` request, but under a different // type string — unlike index_sync, which is asked for and answered under // the same name, so the generic fallback below happens to work for it by diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js new file mode 100644 index 0000000..250bf8b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -0,0 +1,599 @@ +import { + html, useState, useEffect, useRef, useMemo, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { formatSize, pipelinedDownload } from './file-utils.js'; + +// ── Videos ─────────────────────────────────────────────────────────────────── +// +// A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a +// group's video files, per docs/mediacenter.md. Grouping: one card per movie, +// one card per show — shows are grouped by `display_title` (already +// resolved/corroborated at index time, §3.4), not by folder path, since a +// client-side path convention would have to guess how many roots/subfolders +// deep a show folder sits, which display_title already settled once. +// +// TMDB metadata is fetched lazily, only for a tile once it is actually +// visible (LazyTile below) — apps.md §5's virtualization requirement for a +// grid of many tiles. Thumbnails go through the same `file_req`/chunk path +// as a real file (docs/mediacenter.md §5.3) via MediaThumb, reusing +// chat-app.js's ChatImage pattern. + +const VIEW_MODE_KEY = 'meshbay_video_view_mode'; + +function loadViewMode() { + try { return localStorage.getItem(VIEW_MODE_KEY) === 'flat' ? 'flat' : 'poster'; } + catch { return 'poster'; } +} +function saveViewMode(mode) { + try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* per-device convenience only */ } +} + +function formatDuration(seconds) { + if (!seconds) return ''; + const total = Math.round(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`; +} + +function formatResolution(width, height) { + if (!width || !height) return ''; + if (height >= 2100) return '4K'; + if (height >= 1000) return `${height}p`; + return `${width}x${height}`; +} + +function yearOf(dateStr) { + return dateStr ? String(dateStr).slice(0, 4) : ''; +} + +// ── grouping ───────────────────────────────────────────────────────────────── + +// Nothing shows until an operator has actually chosen a root in Settings +// (§5.6/V-whatever this is now): the node itself runs no TMDB/thumbnail +// work for this group before that either (daemon.py's +// _enrich_new_video_entries), so falling back to "the whole index" here +// would just show files nothing has enriched. +function underVideoRoot(entry, videoRoot) { + if (!videoRoot) return false; + const p = entry.path || ''; + return p === videoRoot || p.startsWith(videoRoot + '/'); +} + +function buildSeasons(episodes) { + const sorted = [...episodes].sort((a, b) => (a.season - b.season) || (a.episode - b.episode)); + const bySeason = new Map(); + for (const ep of sorted) { + if (!bySeason.has(ep.season)) bySeason.set(ep.season, []); + bySeason.get(ep.season).push(ep); + } + return [...bySeason.entries()].sort((a, b) => a[0] - b[0]) + .map(([season, seasonEpisodes]) => ({ season, episodes: seasonEpisodes })); +} + +function groupVideoEntries(entries, videoRoot) { + const movies = []; + const showsByTitle = new Map(); + for (const e of entries) { + if (e.type !== 'video') continue; + if (!underVideoRoot(e, videoRoot)) continue; + if (e.season != null && e.episode != null) { + const title = e.display_title || e.name; + if (!showsByTitle.has(title)) showsByTitle.set(title, { title, episodes: [] }); + showsByTitle.get(title).episodes.push(e); + } else { + movies.push(e); + } + } + movies.sort((a, b) => (a.display_title || a.name).localeCompare(b.display_title || b.name)); + const shows = [...showsByTitle.values()].sort((a, b) => a.title.localeCompare(b.title)); + for (const show of shows) { + show.episodes.sort((a, b) => (a.season - b.season) || (a.episode - b.episode)); + show.seasons = buildSeasons(show.episodes); + } + return { movies, shows }; +} + +// ── lazy-mount tile (apps.md §5 virtualization) ───────────────────────────── + +const LAZY_TILE_MARGIN = 300; + +function LazyTile({ cls = 'video-tile-slot', children }) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (visible || !ref.current) return; + // A tile that is already on screen (or within the margin) the moment + // it mounts — the overwhelmingly common case, since a merge (§V6) or a + // tab revisit mounts tiles into a grid that was already scrolled to + // wherever the operator was looking — doesn't need to wait for + // IntersectionObserver's own first callback at all: that first + // delivery is only a *microtask/next-paint* guarantee, not an + // immediate one, and was observed live taking upwards of 30 seconds + // (matching the browser's own periodic intersection-computation + // cadence exactly) — which read as "the poster never finishes + // loading" even though every fetch behind it had already completed. + // Checked synchronously so a genuinely below-the-fold tile still only + // mounts once actually scrolled near. + const rect = ref.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN; + if (alreadyNear) { setVisible(true); return; } + const obs = new IntersectionObserver((obsEntries) => { + if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); } + }, { rootMargin: `${LAZY_TILE_MARGIN}px` }); + obs.observe(ref.current); + return () => obs.disconnect(); + }, [visible]); + + return html`<div ref=${ref} class=${cls}>${visible ? children : null}</div>`; +} + +// ── thumbnail/poster image, decrypted via the chunk path ──────────────────── +// +// Cached per session by thumb_hash (a content hash, so it never goes stale): +// the same poster reused across a season's worth of episode tiles is +// decrypted once, not once per tile. Blob URLs are not revoked — the number +// of distinct thumbnails one session ever visits is bounded by the library +// size, and reference-counting revocation across many tile mounts/unmounts +// would cost real complexity for a benefit that only matters in a very long +// session. +const _thumbBlobCache = new Map(); + +function MediaThumb({ + thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady, +}) { + const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null); + + // Re-checks the cache by the CURRENT thumbHash on every change rather than + // trusting the `blobUrl` state variable — a PosterCard swaps this same + // component instance's thumbHash prop from the raw fallback frame to the + // TMDB poster once metadata resolves, and gating on "is blobUrl already + // set" (from the *previous* hash) would leave the fallback frame on + // screen forever instead of ever fetching the poster. + // + // `onReady` fires exactly once per settled thumbHash — cache hit, fetch + // success, fetch failure, or no hash at all — so a caller that hides this + // component until its image has actually arrived (PosterCard) always + // gets unstuck, even when there's nothing to show. + useEffect(() => { + const cached = _thumbBlobCache.get(thumbHash); + if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; } + setBlobUrl(null); + if (!thumbHash) { if (onReady) onReady(null); return; } + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { if (onReady) onReady(null); return; } + try { + const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1); + if (cancelled) return; + const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' })); + _thumbBlobCache.set(thumbHash, url); + setBlobUrl(url); + if (onReady) onReady(url); + } catch { + /* leave the placeholder — a transient fetch failure isn't an error state */ + if (!cancelled && onReady) onReady(null); + } + })(); + return () => { cancelled = true; }; + }, [thumbHash]); + + if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name="video" /></div>`; + return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`; +} + +// ── TMDB metadata, fetched once per visible tile ──────────────────────────── + +function useMediaMeta(transportRef, path, active) { + const [meta, setMeta] = useState(null); + useEffect(() => { + if (!active || !path) return; + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const resp = await transport.fetchMediaMeta(path); + if (!cancelled) setMeta(resp); + } catch { if (!cancelled) setMeta({ confidence: 0 }); } + })(); + return () => { cancelled = true; }; + }, [path, active]); + return meta; +} + +// ── Mode A: poster grid ────────────────────────────────────────────────────── + +function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) { + const meta = useMediaMeta(transportRef, repEntry.path, true); + const confident = meta && meta.confidence && meta.tmdb_id; + const metaReady = meta !== null; + + // Reports this tile's own resolution upward so PosterGrid can notice two + // differently-parsed folders (a show split across release groups that + // named its seasons inconsistently, §3.4/V6) resolving to the same TMDB + // id, and merge them into one card — never required for the generic + // per-folder display to work, only an enhancement once it's known safe. + useEffect(() => { + if (meta && onMetaResolved) onMetaResolved(groupKey, meta); + }, [meta, groupKey]); + + const posterHash = confident && meta.poster_thumb_hash ? meta.poster_thumb_hash : repEntry.thumb_hash; + + // Which hash MediaThumb has actually confirmed ready — compared against + // the *current* posterHash below, rather than a separate boolean reset + // by its own effect on posterHash change. That second-writer shape had a + // real bug: the instant metaReady flips true, posterHash jumps from the + // raw fallback frame to the resolved poster in the very same commit that + // mounts MediaThumb for it — and when that poster's bytes are already in + // MediaThumb's session cache (a revisit within the same tab, no reload), + // its onReady fires synchronously from that same mount effect. Effects + // run children-first, so the "reset on posterHash change" effect fired + // *after* it, in the same commit, unconditionally setting the flag back + // to false — with no later event left to ever set it true again. The + // card stayed a spinner forever despite the image already being loaded. + // Deriving readiness from a direct comparison has no such ordering to + // get wrong: whichever of the two fires, in whichever order, the render + // that follows sees the same answer. + const [readyHash, setReadyHash] = useState(null); + const handleImageReady = useCallback(() => setReadyHash(posterHash), [posterHash]); + const imageReady = readyHash === posterHash; + + // Nothing is shown until BOTH the TMDB lookup and the final chosen image + // (the poster once matched, the file's own frame otherwise) have + // actually settled. Revealing the raw per-file frame first and swapping + // it for the poster a moment later — or showing a card that a moment + // later gets absorbed into a neighbour once §V6's merge kicks in — was + // exactly the flash an operator flagged as hard on the eyes. A slow + // lookup (a big, freshly-scanned library) just means the spinner stays a + // little longer, never a partially-drawn card. + const ready = metaReady && imageReady; + + return html` + <div class="video-card" onClick=${onOpen}> + ${!ready && html` + <div class="video-poster video-poster-loading"><span class="spinner"></span></div> + `} + <div class="video-poster-slot" style=${ready ? '' : 'display:none'}> + ${metaReady && html` + <${MediaThumb} thumbHash=${posterHash} alt=${title} + cls="video-poster" transportRef=${transportRef} gekRef=${gekRef} + onReady=${handleImageReady} /> + `} + </div> + ${ready && html` + <div class="video-card-info"> + <div class="video-card-title">${(confident && meta.title) || title}</div> + <div class="video-card-sub"> + ${confident && meta.release_date ? yearOf(meta.release_date) : ''} + ${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''} + ${subtitle ? ` · ${subtitle}` : ''} + </div> + </div> + `} + </div> + `; +} + +function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay }) { + const confident = meta && meta.confidence && meta.tmdb_id; + return html` + <div class="video-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-overlay')) onClose(); + }}> + <div class="video-detail"> + <div class="video-top-bar"> + <span class="video-title">${(confident && meta.title) || title}</span> + <button class="video-close" onClick=${onClose} title=${t('video.close')}> + <${Icon} name="close" /></button> + </div> + <div class="video-detail-body"> + ${confident && html` + <p class="video-detail-overview">${meta.overview}</p> + <p class="video-detail-facts"> + ${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''} + ${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''} + ${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''} + </p> + ${meta.cast && meta.cast.length > 0 && html` + <p class="video-detail-cast"> + ${meta.cast.slice(0, 6).map((c) => c.name).join(', ')} + </p> + `} + `} + ${!show && html` + <button class="admin-btn" onClick=${() => onPlay(repEntry)}> + <${Icon} name="play" /> ${t('group.play')} + ${repEntry.duration ? ` (${formatDuration(repEntry.duration)})` : ''} + </button> + `} + ${show && html` + <div class="video-season-list"> + ${show.seasons.map((s) => html` + <div class="video-season" key=${s.season}> + <div class="video-season-header"> + ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })} + </div> + ${s.episodes.map((ep) => html` + <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}> + <${LazyTile} cls="video-episode-thumb-slot"> + <${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name} + cls="video-episode-thumb" transportRef=${transportRef} gekRef=${gekRef} /> + </${LazyTile}> + <span class="video-episode-label"> + S${ep.season}E${String(ep.episode).padStart(2, '0')} + ${' · '}${ep.display_title || ep.name} + </span> + <span class="video-episode-meta"> + ${formatDuration(ep.duration)} ${formatResolution(ep.width, ep.height)} + </span> + </button> + `)} + </div> + `)} + </div> + `} + </div> + </div> + </div> + `; +} + +function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled }) { + const [detail, setDetail] = useState(null); // { title, repEntry, show? } + // raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp. + const [metaByGroup, setMetaByGroup] = useState({}); + + const handleMetaResolved = useCallback((groupKey, meta) => { + setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta })); + }, []); + + // 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 + // 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 + // with no confident match yet, or ever, still shows on its own, exactly + // the generic per-folder display needs. + const mergedShows = useMemo(() => { + const byTmdbId = new Map(); + const standalone = []; + for (const s of shows) { + const meta = metaByGroup[s.title]; + const tmdbId = meta && meta.confidence && meta.tmdb_id; + if (tmdbId) { + if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []); + byTmdbId.get(tmdbId).push(s); + } else { + standalone.push([s]); + } + } + return [...byTmdbId.values(), ...standalone].map((groups) => { + const episodes = groups.flatMap((g) => g.episodes); + return { + // groups[0].title, not a joined string of every constituent's + // title: a fresh key here would make this a brand-new PosterCard + // (and LazyTile) the instant a second raw group merges into an + // already-visible one — throwing away its already-fired + // IntersectionObserver and already-resolved metadata/poster for no + // reason, and reintroducing exactly the flash the "ready" gating + // above exists to prevent. groups[0].title is already unique + // (raw titles are, via groupVideoEntries' showsByTitle) and, for + // the overwhelmingly common unmerged case, is the same key the + // card already had — so nothing about this changes when no merge + // ever happens. + key: groups[0].title, + title: groups[0].title, + episodes, + seasons: buildSeasons(episodes), + }; + }); + }, [shows, metaByGroup]); + + const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show }); + const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.path : null, !!detail); + + return html` + <div class="video-grid"> + ${movies.map((e) => html` + <${LazyTile} key=${e.id}> + <${PosterCard} title=${e.display_title || e.name} + subtitle=${formatDuration(e.duration)} repEntry=${e} + groupKey=${`movie:${e.id}`} + transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => (tmdbEnabled + // With TMDB off there is nothing the detail modal would show + // for a movie (no overview, no season list to pick from, + // unlike a show) — so it would just be an extra click in + // front of a Play button. Straight to the player instead. + ? openDetail(e.display_title || e.name, e, null) + : onPreview(e))} /> + </${LazyTile}> + `)} + ${mergedShows.map((s) => { + // Prefer an episode that actually has a thumbnail over blindly + // episodes[0]: if that specific file's enrichment hasn't produced + // one yet (or failed), the card showed an empty placeholder even + // though sibling episodes — visible right there in Flat list — + // have one. The TMDB path (or its absence) is the same regardless + // of which episode's own file supplies the fallback frame. + const repEntry = s.episodes.find((e) => e.thumb_hash) || s.episodes[0]; + // Known up front from the already-parsed index fields (§3.4), no + // TMDB needed: a card covering exactly one season says so before + // a click, rather than an anonymous episode count — the generic + // "N episodes" stays for a merged, multi-season, or special-only + // card, where a single number would misrepresent it. + const singleSeason = s.seasons.length === 1 ? s.seasons[0].season : null; + const subtitle = singleSeason != null + ? (singleSeason === 0 ? t('video.specials') : t('video.season_n', { n: singleSeason })) + : t('video.n_episodes', { n: s.episodes.length }); + return html` + <${LazyTile} key=${s.key}> + <${PosterCard} title=${s.title} + subtitle=${subtitle} + repEntry=${repEntry} + groupKey=${s.title} + onMetaResolved=${handleMetaResolved} + transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => openDetail(s.title, repEntry, s)} /> + </${LazyTile}> + `; })} + </div> + ${detail && html` + <${VideoDetailModal} title=${detail.title} meta=${detailMeta} + repEntry=${detail.repEntry} show=${detail.show} + transportRef=${transportRef} gekRef=${gekRef} + onClose=${() => setDetail(null)} + onPlay=${(entry) => { setDetail(null); onPreview(entry); }} /> + `} + `; +} + +// ── Mode B: flat, thumbnail-only, no TMDB ──────────────────────────────────── + +// 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 +// 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 +// over the generic "Episode N" label. +function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext }) { + const isEpisode = seasonContext && entry.season != null && entry.episode != null; + const hasOwnTitle = entry.display_title && entry.display_title !== seasonContext; + const label = isEpisode + ? (hasOwnTitle ? `${t('video.episode_n', { n: entry.episode })} · ${entry.display_title}` + : t('video.episode_n', { n: entry.episode })) + : (entry.display_title || entry.name); + + return html` + <div class="video-flat-row" onClick=${() => onPreview(entry)}> + <${LazyTile} cls="video-flat-thumb-slot"> + <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.display_title || entry.name} + cls="video-flat-thumb" transportRef=${transportRef} gekRef=${gekRef} /> + </${LazyTile}> + <div class="video-flat-info"> + <div class="video-flat-title">${label}</div> + <div class="video-flat-sub"> + ${formatDuration(entry.duration)} ${formatResolution(entry.width, entry.height)} + ${' · '}${formatSize(entry.size)} + </div> + </div> + </div> + `; +} + +function FlatShowFolder({ show, transportRef, gekRef, onPreview }) { + const [open, setOpen] = useState(false); + return html` + <div class="video-flat-folder"> + <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}> + <div class="video-flat-thumb video-thumb-empty"><${Icon} name="folder" /></div> + <div class="video-flat-info"> + <div class="video-flat-title">${show.title}</div> + <div class="video-flat-sub">${t('video.n_episodes', { n: show.episodes.length })}</div> + </div> + <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} /> + </div> + ${open && show.seasons.map((s) => html` + <div class="video-flat-season" key=${s.season}> + <div class="video-season-header"> + ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })} + </div> + ${s.episodes.map((ep) => html` + <${FlatMovieRow} key=${ep.id} entry=${ep} seasonContext=${show.title} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} /> + `)} + </div> + `)} + </div> + `; +} + +function FlatList({ movies, shows, transportRef, gekRef, onPreview }) { + const items = [ + ...movies.map((e) => ({ key: e.display_title || e.name, kind: 'movie', entry: e })), + ...shows.map((s) => ({ key: s.title, kind: 'show', show: s })), + ].sort((a, b) => a.key.localeCompare(b.key)); + + return html` + <div class="video-flat-list"> + ${items.map((it) => it.kind === 'movie' + ? html`<${FlatMovieRow} key=${it.entry.id} entry=${it.entry} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />` + : html`<${FlatShowFolder} key=${it.show.title} show=${it.show} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`)} + </div> + `; +} + +// ── shell ──────────────────────────────────────────────────────────────────── + +function VideoApp({ + groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, +}) { + const [mode, setMode] = useState(loadViewMode); + const [filter, setFilter] = useState(''); + const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true; + + useEffect(() => { setMode(loadViewMode()); }, [groupId]); + useEffect(() => { setFilter(''); }, [groupId]); + + const setModeAndSave = (m) => { setMode(m); saveViewMode(m); }; + + const { movies, shows } = useMemo( + () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]); + + const needle = filter.trim().toLowerCase(); + const filteredMovies = useMemo(() => (!needle ? movies : movies.filter( + (e) => (e.display_title || e.name).toLowerCase().includes(needle))), [movies, needle]); + const filteredShows = useMemo(() => (!needle ? shows : shows.filter( + (s) => s.title.toLowerCase().includes(needle))), [shows, needle]); + + return html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> + `} + ${status === 'offline' && html` + <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> + `} + ${status === 'connected' && !videoRoot && html` + <p class="page-message">${t('video.no_root_configured')}</p> + `} + ${status === 'connected' && videoRoot && html` + <div class="video-toolbar"> + <button class="tb-btn ${mode === 'poster' ? 'active' : ''}" + onClick=${() => setModeAndSave('poster')}> + ${t('video.mode_poster')} + </button> + <button class="tb-btn ${mode === 'flat' ? 'active' : ''}" + onClick=${() => setModeAndSave('flat')}> + ${t('video.mode_flat')} + </button> + <div class="tb-search"> + <${Icon} name="search" /> + <input type="text" placeholder="${t('group.filter')}" + value=${filter} onInput=${(e) => setFilter(e.target.value)} /> + </div> + </div> + ${filteredMovies.length === 0 && filteredShows.length === 0 && html` + <p class="page-message">${needle ? t('group.empty_filter') : t('video.empty')}</p> + `} + ${mode === 'poster' + ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} + tmdbEnabled=${tmdbEnabled} />` + : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`} + `} + `; +} + +export { VideoApp }; diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index dac1357..d7b7fff 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -34,7 +34,7 @@ APP = STATIC / "app.js" # the files named in this list. STATIC_FILES = [ "app.js", "group-page.js", "chat-app.js", "files-app.js", - "video-player.js", "group-settings.js", + "video-player.js", "video-app.js", "group-settings.js", ] pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index e014518..792aebd 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -28,7 +28,8 @@ APP = STATIC / "app.js" CHAT_APP = STATIC / "chat-app.js" GROUP_PAGE = STATIC / "group-page.js" SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", - STATIC / "video-player.js", STATIC / "group-settings.js"] + STATIC / "video-player.js", STATIC / "video-app.js", + STATIC / "group-settings.js"] pytestmark = pytest.mark.skipif( not TRANSPORT.exists(), reason="the SPA sources are not available") diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml index 4999e3d..58f6f7f 100644 --- a/packages/meshbay-node/pyproject.toml +++ b/packages/meshbay-node/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "websockets>=12.0", # hub→node revocation push "aiortc>=1.9", # WebRTC DataChannel for browser P2P (Phase 9) "aiosqlite>=0.20", # async SQLite for chat, audit, bundle stores + "guessit>=4.4", # filename parsing for the Videos app ] [project.optional-dependencies] diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index af85340..65c7da8 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -26,7 +26,7 @@ Usage: """ import asyncio -from dataclasses import asdict +from dataclasses import asdict, replace import base64 import json import logging @@ -38,14 +38,17 @@ from pathlib import Path import uvicorn from meshbay_common import MNP_VERSION -from meshbay_common.protocol import MNP +from meshbay_common.protocol import MNP, index_entry_wire from meshbay_node.audit import AuditStore from meshbay_node.bundle_store import BundleStore from meshbay_node.chat.store import ChatStore from meshbay_node.config import Config, DEFAULT_CONFIG_PATH, load_config, write_example_config -from meshbay_node.roots import RootSet, RootError +from meshbay_node.roots import RootSet, RootError, entry_abs_path from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.indexer import DirectoryIndexer, IndexCache, GroupIndex +from meshbay_node.indexer.enrich import Enricher +from meshbay_node.media_cache import MediaCache +from meshbay_node.tmdb import TmdbClient from meshbay_node.keystore import create_keystore, load_keystore, load_or_create_keystore from meshbay_node.roster import Roster from meshbay_node.transport import ( @@ -62,6 +65,12 @@ if WEBRTC_AVAILABLE: log = logging.getLogger(__name__) +def _under_video_root(path: str, video_root: str) -> bool: + """Mirrors video-app.js's underVideoRoot: same folder, or a descendant.""" + path = path or "" + return path == video_root or path.startswith(video_root + "/") + + # ── Argon2id calibration ────────────────────────────────────────────────────── def calibrate_argon2(target_ms: int = 500) -> None: @@ -132,6 +141,15 @@ class NodeDaemon: self._last_broadcast_snapshot: dict[str, tuple] = {} self._audit_store: AuditStore | None = None self._bundle_store: BundleStore | None = None + self._media_cache: MediaCache | None = None + self._enricher: Enricher | None = None + self._tmdb_client: TmdbClient | None = None + # A file id attempted at most once per daemon run, success or + # failure — a persistently unprobeable file (corrupt, still being + # written) does not get re-queued on every coalesced broadcast. A + # restart retries everything, matching the "disposable, rebuildable" + # stance the rest of this cache takes (docs/mediacenter.md §1/§2). + self._enriched_attempted: set[str] = set() self._roster: Roster | None = None self._indexers: list[DirectoryIndexer] = [] self._tasks: list[asyncio.Task] = [] @@ -312,6 +330,10 @@ class NodeDaemon: # by the signed operation that changes it. "enabled_apps": await self._roster.enabled_apps( group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS), + # Which folder is the Videos app's entry point for this + # group — "" means the whole group index. + "video_root": await self._roster.video_root( + group_cfg.id) if self._roster else "", } if not groups_ctx: @@ -333,6 +355,26 @@ class NodeDaemon: await self._audit_store.open() log.info("Audit store opened: %s", audit_db) + # 6b. Media cache (Videos app — TMDB metadata + thumbnails). + # Node-wide like audit.db, not per-group: a thumbnail is the same + # bytes regardless of which group happens to share the file + # (docs/mediacenter.md §2/§5.5). + media_cache_db = data_dir / "media_cache.db" + self._media_cache = MediaCache(db_path=media_cache_db) + await self._media_cache.open() + self._enricher = Enricher(self._media_cache) + self._tmdb_client = TmdbClient(roster=self._roster) + # Read once at load, like member_upload/enabled_apps — kept + # current in place by ops.set_tmdb_config (the signed op), + # exposed to every group's handshake ack via `daemon_state` + # (already wired to self._webrtc._ctx below) since this is + # node-wide, not per-group. + tmdb_enabled, tmdb_token, tmdb_language = await self._roster.tmdb_config() + self._state["tmdb_enabled"] = tmdb_enabled + self._state["tmdb_token_customized"] = bool(tmdb_token) + self._state["tmdb_language"] = tmdb_language or "" + log.info("Media cache opened: %s", media_cache_db) + # 5. Denylist denylist = self._denylist @@ -359,6 +401,8 @@ class NodeDaemon: self._webrtc._ctx["node_user_id"] = session.user_id self._webrtc._ctx["audit_store"] = self._audit_store self._webrtc._ctx["bundle_store"] = self._bundle_store + self._webrtc._ctx["media_cache"] = self._media_cache + self._webrtc._ctx["tmdb_client"] = self._tmdb_client self._webrtc._ctx["sk_x25519_raw"] = sk_x_raw self._webrtc._ctx["pk_x25519_raw"] = pk_x_raw self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64 @@ -468,6 +512,7 @@ class NodeDaemon: self._state["quic_server"] = self._quic_server self._state["hub"] = hub self._state["reload_fn"] = self._reload_config + self._state["enrich_video_root_fn"] = self._enrich_video_root_now # Rotating a key has to reach every transport holding a copy of it, # and clearing the denylist has to reach the one the handshake # consults — so both are published rather than reachable only @@ -494,6 +539,16 @@ class NodeDaemon: hashes = [e.id for e in gctx["index"].entries] if hashes: await self._register_swarm(hashes, endpoint) + # initial_scan() itself never calls on_change (it predates + # the concept — every existing caller only cared about the + # scan finishing, not about notifying anyone) — but Videos + # app enrichment (duration/thumb_hash/display_title/...) + # hangs entirely off that callback (_broadcast_index_change). + # Without this, every file already on disk at startup — the + # common case, an existing library — would never get + # enriched at all; only a file added later, while the node + # is already running, would trigger it via the watchdog. + await self._on_index_change(indexer) for idx, group_cfg in zip(self._indexers, self._config.groups): gctx = groups_ctx.get(group_cfg.id) @@ -663,6 +718,9 @@ class NodeDaemon: "enabled_apps": ( await self._roster.enabled_apps(group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS)), + "video_root": ( + await self._roster.video_root(group_cfg.id) + if self._roster else ""), "chat_store": store, } groups_ctx[group_cfg.id] = new_ctx @@ -887,6 +945,22 @@ class NodeDaemon: delta = idx.diff(previous) self._last_broadcast_snapshot[group_id] = (idx.version, idx.entries_by_id()) + # Videos app (docs/mediacenter.md §5.2): schedule async technical + # probe + title parse + thumbnail generation for every newly-seen + # video entry under the group's configured video_root. Never blocks + # this broadcast — enrichment fields arrive later as their own + # INDEX_DELTA update (_on_enriched below). + new_entries = delta.additions if delta is not None else list(idx.entries) + asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries)) + + # Videos app: a file that leaves the index also loses its thumbnail + # and file->tmdb mapping — the "real deletion obligation" docs/ + # mediacenter.md §2/§8 calls out explicitly rather than leaving + # implicit. tmdb_meta rows are left alone (§2: shared across files). + if delta is not None and delta.deletions and self._media_cache: + for file_id in delta.deletions: + asyncio.ensure_future(self._media_cache.prune_file(file_id)) + # 11.5 — Push to connected WebRTC peers in this group if self._webrtc: if delta is not None: @@ -896,12 +970,9 @@ class NodeDaemon: "group_id": idx.group_id, "base_version": delta.base_version, "version": delta.version, - "additions": [ - {"id": e.id, "name": e.name, "path": e.path, - "size": e.size, "type": e.type, "added_at": e.added_at} - for e in delta.additions - ], + "additions": [index_entry_wire(e) for e in delta.additions], "deletions": delta.deletions, + "updates": [index_entry_wire(e) for e in delta.updates], } else: msg = { @@ -909,11 +980,7 @@ class NodeDaemon: "v": MNP_VERSION, "group_id": idx.group_id, "version": idx.version, - "entries": [ - {"id": e.id, "name": e.name, "path": e.path, - "size": e.size, "type": e.type, "added_at": e.added_at} - for e in idx.entries - ], + "entries": [index_entry_wire(e) for e in idx.entries], } pushed = 0 for session in list(self._webrtc._sessions.values()): @@ -942,6 +1009,77 @@ class NodeDaemon: endpoint = f"webrtc:{self._config.node.quic_port}" asyncio.ensure_future(self._register_swarm(hashes, endpoint)) + async def _enrich_new_video_entries(self, indexer: DirectoryIndexer, entries: list) -> None: + """ + Videos app: fire (never await further) enrichment for unattempted + video entries under the group's configured video_root. + + A group with no video_root set yet does not enrich anything — TMDB + lookups and ffmpeg thumbnailing are real, ongoing per-file cost + (mediacenter.md §5.2/§10), and running them over an operator's whole + shared index before they have chosen which folder is actually their + media library would burn both TMDB's rate limit and the node's CPU + on files that were never meant to be in the Videos app at all. Once a + root is set, `_enrich_video_root_now` (called from ops.set_video_root) + separately sweeps whatever it already contains — this path alone only + ever sees entries new since the last broadcast. + """ + if not self._enricher or not self._roster: + return + video_root = await self._roster.video_root(indexer.group_id) + if not video_root: + return + for entry in entries: + if entry.type != "video" or entry.id in self._enriched_attempted: + continue + if not _under_video_root(entry.path, video_root): + continue + file_path = entry_abs_path(indexer.roots, entry) + if not file_path or not file_path.exists(): + continue + self._enriched_attempted.add(entry.id) + + async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: + await self._on_enriched(_indexer, file_id, fields) + + self._enricher.spawn(entry, file_path, on_done) + + async def _enrich_video_root_now(self, group_id: str) -> None: + """ + Videos app: sweep a group's existing index for enrichment right + after its video_root is set or changed (ops.set_video_root). + + The ordinary path above only ever looks at entries new since the + last broadcast, so a folder that already had files sitting in it + before it became the video_root would otherwise never get enriched + at all — nothing else re-visits already-indexed entries once they + have been broadcast once. + """ + indexer = self._state.get("indexers", {}).get(group_id) + if not indexer: + return + await self._enrich_new_video_entries(indexer, list(indexer.index.entries)) + + async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None: + """ + Merge enrichment fields into the live index and re-trigger a + broadcast so they reach connected clients as an INDEX_DELTA update + (GroupIndex.diff's `updates`, not `additions` — same id, new fields). + + Builds a *new* IndexEntry via dataclasses.replace rather than + mutating the existing one in place: the diff mechanism compares + against a shallow snapshot of entry *references*, so an in-place + mutation would silently also change what "previous" looks like, + and the change would never show up as a diff (see group_index.py's + diff() docstring). + """ + idx = indexer.index + entry = idx.get_entry(file_id) + if entry is None: + return # removed from the index while enrichment was in flight + idx.add_entry(replace(entry, **fields)) + await self._on_index_change(indexer) + def _drop_group_sessions(self, group_id: str) -> None: """Close live sessions for a revoked group (H4).""" if not self._webrtc or not group_id: @@ -983,6 +1121,12 @@ class NodeDaemon: if self._bundle_store: await self._bundle_store.close() + if self._tmdb_client: + await self._tmdb_client.close() + + if self._media_cache: + await self._media_cache.close() + if self._roster: await self._roster.close() diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py new file mode 100644 index 0000000..784b2a3 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py @@ -0,0 +1,166 @@ +""" +Index-time enrichment for the Videos group app: technical probe (ffprobe), +filename parsing (title_parse), and thumbnail generation (ffmpeg) for a +newly-added video IndexEntry. + +Runs through its own small bounded worker pool — separate from the streaming +transcode pool (docs/mediacenter.md §5.2, mirroring webrtc_server.py's +`_transcode_semaphore`) — so indexing a large library never blocks on this, +and enrichment never competes with an active viewer for CPU. The scan itself +already put the entry in the index with hash/size/type only; this fills in +the rest asynchronously and hands the result back via a callback. +""" + +import asyncio +import logging +from pathlib import Path +from typing import Awaitable, Callable + +import blake3 + +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer import title_parse +from meshbay_node.indexer.indexer import MEDIA_EXTENSIONS +from meshbay_node.media_cache import MediaCache +from meshbay_node.media_probe import probe_video + +log = logging.getLogger(__name__) + +DEFAULT_MAX_CONCURRENT = 2 +PROBE_TIMEOUT_SECS = 30 +THUMB_TIMEOUT_SECS = 30 +THUMB_WIDTH = 320 +# "Show/SeasonFolder/episode.mkv" is the expected shape, with a little slack +# for an extra wrapper folder — not an attempt to find the exact group root. +MAX_ANCESTOR_DEPTH = 4 +# Bounds the "borrow a title from a sibling episode filename" scan (§3.4) so +# a folder with thousands of files costs a fixed, small amount of work. +MAX_SIBLINGS_CHECKED = 20 + + +def _season_from_ancestors(file_path: Path) -> int | None: + folder = file_path.parent + for _ in range(MAX_ANCESTOR_DEPTH): + if folder is None or folder == folder.parent: + break + season = title_parse.season_from_folder_name(folder.name) + if season is not None: + return season + folder = folder.parent + return None + + +def _title_from_siblings(file_path: Path) -> str | None: + """ + §3.4: an episode filename with no show name in it borrows the title from + a representative sibling in the same folder, never from the folder name + alone (an acronym-named show folder is a real, observed case). + """ + try: + names = sorted(p.name for p in file_path.parent.iterdir() if p.is_file()) + except OSError: + return None + checked = 0 + for name in names: + if name == file_path.name: + continue + if Path(name).suffix.lower() not in MEDIA_EXTENSIONS["video"]: + continue + checked += 1 + if checked > MAX_SIBLINGS_CHECKED: + break + parsed = title_parse.parse_episode_filename(name) + if parsed.display_title: + return parsed.display_title + return None + + +async def _make_thumbnail(file_path: Path, duration: float | None) -> bytes | None: + """One ffmpeg frame grab at ~10% of duration (or 5s if unknown), scaled down.""" + seek = max(0.0, (duration or 50.0) * 0.1) + proc = await asyncio.create_subprocess_exec( + "ffmpeg", "-v", "error", "-ss", str(seek), "-i", str(file_path), + "-frames:v", "1", "-vf", f"scale={THUMB_WIDTH}:-1", + "-f", "image2", "-c:v", "mjpeg", "pipe:1", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), THUMB_TIMEOUT_SECS) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return None + return stdout or None + + +class Enricher: + """Owns the node's bounded index-time enrichment pool.""" + + def __init__(self, media_cache: MediaCache, max_concurrent: int = DEFAULT_MAX_CONCURRENT): + self._media_cache = media_cache + self._sem = asyncio.Semaphore(max_concurrent) + self._tasks: set[asyncio.Task] = set() + + def spawn( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> asyncio.Task: + """ + Fire-and-forget one file's enrichment. `on_done(file_id, fields)` is + awaited with the index fields to merge in once ready — never blocks + the caller (a scan or watchdog event). The reference this method + returns is what keeps the task alive; callers should hold it the + same way `WebRTCPeerSession._spawn` holds streaming tasks. + """ + task = asyncio.ensure_future(self._run(entry, file_path, on_done)) + self._tasks.add(task) + + def _cleanup(t: asyncio.Task) -> None: + self._tasks.discard(t) + if not t.cancelled() and t.exception(): + log.error("Enrichment failed for %s: %s", entry.id[:12], t.exception(), + exc_info=t.exception()) + task.add_done_callback(_cleanup) + return task + + async def _run( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> None: + async with self._sem: + fields: dict = {} + duration: float | None = None + try: + _codec, duration, _has_audio, width, height = await asyncio.wait_for( + probe_video(str(file_path)), timeout=PROBE_TIMEOUT_SECS) + fields["duration"] = int(duration) if duration else None + fields["width"] = width + fields["height"] = height + except Exception as e: + log.warning("Probe failed for %s: %s", file_path, e) + + ep = title_parse.parse_episode_filename(entry.name) + if ep.episode is not None: + title = ep.display_title or await asyncio.to_thread( + _title_from_siblings, file_path) + season = ep.season + if season is None: + season = await asyncio.to_thread(_season_from_ancestors, file_path) + fields["display_title"] = title or title_parse.naive_title(entry.name) + fields["season"] = season + fields["episode"] = ep.episode + else: + mv = title_parse.parse_movie_filename(entry.name) + fields["display_title"] = mv.display_title or mv.naive_title + + try: + thumb = await _make_thumbnail(file_path, duration) + except Exception as e: + log.warning("Thumbnail generation failed for %s: %s", file_path, e) + thumb = None + if thumb: + thumb_hash = blake3.blake3(thumb).hexdigest() + await self._media_cache.put_thumb(thumb_hash, entry.id, thumb) + fields["thumb_hash"] = thumb_hash + + await on_done(entry.id, fields) diff --git a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py index ec98667..1ce4e0a 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py @@ -78,6 +78,19 @@ class GroupIndex: def get_entry(self, file_id: str) -> IndexEntry | None: return self._entries.get(file_id) + def get_entry_by_path(self, path: str) -> IndexEntry | None: + """ + Linear scan — entries are keyed by content id, not path, and nothing + before the Videos app needed to go the other way (a client always + already has the id from index_sync/index_delta). Fine for an + on-demand, per-tile lookup against a few thousand entries; revisit + if a future caller makes this hot. + """ + for entry in self._entries.values(): + if entry.path == path: + return entry + return None + @property def entries(self) -> list[IndexEntry]: return list(self._entries.values()) @@ -205,16 +218,34 @@ class GroupIndex: # ── Delta ───────────────────────────────────────────────────────────────── def diff(self, previous: "GroupIndex") -> IndexDelta: - """Compute what changed since a previous version of this index.""" + """ + Compute what changed since a previous version of this index. + + A shared id whose entry object now compares unequal (field-by-field, + via IndexEntry's dataclass-generated __eq__) is an update, not an + addition — the Videos app's async enrichment (duration, thumb_hash, + title, ...) replaces an existing entry's fields after the fact via + `add_entry`, which never introduces a new id. This only works + because that replacement always constructs a *new* IndexEntry object + (`dataclasses.replace`, never in-place attribute mutation) — mutating + the same object in place would also mutate `previous`'s copy, since + entries_by_id() is a shallow dict copy, and the two would always + compare equal. + """ prev_ids = set(previous._entries) curr_ids = set(self._entries) additions = [self._entries[i] for i in curr_ids - prev_ids] deletions = list(prev_ids - curr_ids) + updates = [ + self._entries[i] for i in curr_ids & prev_ids + if self._entries[i] != previous._entries[i] + ] return IndexDelta( base_version=previous.version, version=self.version, additions=additions, deletions=deletions, + updates=updates, ) diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py new file mode 100644 index 0000000..ef522ad --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py @@ -0,0 +1,174 @@ +""" +Filename -> title/year/season/episode parsing for the Videos group app. + +Wraps `guessit` and layers the fixes from docs/mediacenter.md §3.3/§3.4 on +top of it: none of them are per-title hacks, each is a generic rule found +by validating guessit's raw output against real TMDB search results over a +~1950-file library (movies, TV shows, and a small franchise set). + +Scope is deliberately narrow (§3.5): title, year, season, episode. Technical +facts (resolution, codec, duration) come from ffprobe, never the filename — +a mislabeled `1080p` tag is a real, observed failure mode. + +This module never touches the filesystem or the network. The orchestration +that decides *which* file supplies a show's title (a representative episode +filename, not the folder name — §3.4) lives in the indexer, which has the +directory listing; this module only parses strings it's handed. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from guessit import guessit + +# French edition/release vocabulary guessit's (English-centric) edition list +# doesn't recognize — left stuck to the title instead of stripped as a tag. +_EDITION_PHRASES = ( + r"version\s+longue", + r"version\s+int[ée]grale", + r"remasteris[ée]e?", + r"non[\s._-]*censur[ée]e?", + r"original\s+version", +) +_EDITION_RE = re.compile("|".join(_EDITION_PHRASES), re.IGNORECASE) + +# A season-like ancestor folder: the English/French words plus a number or +# Roman numeral. Vocabulary is a plain tuple so a deployment can extend it +# per locale without touching the regex-building logic. +SEASON_WORDS = ("season", "saison") +_SEASON_RE = re.compile( + r"(?:" + "|".join(SEASON_WORDS) + r")\s*([0-9]+|[ivxlc]+)\b", + re.IGNORECASE, +) +_SPECIALS_RE = re.compile(r"\b(?:bonus|extras?|specials?)\b", re.IGNORECASE) + +_ROMAN_NUMERALS = { + 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", + 7: "VII", 8: "VIII", 9: "IX", 10: "X", +} + + +def _roman_to_int(s: str) -> int | None: + values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100} + s = s.lower() + if not s or any(c not in values for c in s): + return None + total = 0 + prev = 0 + for c in reversed(s): + v = values[c] + total += v if v >= prev else -v + prev = v + return total or None + + +def _strip_editions(title: str) -> str: + return re.sub(r"\s+", " ", _EDITION_RE.sub(" ", title)).strip() + + +def naive_title(filename: str) -> str: + """ + The mandated fallback (§3.6, §4.1): strip the extension, replace every + `.`/`_`/`-` with a space, drop a trailing parenthesized year, collapse + whitespace. Always computable, never fails, used both as the flat-mode + display name of last resort and as a second TMDB query candidate. + """ + stem = filename.rsplit(".", 1)[0] if "." in filename else filename + stem = re.sub(r"[._-]+", " ", stem) + stem = re.sub(r"\(\s*(19|20)\d{2}\s*\)", " ", stem) + stem = _strip_editions(stem) + return re.sub(r"\s+", " ", stem).strip() + + +def sequel_variants(title: str) -> list[str]: + """ + A trailing sequel digit sometimes has no equivalent in the real TMDB + title, or the real title uses a Roman numeral instead (§3.3 row 4). + Returns extra candidates to try — empty if `title` has no trailing digit. + """ + m = re.match(r"^(.*\S)\s+([2-9])$", title) + if not m: + return [] + base, digit = m.group(1), int(m.group(2)) + variants = [base] + roman = _ROMAN_NUMERALS.get(digit) + if roman: + variants.append(f"{base} {roman}") + return variants + + +def season_from_folder_name(name: str) -> int | None: + """ + §3.4: a season-like ancestor folder, vocabulary-driven rather than + assuming a numeric convention everywhere. A specials/bonus/extras + folder maps to season 0 (matching TMDB's own `season_number: 0`). + Returns None if `name` doesn't look like a season folder at all. + """ + if _SPECIALS_RE.search(name): + return 0 + m = _SEASON_RE.search(name) + if not m: + return None + token = m.group(1) + if token.isdigit(): + return int(token) + return _roman_to_int(token) + + +@dataclass +class ParsedName: + display_title: str | None # None => caller must supply from elsewhere (e.g. a sibling file) + alt_title: str | None = None # guessit's alternative_title, a second query candidate (§3.3 row 1) + naive_title: str = "" # always available, fully punctuation-normalized fallback + year: int | None = None + season: int | None = None + episode: int | None = None + confidence: bool = False # True only when display_title is set and structurally corroborated + + +def parse_movie_filename(filename: str) -> ParsedName: + """Parse a standalone movie filename.""" + g = guessit(filename) + title = g.get("title") + title = str(title).strip() if title else None + if title: + title = _strip_editions(title) + alt = g.get("alternative_title") + alt = _strip_editions(str(alt).strip()) if alt else None + year = g.get("year") + nt = naive_title(filename) + confidence = bool(title) and len(title) >= 2 and year is not None + return ParsedName( + display_title=title or None, alt_title=alt, naive_title=nt, + year=year, confidence=confidence, + ) + + +def parse_episode_filename(filename: str) -> ParsedName: + """ + Parse an episode filename. `display_title` may come back None (e.g. + `S08E02.SUBFRENCH.720p.mkv` carries no show name at all, §3.2) — the + indexer then supplies the show title from a representative sibling + filename in the same folder rather than the folder name itself (§3.4). + """ + g = guessit(filename) + title = g.get("title") + title = str(title).strip() if title else None + if title: + title = _strip_editions(title) + season = g.get("season") + episode = g.get("episode") + # guessit returns a list when it finds more than one candidate (e.g. a + # multi-episode file); take the first as the representative one. + if isinstance(season, list): + season = season[0] if season else None + if isinstance(episode, list): + episode = episode[0] if episode else None + nt = naive_title(filename) + confidence = bool(title) and len(title) >= 2 and season is not None and episode is not None + return ParsedName( + display_title=title or None, naive_title=nt, + season=season, episode=episode, confidence=confidence, + ) diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py new file mode 100644 index 0000000..129927d --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -0,0 +1,153 @@ +""" +MeshBay Node — TMDB metadata and thumbnail cache for the Videos group app. + +Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as +`tmdb_enabled`/`tmdb_api_token` living in `group_settings` under the +`group_id=""` sentinel (docs/mediacenter.md §5.5): TMDB is one operator's +budget and credential, and a thumbnail is the same bytes regardless of which +group happens to share the file. + +Disposable and rebuildable, like the rest of the file index (§1, §2) — never +a second identity for a file. Every row here is keyed off a value the node +can already derive (a file's own blake3 id, or a TMDB id), so losing this +database costs re-probing/re-fetching, not data. +""" + +import json +import logging +import time +from pathlib import Path + +import aiosqlite + +log = logging.getLogger(__name__) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS file_tmdb ( + file_id TEXT PRIMARY KEY, + tmdb_id TEXT NOT NULL, + media_type TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS tmdb_meta ( + tmdb_id TEXT NOT NULL, + media_type TEXT NOT NULL, + json TEXT NOT NULL, + fetched_at REAL NOT NULL, + PRIMARY KEY (tmdb_id, media_type) +); +CREATE TABLE IF NOT EXISTS thumbs ( + thumb_hash TEXT PRIMARY KEY, + file_id TEXT NOT NULL, + jpeg BLOB NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_thumbs_file ON thumbs(file_id); +""" + +# TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not +# need re-checking on this schedule, only the metadata blob (§5.4, V3). +TMDB_META_TTL_SECS = 30 * 86400 + + +class MediaCache: + """Async SQLite cache for TMDB lookups and generated thumbnails.""" + + def __init__(self, db_path: Path): + self._db_path = db_path + self._db: aiosqlite.Connection | None = None + + async def open(self) -> None: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + self._db = await aiosqlite.connect(str(self._db_path)) + await self._db.executescript(_SCHEMA) + await self._db.commit() + + async def close(self) -> None: + if self._db: + await self._db.close() + self._db = None + + # ── file -> tmdb id mapping ────────────────────────────────────────────── + + async def get_file_tmdb(self, file_id: str) -> tuple[str, str] | None: + """Returns (tmdb_id, media_type), or None if this file was never resolved.""" + async with self._db.execute( + "SELECT tmdb_id, media_type FROM file_tmdb WHERE file_id = ?", + (file_id,), + ) as cur: + row = await cur.fetchone() + return (row[0], row[1]) if row else None + + async def set_file_tmdb(self, file_id: str, tmdb_id: str, media_type: str) -> None: + await self._db.execute( + "INSERT OR REPLACE INTO file_tmdb (file_id, tmdb_id, media_type) " + "VALUES (?, ?, ?)", + (file_id, tmdb_id, media_type), + ) + await self._db.commit() + + # ── tmdb id -> metadata json ───────────────────────────────────────────── + + async def get_tmdb_meta(self, tmdb_id: str, media_type: str) -> dict | None: + """Returns None on a miss or on an entry older than TMDB_META_TTL_SECS.""" + async with self._db.execute( + "SELECT json, fetched_at FROM tmdb_meta WHERE tmdb_id = ? AND media_type = ?", + (tmdb_id, media_type), + ) as cur: + row = await cur.fetchone() + if not row: + return None + raw_json, fetched_at = row + if time.time() - fetched_at > TMDB_META_TTL_SECS: + return None + return json.loads(raw_json) + + async def set_tmdb_meta(self, tmdb_id: str, media_type: str, meta: dict) -> None: + await self._db.execute( + "INSERT OR REPLACE INTO tmdb_meta (tmdb_id, media_type, json, fetched_at) " + "VALUES (?, ?, ?, ?)", + (tmdb_id, media_type, json.dumps(meta), time.time()), + ) + await self._db.commit() + + # ── thumbnails ──────────────────────────────────────────────────────────── + + async def get_thumb(self, thumb_hash: str) -> bytes | None: + async with self._db.execute( + "SELECT jpeg FROM thumbs WHERE thumb_hash = ?", (thumb_hash,), + ) as cur: + row = await cur.fetchone() + return bytes(row[0]) if row else None + + async def get_thumb_hash_by_file_id(self, file_id: str) -> str | None: + """ + A TMDB poster/backdrop is stored under a synthetic file_id + (`tmdb:{poster_path}`, stable across requests for the same image) — + this is how `_fetch_and_cache_poster` recognizes "already fetched" + without knowing the content hash up front (that's only known once + the bytes are downloaded). + """ + async with self._db.execute( + "SELECT thumb_hash FROM thumbs WHERE file_id = ?", (file_id,), + ) as cur: + row = await cur.fetchone() + return row[0] if row else None + + async def put_thumb(self, thumb_hash: str, file_id: str, jpeg: bytes) -> None: + await self._db.execute( + "INSERT OR REPLACE INTO thumbs (thumb_hash, file_id, jpeg) VALUES (?, ?, ?)", + (thumb_hash, file_id, jpeg), + ) + await self._db.commit() + + # ── pruning ─────────────────────────────────────────────────────────────── + + async def prune_file(self, file_id: str) -> None: + """ + Called when a file leaves the index (deletion, unshared root). Removes + its thumbnail and its file->tmdb mapping. `tmdb_meta` rows are left + alone — they're keyed by tmdb_id, not file_id, and other files (other + episodes of the same show) may still reference the same entry. + """ + await self._db.execute("DELETE FROM thumbs WHERE file_id = ?", (file_id,)) + await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,)) + await self._db.commit() diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py new file mode 100644 index 0000000..8ad883d --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/media_probe.py @@ -0,0 +1,70 @@ +""" +ffprobe wrapper shared by stream-time codec detection (transport/webrtc_server.py) +and index-time technical-field enrichment (indexer/enrich.py). + +Split out of webrtc_server.py so the indexer package (which webrtc_server.py +already imports from) can call it too without a circular import. +""" + +import asyncio +import json + +_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"} + + +async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, int | None]: + """ + Probe video file with ffprobe, return (MSE codec string, duration, + has_audio, width, height). + + The audio half of the codec string is always "mp4a.40.2" (AAC-LC) or + absent — never the source's real audio codec — because the streaming + path always transcodes audio to AAC and never copies it: MSE in every + mainstream browser only decodes AAC/Opus, and a source codec outside + that (AC-3, E-AC-3, DTS, ...) is at best silently unplayable and at + worst, for E-AC-3 at least, makes ffmpeg itself refuse to write the + fragmented MP4 header ("Cannot write moov atom before EAC3 packets + parsed" — reproduced against a real 5.1 E-AC-3 WEB-DL). Video stays + whatever it actually is: it is always copied, never transcoded. + + width/height come from the same ffprobe call (one extra `-show_entries` + field, no second process spawn) — resolution is deliberately never + guessed from the filename (docs/mediacenter.md §3.5). + """ + proc = await asyncio.create_subprocess_exec( + "ffprobe", "-v", "error", + "-show_entries", "stream=codec_name,profile,level,codec_type,width,height", + "-show_entries", "format=duration", + "-of", "json", path, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + info = json.loads(stdout) + duration = float(info.get("format", {}).get("duration", 0)) + + v_codec = "" + has_audio = False + width: int | None = None + height: int | None = None + for s in info.get("streams", []): + if s.get("codec_type") == "video" and not v_codec: + cn = s.get("codec_name", "") + if cn == "h264": + p = _H264_PROFILES.get(s.get("profile", "High"), "64") + lvl = int(s.get("level", 40)) + v_codec = f"avc1.{p}00{lvl:02x}" + elif cn == "hevc": + v_codec = "hev1.1.6.L93.B0" + elif cn == "vp9": + v_codec = "vp09.00.10.08" + elif cn == "av1": + v_codec = "av01.0.01M.08" + width = s.get("width") + height = s.get("height") + elif s.get("codec_type") == "audio": + has_audio = True + + if not v_codec: + return None, duration, has_audio, width, height + codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec + return codec, duration, has_audio, width, height diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 154813d..6848e3a 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -731,6 +731,68 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: return {"apps": apps, "group_id": group_id} +# ── TMDB config (Videos app) ───────────────────────────────────────────────── + +async def set_tmdb_config(state: dict, enabled: bool, token: str | None = None, + language: str | None = None) -> dict: + """ + Whether the node calls TMDB at all, whether it uses a custom API token + instead of the shipped default, and in what language it queries TMDB + (docs/mediacenter.md §5.5). + + Node-wide (roster.py group_settings, group_id="") rather than per-group + like set_member_upload/set_enabled_apps: TMDB is one operator's budget, + one credential and one shared cache, not a per-group or per-viewer + concern. `token=""` explicitly clears a previously-set custom token + (reverts to the shipped default); `token=None` leaves whatever was + there unchanged. Same discipline for `language`. + """ + roster = _roster(state) + await roster.set_tmdb_config(enabled, token, language, set_by=state.get("node_user_id", "")) + state["tmdb_enabled"] = enabled + # `token=None` means "leave whatever was there" (§ set_tmdb_config's own + # docstring) — so the customized flag only changes when a value (a real + # token, or "" to clear one) was actually given. + if token is not None: + state["tmdb_token_customized"] = bool(token) + if language is not None: + state["tmdb_language"] = language + log.info("TMDB config: enabled=%s custom_token=%s language=%s", + enabled, bool(token), language or state.get("tmdb_language", "")) + return { + "enabled": enabled, + "token_customized": state.get("tmdb_token_customized", False), + "language": state.get("tmdb_language", ""), + } + + +async def set_video_root(state: dict, group_id: str, path: str) -> dict: + """ + Which folder (possibly a subfolder of a shared root) is the Videos app's + entry point for this group. Same shape as set_enabled_apps: lives on the + node (roster.db), takes effect without a restart, signed by the operator. + `path=""` clears it — the Videos tab then asks for one to be chosen + before anything (including TMDB enrichment, docs/mediacenter.md §5.2) + runs, rather than defaulting to the whole shared index. + + A non-empty path fires (never awaits) a sweep of whatever that folder + already contains: the ordinary per-change enrichment path only ever + looks at files new since the last broadcast, so anything already sitting + in a folder before it became the video_root would otherwise never be + picked up. + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_video_root(group_id, path, set_by=state.get("node_user_id", "")) + ctx["video_root"] = path + log.info("Videos root for group %s: %r", group_id[:8], path) + if path: + enrich_fn = state.get("enrich_video_root_fn") + if enrich_fn: + asyncio.ensure_future(enrich_fn(group_id)) + return {"path": path, "group_id": group_id} + + # ── Scan settings ──────────────────────────────────────────────────────────── async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: float, diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 6eb7651..701462f 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -599,6 +599,52 @@ class Roster: json.dumps(sorted(apps)), set_by) return apps + # TMDB is one operator's budget and credential, not a per-group concern + # (docs/mediacenter.md §5.5) — stored under the group_id="" sentinel, + # the same precedent as `roster.get_member("", user_id)` authorizing the + # operator node-wide (desktop-client-v1.md §6.3). Unset means "on, using + # the shipped default token" — the same "absent means the old behaviour" + # discipline member_upload/enabled_apps already follow. + SETTING_TMDB_ENABLED = "tmdb_enabled" + SETTING_TMDB_TOKEN = "tmdb_api_token" + # A TMDB language tag (e.g. "fr-FR") — one for the whole node, same + # reasoning as the token: one shared cache, not a per-viewer request. + # Unset means TMDB's own default (English) rather than this node + # guessing one. + SETTING_TMDB_LANGUAGE = "tmdb_language" + NODE_WIDE_GROUP_ID = "" + + async def tmdb_config(self) -> tuple[bool, str | None, str | None]: + """Returns (enabled, custom_token_or_None, language_or_None).""" + enabled = (await self.get_setting( + self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_ENABLED, "1")) != "0" + token = await self.get_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_TOKEN) + language = await self.get_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE) + return enabled, (token or None), (language or None) + + async def set_tmdb_config(self, enabled: bool, token: str | None = None, + language: str | None = None, set_by: str = "") -> None: + await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_ENABLED, + "1" if enabled else "0", set_by) + if token is not None: + await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_TOKEN, + token, set_by) + if language is not None: + await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE, + language, set_by) + + # Which folder is the Videos app's entry point for this group — per-group + # (unlike tmdb_config above), since different groups share different + # trees. Empty/unset means the whole group index, exactly as today. + SETTING_VIDEO_ROOT = "video_root" + + async def video_root(self, group_id: str) -> str: + return await self.get_setting(group_id, self.SETTING_VIDEO_ROOT, "") or "" + + async def set_video_root(self, group_id: str, path: str, set_by: str = "") -> str: + await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by) + return path or "" + # How often the indexer's reconciliation backstop runs, and how long it # waits after the last change on a file before hashing it. Unset means # the indexer's own defaults — an existing group's behaviour must not diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py new file mode 100644 index 0000000..5e5ed9f --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/tmdb.py @@ -0,0 +1,159 @@ +""" +TMDB (themoviedb.org) client for the Videos group app. + +Called only by the node, never by a client (docs/mediacenter.md §2): the +node holds the one credential and makes the one request per unique title, +shared by every member. Token resolution order (§5.5): + + 1. an operator-supplied token (roster.py group_settings, group_id="") + 2. the MESHBAY_TMDB_DEFAULT_TOKEN environment variable + 3. none — TMDB lookups are inert (callers get an empty result, never an + exception, so a node with no token configured just serves thumbnails) + +The real secret (whichever token resolves) never appears in source control: +there is no literal fallback value in this file. See mediacenter.md's +implementation notes on why a shipped default is a deployment concern, not +a code concern. + +Results also come back in whatever language the operator configured +(roster.py's `tmdb_language`, e.g. "fr-FR") — one language for the whole +node, same reasoning as the token: one shared cache, not a per-viewer +request. Omitted entirely when unset, which lets TMDB fall back to its own +default (English) rather than this client guessing one. +""" + +import difflib +import logging +import os +import re + +import httpx + +from meshbay_node.roster import Roster + +log = logging.getLogger(__name__) + +_BASE_URL = "https://api.themoviedb.org/3/" +_IMAGE_BASE = "https://image.tmdb.org/t/p/w500" +_TIMEOUT = 10.0 +_DEFAULT_TOKEN_ENV = "MESHBAY_TMDB_DEFAULT_TOKEN" + + +def _normalize(s: str) -> str: + s = s.lower() + s = re.sub(r"[^a-z0-9àâäéèêëïîôöùûüçñ ]+", " ", s) + return re.sub(r"\s+", " ", s).strip() + + +def _best_match(query_title: str, results: list[dict], keys: tuple[str, ...]) -> tuple[dict | None, float]: + """ + Trusts TMDB's own ranking (§3.3's last row — a locally-recomputed + re-rank picked a coincidentally-closer-looking wrong show once): only + the top result is considered. The similarity ratio is returned purely + as a confidence signal for the caller's fallback decision, never used + to pick a different candidate. + """ + if not results: + return None, 0.0 + top = results[0] + qn = _normalize(query_title) + best_ratio = 0.0 + for k in keys: + val = top.get(k) + if val: + best_ratio = max(best_ratio, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio()) + return top, best_ratio + + +class TmdbClient: + """One instance per node, holding the resolved token and an httpx client.""" + + def __init__(self, roster: Roster | None = None, + transport: httpx.AsyncBaseTransport | None = None): + self._roster = roster + # `transport` is a test-only seam (httpx.MockTransport) — production + # callers never pass it, and httpx.AsyncClient defaults to real + # network I/O when it's None. + self._client = httpx.AsyncClient(timeout=_TIMEOUT, transport=transport) + + async def close(self) -> None: + await self._client.aclose() + + async def _resolve(self) -> tuple[bool, str | None, str | None]: + """Returns (enabled, token, language). token/language are None when unset.""" + if self._roster is not None: + enabled, custom_token, language = await self._roster.tmdb_config() + else: + enabled, custom_token, language = True, None, None + token = custom_token or os.environ.get(_DEFAULT_TOKEN_ENV) or None + return enabled and bool(token), token, language + + async def _get(self, path: str, params: dict) -> dict | None: + enabled, token, language = await self._resolve() + if not enabled: + return None + if language and "language" not in params: + params = {**params, "language": language} + try: + resp = await self._client.get( + _BASE_URL + path, params=params, + headers={"Authorization": f"Bearer {token}", "accept": "application/json"}, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as e: + log.warning("TMDB request failed (%s): %s", path, e) + return None + + @staticmethod + def poster_url(path: str | None) -> str | None: + return f"{_IMAGE_BASE}{path}" if path else None + + async def fetch_image(self, url: str) -> bytes | None: + """Fetches a poster/backdrop image. Unauthenticated — image.tmdb.org needs no token.""" + try: + resp = await self._client.get(url, timeout=_TIMEOUT) + resp.raise_for_status() + return resp.content + except httpx.HTTPError as e: + log.warning("TMDB image fetch failed (%s): %s", url, e) + return None + + async def search_movie(self, title: str, year: int | None = None) -> tuple[dict | None, float]: + params = {"query": title, "include_adult": "false"} + if year: + params["year"] = year + data = await self._get("search/movie", params) + results = (data or {}).get("results", []) + return _best_match(title, results, ("title", "original_title")) + + async def search_tv(self, title: str) -> tuple[dict | None, float]: + data = await self._get("search/tv", {"query": title}) + results = (data or {}).get("results", []) + return _best_match(title, results, ("name", "original_name")) + + async def tv_season(self, tmdb_id: str | int, season: int) -> dict | None: + return await self._get(f"tv/{tmdb_id}/season/{season}", {}) + + async def movie_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None: + """ + Full details, not the search result: search/movie doesn't return + `runtime` or genre names (only `genre_ids`) at all. + + `language`, when given, overrides the configured one — used for the + English fallback fetch (§ below): TMDB itself doesn't fall back + server-side for an untranslated field, it just returns "" for it, + the same gap the TMDB website itself papers over client-side. + """ + params = {"language": language} if language else {} + return await self._get(f"movie/{tmdb_id}", params) + + async def tv_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None: + params = {"language": language} if language else {} + return await self._get(f"tv/{tmdb_id}", params) + + async def movie_credits(self, tmdb_id: str | int) -> dict | None: + return await self._get(f"movie/{tmdb_id}/credits", {}) + + async def tv_credits(self, tmdb_id: str | int) -> dict | None: + return await self._get(f"tv/{tmdb_id}/credits", {}) 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 aaf1ecf..938ce3b 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -33,6 +33,7 @@ import time from pathlib import Path from typing import Any +import blake3 import jwt import msgpack from aiortc import RTCPeerConnection, RTCSessionDescription, RTCDataChannel @@ -64,6 +65,8 @@ from meshbay_common.adminop import ( OP_MEMBER_UPLOAD, OP_APPS_ENABLED, OP_SET_SCAN_SETTINGS, + OP_TMDB_CONFIG, + OP_VIDEO_ROOT, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -84,10 +87,15 @@ from meshbay_common.join import ( join_transcript, ) from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes -from meshbay_common.protocol import MNP +from meshbay_common.protocol import MNP, index_entry_wire from meshbay_node.indexer import GroupIndex from meshbay_node.indexer.indexer import DirectoryIndexer from meshbay_node import ops +# Re-imported under its original name: every call site and existing test in +# this module still refers to it as `_probe_video`. The implementation lives +# in media_probe.py so the indexer package (imported just above) can call it +# too, for index-time enrichment, without a circular import. +from meshbay_node.media_probe import probe_video as _probe_video from meshbay_node.roots import ( RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name, ) @@ -163,59 +171,6 @@ STREAM_CREDIT_TIMEOUT = 120 # charged for a slot within this, rather than within the timeout. STREAM_CREDIT_POLL = 3 -_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"} - - -async def _probe_video(path: str) -> tuple[str | None, float, bool]: - """ - Probe video file with ffprobe, return (MSE codec string, duration, - has_audio). - - The audio half of the codec string is always "mp4a.40.2" (AAC-LC) or - absent — never the source's real audio codec — because _stream_video_ - inner always transcodes audio to AAC and never copies it: MSE in every - mainstream browser only decodes AAC/Opus, and a source codec outside - that (AC-3, E-AC-3, DTS, ...) is at best silently unplayable and at - worst, for E-AC-3 at least, makes ffmpeg itself refuse to write the - fragmented MP4 header ("Cannot write moov atom before EAC3 packets - parsed" — reproduced against a real 5.1 E-AC-3 WEB-DL). Video stays - whatever it actually is: it is always copied, never transcoded. - """ - import json as _json - proc = await asyncio.create_subprocess_exec( - "ffprobe", "-v", "error", - "-show_entries", "stream=codec_name,profile,level,codec_type", - "-show_entries", "format=duration", - "-of", "json", path, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - ) - stdout, _ = await proc.communicate() - info = _json.loads(stdout) - duration = float(info.get("format", {}).get("duration", 0)) - - v_codec = "" - has_audio = False - for s in info.get("streams", []): - if s.get("codec_type") == "video" and not v_codec: - cn = s.get("codec_name", "") - if cn == "h264": - p = _H264_PROFILES.get(s.get("profile", "High"), "64") - lvl = int(s.get("level", 40)) - v_codec = f"avc1.{p}00{lvl:02x}" - elif cn == "hevc": - v_codec = "hev1.1.6.L93.B0" - elif cn == "vp9": - v_codec = "vp09.00.10.08" - elif cn == "av1": - v_codec = "av01.0.01M.08" - elif s.get("codec_type") == "audio": - has_audio = True - - if not v_codec: - return None, duration, has_audio - codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec - return codec, duration, has_audio - def _pack(obj: dict) -> bytes: data = msgpack.packb(obj, use_bin_type=True) @@ -417,6 +372,12 @@ class WebRTCPeerSession: self._do_apps_enabled(msg) elif mtype == MNP.SET_SCAN_SETTINGS: self._do_set_scan_settings(msg) + elif mtype == MNP.TMDB_CONFIG: + self._do_tmdb_config(msg) + elif mtype == MNP.VIDEO_ROOT: + self._do_video_root(msg) + elif mtype == MNP.MEDIA_META_REQ: + self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -667,6 +628,20 @@ class WebRTCPeerSession: # setting (or one whose context has not loaded it yet) hides # nothing. "enabled_apps": list(self._group_ctx().get("enabled_apps") or []), + # Which folder the Videos app treats as its entry point for + # this group — "" means the whole group index. + "video_root": self._group_ctx().get("video_root") or "", + # Node-wide (not per-group), same "read once, kept current by + # the signed op" shape — surfaced here rather than only via + # tmdb_config_ack so a client that connects after the operator + # already configured it does not have to wait for a live change + # to find out (docs/mediacenter.md §5.5). + "tmdb_enabled": bool( + self._ctx.get("daemon_state", {}).get("tmdb_enabled", True)), + "tmdb_token_customized": bool( + self._ctx.get("daemon_state", {}).get("tmdb_token_customized", False)), + "tmdb_language": str( + self._ctx.get("daemon_state", {}).get("tmdb_language") or ""), # So a client that connects mid-scan shows the indexing state # immediately, instead of waiting for the next periodic # INDEX_PROGRESS push. Never a path or filename — see @@ -1623,10 +1598,13 @@ class WebRTCPeerSession: except Exception: pass - # Every "application" a group can show — Chat and Files today. Videos, - # Music, Photos join this set (and apps.js's registry, client-side) when - # they land; nothing else about this handler changes. - ALLOWED_APPS = frozenset({"chat", "files"}) + # Every "application" a group can show. Music, Photos join this set (and + # apps.js's registry, client-side) when they land; nothing else about + # this handler changes. DEFAULT_APPS (roster.py) deliberately does not + # include "video" — it is the first app with outbound third-party + # network calls (once TMDB is on), so an operator opts a group in + # explicitly rather than getting it for free (docs/mediacenter.md §5.6). + ALLOWED_APPS = frozenset({"chat", "files", "video"}) def _do_apps_enabled(self, msg: dict) -> None: """ @@ -1677,6 +1655,125 @@ class WebRTCPeerSession: except Exception: pass + def _do_tmdb_config(self, msg: dict) -> None: + """ + Turn TMDB lookups on/off node-wide, optionally set (or clear) a + custom API token, and optionally set the language TMDB is queried + in (e.g. "fr-FR") — one for the whole node, same reasoning as the + token: one shared cache, not a per-viewer request. Signed like the + rest: this turns on outbound third-party network traffic the node + did not have before the Videos app (docs/mediacenter.md §5.5, §8) + — an unsigned toggle would let any member turn on egress the + operator never agreed to. + """ + enabled = msg.get("enabled") + if not isinstance(enabled, bool): + self._send({"type": "error", "detail": "Missing or invalid 'enabled'"}) + return + 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"enabled={enabled},custom_token={'yes' if token else 'no'}," + f"language={language or 'default'}") + self._issue_admin_challenge( + OP_TMDB_CONFIG, subject, + payload={"enabled": enabled, "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("enabled", True), 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/member_upload). + notice = { + "type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION, + "enabled": result["enabled"], "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_video_root(self, msg: dict) -> None: + """ + Which folder (possibly a subfolder of a shared root) the Videos app + treats as its entry point for this group. Signed like apps_enabled: + it decides what every member's Videos tab shows. + + An empty path is always accepted (it means "the whole group index", + today's behaviour). A non-empty path must resolve to a real, + currently-readable directory — validated against the group's own + roots the same way directory creation/deletion already is, so a + stale or mistyped path is refused before a signature is even asked + for. + """ + path = msg.get("path") + if not isinstance(path, str): + self._send({"type": "error", "detail": "Missing or invalid 'path'"}) + return + path = path.strip("/") + if path: + ctx = self._group_ctx() + resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None + if not resolved or not resolved.is_dir(): + self._send({"type": "error", "detail": "Not a directory in this group"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_VIDEO_ROOT, path) + + async def _admin_exec_video_root( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + path = 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"video_root:{path}") + return + try: + await self._run_op(ops.set_video_root, self._group_id or "", path) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("video_root", path) + + notice = {"type": MNP.VIDEO_ROOT_ACK, "v": MNP_VERSION, "path": path} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + # Reconcile's backstop and the watchdog debounce (indexer.py # DirectoryIndexer) — how hard the node works on the operator's own # disk, not a member-facing permission. Signed for the same reason as @@ -2138,14 +2235,7 @@ class WebRTCPeerSession: def _do_index_sync(self) -> None: ctx = self._group_ctx() idx = ctx["index"] - entries = [ - { - "id": e.id, "name": e.name, "path": e.path, - "size": e.size, "type": e.type, "added_at": e.added_at, - "uploader_id": e.uploader_id, - } - for e in idx.entries - ] + entries = [index_entry_wire(e) for e in idx.entries] self._send({ "type": MNP.INDEX_SYNC, "v": MNP_VERSION, @@ -2189,12 +2279,38 @@ class WebRTCPeerSession: continue return sorted(out)[:2000] + async def _try_serve_thumbnail( + self, thumb_hash: str, chunk_index: int, gek: bytes | None, + ) -> dict | None: + """ + docs/mediacenter.md §5.3: a thumbnail is served through the same + chunked file_req path as a real file, resolved against the media + cache instead of the index when the id doesn't match a file. Always + a single chunk in practice (a thumbnail-sized JPEG never approaches + CHUNK_SIZE) — a request for any chunk beyond 0 is just a miss. + """ + media_cache = self._ctx.get("media_cache") + if media_cache is None or chunk_index != 0: + return None + jpeg = await media_cache.get_thumb(thumb_hash) + if jpeg is None: + return None + return _encrypt_chunk_bytes( + self._ctx["sk_node"], gek, jpeg, 0, + bytes.fromhex(thumb_hash), thumb_hash, + ) + async def _do_file_request(self, msg: dict) -> None: ctx = self._group_ctx() file_id = msg["file_id"] chunk_index = msg["chunk_index"] entry = ctx["index"].get_entry(file_id) if not entry: + thumb = await self._try_serve_thumbnail(file_id, chunk_index, ctx.get("gek")) + if thumb is not None: + log.debug("file_req file_id=%s chunk=%s: served as thumbnail", file_id[:16], chunk_index) + self._send(thumb) + return log.warning("File not found: %s", file_id[:16]) self._send({"type": "error", "detail": "File not found"}) return @@ -2235,6 +2351,191 @@ class WebRTCPeerSession: if chunk_index == 0: self._audit("file_download", entry.name) + @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/mediacenter.md §5.4), 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/mediacenter.md §5.4: TMDB metadata for one path, resolved from + the group's index (root+relpath the client already knows from + index_sync/index_delta — never a raw filesystem path off the wire). + """ + path = msg.get("path") + log.debug("media_meta_req path=%r", path) + if not isinstance(path, str) or not path: + self._send({"type": "error", "detail": "Missing path"}) + return + ctx = self._group_ctx() + entry = ctx["index"].get_entry_by_path(path) + 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") + if media_cache is None or tmdb_client is None: + self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, + "path": path, "confidence": 0}) + return + + 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: + tmdb_id, media_type = cached + meta = await media_cache.get_tmdb_meta(tmdb_id, media_type) + + if meta is None: + 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, + "path": path, "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 path=%r: replying tmdb_id=%s poster=%s backdrop=%s", + path, tmdb_id, poster_thumb_hash, backdrop_thumb_hash) + + resp = { + "type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, "path": path, + "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 _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). + """ + from meshbay_node.indexer import title_parse + + if is_show: + title = entry.display_title or title_parse.naive_title(entry.name) + result, ratio = await tmdb_client.search_tv(title) + if result is None or ratio < 0.6: + naive = title_parse.naive_title(entry.name) + if naive != title: + result, ratio = await tmdb_client.search_tv(naive) + return result, ratio + + parsed = title_parse.parse_movie_filename(entry.name) + title = entry.display_title or parsed.display_title or parsed.naive_title + result, ratio = await tmdb_client.search_movie(title, parsed.year) + if result is not None and ratio >= 0.6: + return result, ratio + for candidate in filter(None, [parsed.alt_title, parsed.naive_title, + *title_parse.sequel_variants(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: + break + return result, ratio + + @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) + 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 _do_stream_segment(self, msg: dict) -> None: self._spawn(self._do_stream_segment_async(msg)) @@ -2738,6 +3039,12 @@ class WebRTCPeerSession: elif pending["op"] == OP_SET_SCAN_SETTINGS: self._spawn( self._admin_exec_set_scan_settings(pending, transcript, sig_bytes)) + elif pending["op"] == OP_TMDB_CONFIG: + self._spawn( + self._admin_exec_tmdb_config(pending, transcript, sig_bytes)) + elif pending["op"] == OP_VIDEO_ROOT: + self._spawn( + self._admin_exec_video_root(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) @@ -3009,7 +3316,7 @@ class WebRTCPeerSession: file_hash = bytes.fromhex(entry.id) try: - codec_str, duration, has_audio = await _probe_video(str(file_path)) + codec_str, duration, has_audio, _width, _height = await _probe_video(str(file_path)) except Exception as e: self._send({"type": "error", "detail": f"Probe failed: {e}"}) return @@ -3220,18 +3527,14 @@ class WebRTCPeerSession: await self._pc.close() -def _read_and_encrypt( +def _encrypt_chunk_bytes( sk_node: Ed25519PrivateKey, gek: bytes, - file_path: Path, + plaintext: bytes, chunk_index: int, file_hash: bytes, file_id: str = "", ) -> dict: - with open(file_path, "rb") as f: - f.seek(chunk_index * CHUNK_SIZE) - plaintext = f.read(CHUNK_SIZE) - ckey = chunk_key_aes(gek, file_hash, chunk_index) nonce, ct = encrypt_chunk_aes(ckey, plaintext) @@ -3249,6 +3552,20 @@ def _read_and_encrypt( } +def _read_and_encrypt( + sk_node: Ed25519PrivateKey, + gek: bytes, + file_path: Path, + chunk_index: int, + file_hash: bytes, + file_id: str = "", +) -> dict: + with open(file_path, "rb") as f: + f.seek(chunk_index * CHUNK_SIZE) + plaintext = f.read(CHUNK_SIZE) + return _encrypt_chunk_bytes(sk_node, gek, plaintext, chunk_index, file_hash, file_id) + + class WebRTCTransport: """ Manages WebRTC peer connections for browser clients. diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py new file mode 100644 index 0000000..cff4d50 --- /dev/null +++ b/packages/meshbay-node/tests/test_enrich.py @@ -0,0 +1,130 @@ +"""Tests for indexer/enrich.py — season/title corroboration and the end-to-end pool.""" + +import asyncio +import shutil +import subprocess +from pathlib import Path + +import pytest + +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer.enrich import Enricher, _season_from_ancestors, _title_from_siblings +from meshbay_node.media_cache import MediaCache + +_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") + + +# ── pure helpers, no ffmpeg needed ─────────────────────────────────────────── + +def test_season_from_ancestors_finds_season_folder(tmp_path): + folder = tmp_path / "Some Show" / "Season 2" + folder.mkdir(parents=True) + ep = folder / "01 - Episode Title.mkv" + ep.touch() + + assert _season_from_ancestors(ep) == 2 + + +def test_season_from_ancestors_none_when_no_season_folder(tmp_path): + folder = tmp_path / "Movies" + folder.mkdir() + f = folder / "Some Movie 2015.mkv" + f.touch() + + assert _season_from_ancestors(f) is None + + +def test_title_from_siblings_borrows_from_a_titled_sibling(tmp_path): + folder = tmp_path / "Acronym Show" + folder.mkdir() + titled = folder / "Some.Show.Name.S01E01.720p.mkv" + untitled = folder / "S01E02.SUBFRENCH.720p.mkv" + titled.touch() + untitled.touch() + + assert _title_from_siblings(untitled) == "Some Show Name" + + +def test_title_from_siblings_none_when_no_titled_sibling(tmp_path): + folder = tmp_path / "Acronym Show" + folder.mkdir() + (folder / "S01E02.mkv").touch() + + assert _title_from_siblings(folder / "S01E02.mkv") is None + + +# ── end-to-end against a real (tiny, synthetic) video file ────────────────── + +pytestmark_ffmpeg = pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed") + + +def _make_clip(path: Path) -> None: + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1", + "-c:v", "libx264", "-preset", "ultrafast", "-an", str(path)], + check=True, capture_output=True, + ) + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_populates_fields_and_stores_thumbnail(tmp_path, media_cache): + clip = tmp_path / "Some.Movie.2015.1080p.mkv" + _make_clip(clip) + entry = IndexEntry(id="fileid1", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done) + file_id, fields = await asyncio.wait_for(done, timeout=30) + + assert file_id == "fileid1" + assert fields["width"] == 320 + assert fields["height"] == 240 + assert fields["display_title"] == "Some Movie" + assert fields.get("thumb_hash") + stored = await media_cache.get_thumb(fields["thumb_hash"]) + assert stored is not None and len(stored) > 0 + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_handles_episode_with_season_from_folder(tmp_path, media_cache): + # Filename carries only a bare episode number, no SxxExx token — guessit + # confirmed (separately) not to find a season here at all — so the + # season must come from the ancestor folder (§3.4's non-standard case). + folder = tmp_path / "Some Show" / "Saison 3" + folder.mkdir(parents=True) + titled_sibling = folder / "Some.Show.Episode.06.mkv" + titled_sibling.touch() + clip = folder / "Episode.07.720p.mkv" + _make_clip(clip) + entry = IndexEntry(id="fileid2", name=clip.name, path=str(clip.relative_to(tmp_path)), + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done) + file_id, fields = await asyncio.wait_for(done, timeout=30) + + assert fields["display_title"] == "Some Show" + assert fields["season"] == 3 + assert fields["episode"] == 7 diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py new file mode 100644 index 0000000..b66c448 --- /dev/null +++ b/packages/meshbay-node/tests/test_media_cache.py @@ -0,0 +1,71 @@ +"""Tests for media_cache.py — TMDB/thumbnail cache and its pruning obligation.""" + +import time + +import pytest + +from meshbay_node.media_cache import MediaCache, TMDB_META_TTL_SECS + + +@pytest.fixture +async def cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +@pytest.mark.asyncio +async def test_file_tmdb_round_trip(cache): + assert await cache.get_file_tmdb("file1") is None + + await cache.set_file_tmdb("file1", "12345", "movie") + + assert await cache.get_file_tmdb("file1") == ("12345", "movie") + + +@pytest.mark.asyncio +async def test_tmdb_meta_round_trip(cache): + assert await cache.get_tmdb_meta("12345", "movie") is None + + await cache.set_tmdb_meta("12345", "movie", {"title": "A Movie", "vote_average": 7.5}) + + meta = await cache.get_tmdb_meta("12345", "movie") + assert meta == {"title": "A Movie", "vote_average": 7.5} + + +@pytest.mark.asyncio +async def test_tmdb_meta_expires_after_ttl(cache): + await cache._db.execute( + "INSERT INTO tmdb_meta (tmdb_id, media_type, json, fetched_at) VALUES (?, ?, ?, ?)", + ("999", "movie", '{"title": "Old"}', time.time() - TMDB_META_TTL_SECS - 1), + ) + await cache._db.commit() + + assert await cache.get_tmdb_meta("999", "movie") is None + + +@pytest.mark.asyncio +async def test_thumb_round_trip(cache): + assert await cache.get_thumb("thumbhash1") is None + + await cache.put_thumb("thumbhash1", "file1", b"\xff\xd8fakejpeg") + + assert await cache.get_thumb("thumbhash1") == b"\xff\xd8fakejpeg" + + +@pytest.mark.asyncio +async def test_prune_file_removes_thumb_and_mapping_but_not_shared_meta(cache): + # Two episodes of the same show share one tmdb_meta row (§2's stated case). + await cache.set_file_tmdb("ep1", "555", "tv") + await cache.set_file_tmdb("ep2", "555", "tv") + await cache.set_tmdb_meta("555", "tv", {"name": "A Show"}) + await cache.put_thumb("thumb-ep1", "ep1", b"jpeg-bytes-1") + + await cache.prune_file("ep1") + + assert await cache.get_file_tmdb("ep1") is None + assert await cache.get_thumb("thumb-ep1") is None + # ep2's own mapping and the shared show metadata both survive + assert await cache.get_file_tmdb("ep2") == ("555", "tv") + assert await cache.get_tmdb_meta("555", "tv") == {"name": "A Show"} diff --git a/packages/meshbay-node/tests/test_poster_cache.py b/packages/meshbay-node/tests/test_poster_cache.py new file mode 100644 index 0000000..bbd824d --- /dev/null +++ b/packages/meshbay-node/tests/test_poster_cache.py @@ -0,0 +1,88 @@ +""" +Bug found live, 2026-08-24: `_fetch_and_cache_poster` downloaded a TMDB +poster/backdrop from `image.tmdb.org` on *every* `media_meta_req`, even for +a file whose TMDB match was already cached — the content-addressed +`thumb_hash` isn't known until the bytes are downloaded, so nothing had +ever checked "have I already fetched this poster_path" first. On a group +with a show split across release folders (§V6), one Videos-tab visit +triggered four to six redundant image downloads; compounded with TMDB +latency (or a stall), this is what an operator saw as posters that "never +finish loading" on a second visit. + +Fixed by keying the `thumbs` cache by a synthetic `tmdb:{poster_path}` id +*before* the network call, mirroring the `file_id` convention `_do_file_request` +already uses to resolve a thumbnail by id. +""" + +import pytest + +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +class FakeTmdbClient: + def __init__(self): + self.fetch_calls = 0 + + @staticmethod + def poster_url(path): + return f"https://image.tmdb.org/t/p/w500{path}" + + async def fetch_image(self, url): + self.fetch_calls += 1 + return b"jpeg-bytes-for-" + url.encode() + + +async def test_second_fetch_for_the_same_poster_path_skips_the_network(media_cache): + client = FakeTmdbClient() + + first = await WebRTCPeerSession._fetch_and_cache_poster( + media_cache, client, "/poster.jpg") + second = await WebRTCPeerSession._fetch_and_cache_poster( + media_cache, client, "/poster.jpg") + + assert first == second, "the same poster_path must yield the same thumb_hash" + assert client.fetch_calls == 1, ( + "a poster already cached must never be re-downloaded from TMDB") + + +async def test_different_poster_paths_are_each_fetched_once(media_cache): + client = FakeTmdbClient() + + poster_hash = await WebRTCPeerSession._fetch_and_cache_poster( + media_cache, client, "/poster.jpg") + backdrop_hash = await WebRTCPeerSession._fetch_and_cache_poster( + media_cache, client, "/backdrop.jpg") + poster_hash_again = await WebRTCPeerSession._fetch_and_cache_poster( + media_cache, client, "/poster.jpg") + + assert poster_hash != backdrop_hash + assert poster_hash == poster_hash_again + assert client.fetch_calls == 2, "one network fetch per distinct poster_path" + + +async def test_none_path_is_a_no_op(media_cache): + client = FakeTmdbClient() + result = await WebRTCPeerSession._fetch_and_cache_poster(media_cache, client, None) + assert result is None + assert client.fetch_calls == 0 + + +async def test_cached_hash_actually_serves_the_downloaded_bytes(media_cache): + client = FakeTmdbClient() + thumb_hash = await WebRTCPeerSession._fetch_and_cache_poster( + media_cache, client, "/poster.jpg") + await WebRTCPeerSession._fetch_and_cache_poster(media_cache, client, "/poster.jpg") + + stored = await media_cache.get_thumb(thumb_hash) + assert stored == b"jpeg-bytes-for-https://image.tmdb.org/t/p/w500/poster.jpg" diff --git a/packages/meshbay-node/tests/test_startup_scan_enrichment.py b/packages/meshbay-node/tests/test_startup_scan_enrichment.py new file mode 100644 index 0000000..cdadad9 --- /dev/null +++ b/packages/meshbay-node/tests/test_startup_scan_enrichment.py @@ -0,0 +1,92 @@ +""" +Regression: indexer.initial_scan() (run once at startup, daemon.py's +_bg_scan) never itself calls on_change — that predates the Videos app, and +every existing caller only cared about the scan finishing, not about +notifying anyone. Enrichment (duration/thumb_hash/display_title/...) hangs +entirely off on_change (daemon._broadcast_index_change). + +Without an explicit call to _on_index_change right after the startup scan, +a file already on disk at boot — the common case, an existing library — +would never get enriched at all: only a file added later, while the node +is already running (seen by the watchdog), would trigger it. Found live +against a real library after the first restart with this feature enabled. + +Enrichment only runs once a group has a video_root configured (a group +with none set gets no TMDB/thumbnail work at all, docs/mediacenter.md +§5.2/§10) — this test's fake roster reports the shared root itself as the +configured video_root, so the enrichment-scheduling behaviour under test +is exercised the same way a real operator's group would be. +""" + +import asyncio + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from unittest.mock import MagicMock + +from meshbay_common.crypto import generate_gek +from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig +from meshbay_node.daemon import NodeDaemon +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.indexer.enrich import Enricher +from meshbay_node.media_cache import MediaCache + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _free_port() -> int: + import socket + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def test_a_file_already_on_disk_at_startup_gets_enrichment_scheduled(tmp_path): + shared = tmp_path / "shared" + shared.mkdir() + (shared / "movie.mkv").write_bytes(b"not a real video, just needs to be indexed as one") + + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()), + groups=[GroupConfig( + id="a" * 32, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29012, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + class _StubRoster: + async def video_root(self, group_id): + return "shared" # the root itself, i.e. "enrich the whole thing" + + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 # real value would make this test wait 0.5s + daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await daemon._media_cache.open() + daemon._enricher = Enricher(daemon._media_cache) + daemon._roster = _StubRoster() + + sk_node = Ed25519PrivateKey.generate() + indexer = DirectoryIndexer( + roots=one_root(shared), group_id="a" * 32, + sk_node=sk_node, gek=generate_gek()) + + # Mirrors _bg_scan's actual sequence in daemon.py. + await indexer.initial_scan() + assert not daemon._enriched_attempted, ( + "nothing should be scheduled before _on_index_change is ever called") + + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) # let the coalescing timer fire _broadcast_index_change + + entry = next(iter(indexer.index.entries)) + assert entry.id in daemon._enriched_attempted, ( + "a file already on disk at startup must get enrichment scheduled the " + "first time its group's index is broadcast, not only on a later " + "watchdog-detected change to it") + + await daemon._media_cache.close() diff --git a/packages/meshbay-node/tests/test_stream_audio_transcode.py b/packages/meshbay-node/tests/test_stream_audio_transcode.py index 5b1fc46..dde0df4 100644 --- a/packages/meshbay-node/tests/test_stream_audio_transcode.py +++ b/packages/meshbay-node/tests/test_stream_audio_transcode.py @@ -149,13 +149,14 @@ async def test_probe_video_reports_aac_regardless_of_source_audio_codec(tmp_path clip = tmp_path / "clip.mkv" _make_clip(clip, acodec="eac3", channels=6) - codec, duration, has_audio = await _probe_video(str(clip)) + codec, duration, has_audio, width, height = await _probe_video(str(clip)) assert has_audio is True assert duration > 0 assert codec is not None assert "eac3" not in codec and "ec-3" not in codec assert "mp4a.40.2" in codec + assert (width, height) == (320, 240) async def test_probe_video_handles_no_audio_track(tmp_path): @@ -167,8 +168,9 @@ async def test_probe_video_handles_no_audio_track(tmp_path): check=True, capture_output=True, ) - codec, duration, has_audio = await _probe_video(str(clip)) + codec, duration, has_audio, width, height = await _probe_video(str(clip)) assert has_audio is False assert codec is not None and "," not in codec, \ "no audio track must not produce a dangling ',' or a fake audio codec" + assert (width, height) == (320, 240) diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py new file mode 100644 index 0000000..1a277f2 --- /dev/null +++ b/packages/meshbay-node/tests/test_title_parse.py @@ -0,0 +1,129 @@ +""" +Tests for indexer/title_parse.py — synthetic filenames only, one per +docs/mediacenter.md §3.3/§3.4 rule. The real ~1950-file library validation +is a manual acceptance step (§11), not something this repo's corpus holds. +""" + +from meshbay_node.indexer.title_parse import ( + ParsedName, + naive_title, + parse_episode_filename, + parse_movie_filename, + season_from_folder_name, + sequel_variants, +) + + +# ── §3.3 row: plain, well-formed movie filename ────────────────────────────── + +def test_plain_movie_filename_parses_confidently(): + r = parse_movie_filename("The.Great.Adventure.2015.1080p.BluRay.x264.mkv") + assert r.display_title == "The Great Adventure" + assert r.year == 2015 + assert r.confidence is True + + +# ── §3.3 row 1: real title in alternative_title ────────────────────────────── + +def test_franchise_numeric_code_exposes_alternative_title(): + r = parse_movie_filename("Franchise.007.-.The.Real.Subtitle.1999.720p.mkv") + assert r.year == 1999 + # guessit lands the franchise fragment in title and the real subtitle in + # alternative_title — both must be surfaced so a caller (tmdb.py) can + # try either, per the design doc's "search both fields" fix. + assert r.alt_title is not None + + +# ── §3.3 row 2: hyphenated title split before a parenthesized year ─────────── + +def test_naive_title_normalizes_hyphens_dots_and_underscores(): + nt = naive_title("Hero-Name.(2002).DVDRip.XviD-GROUP.avi") + assert "-" not in nt + assert "." not in nt + assert "_" not in nt + assert "2002" not in nt # the year itself is stripped, not just its parens + assert "Hero" in nt and "Name" in nt + + +# ── §3.3 row 3: French edition vocabulary stuck to the title ───────────────── + +def test_french_edition_phrases_are_stripped(): + r = parse_movie_filename("Some.Movie.Version.Longue.2010.mkv") + assert "version" not in (r.display_title or "").lower() + assert "longue" not in (r.display_title or "").lower() + + r2 = parse_movie_filename("Autre.Film.Remasterise.1998.mkv") + assert "remasteris" not in (r2.display_title or "").lower() + + +# ── §3.3 row 4: trailing sequel digit / Roman numeral ──────────────────────── + +def test_sequel_variants_strips_digit_and_offers_roman_numeral(): + variants = sequel_variants("Some Sequel 2") + assert "Some Sequel" in variants + assert "Some Sequel II" in variants + + +def test_sequel_variants_empty_when_no_trailing_digit(): + assert sequel_variants("Some Movie") == [] + + +# ── §3.3 row 6: no usable title at all ─────────────────────────────────────── + +def test_low_confidence_when_no_title_or_year(): + r = parse_movie_filename("abc123.mkv") + assert r.confidence is False + # the mandated fallback is always available regardless + assert r.naive_title == "abc123" + + +# ── §3.2/§3.4: episode filename with no show name at all ───────────────────── + +def test_episode_only_filename_has_no_title_but_has_season_episode(): + r = parse_episode_filename("S08E02.SUBFRENCH.720p.mkv") + assert r.display_title is None + assert r.season == 8 + assert r.episode == 2 + # confidence is False (no title yet) — the indexer must supply one from + # a representative sibling filename in the same folder, per §3.4. + assert r.confidence is False + + +def test_episode_filename_with_show_name_parses_confidently(): + r = parse_episode_filename("Some.Show.Name.S02E05.720p.WEB.mkv") + assert r.display_title == "Some Show Name" + assert r.season == 2 + assert r.episode == 5 + assert r.confidence is True + + +# ── §3.4: season-like ancestor folders, including non-English vocabulary ──── + +def test_season_folder_english(): + assert season_from_folder_name("Season 2") == 2 + + +def test_season_folder_french_word(): + assert season_from_folder_name("Saison 3") == 3 + + +def test_season_folder_roman_numeral(): + assert season_from_folder_name("Saison IV") == 4 + + +def test_specials_folder_maps_to_season_zero(): + assert season_from_folder_name("Specials") == 0 + assert season_from_folder_name("Bonus") == 0 + assert season_from_folder_name("Extras") == 0 + + +def test_non_season_folder_name_returns_none(): + assert season_from_folder_name("Some Show Name") is None + + +def test_parsed_name_is_a_plain_dataclass(): + # sanity: constructible with just the one required field, per the + # "None => caller must supply from elsewhere" contract. + p = ParsedName(display_title=None) + assert p.confidence is False + assert p.naive_title == "" diff --git a/packages/meshbay-node/tests/test_tmdb.py b/packages/meshbay-node/tests/test_tmdb.py new file mode 100644 index 0000000..4500feb --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb.py @@ -0,0 +1,166 @@ +"""Tests for tmdb.py against a mocked httpx transport — no live network in CI.""" + +import httpx +import pytest + +from meshbay_node.tmdb import TmdbClient + + +class FakeRoster: + def __init__(self, enabled: bool = True, token: str | None = "fake-token", + language: str | None = None): + self._enabled = enabled + self._token = token + self._language = language + + async def tmdb_config(self): + return self._enabled, self._token, self._language + + +def _handler(response_map): + def handle(request: httpx.Request) -> httpx.Response: + path = request.url.path + for prefix, body in response_map.items(): + if path.endswith(prefix): + return httpx.Response(200, json=body) + return httpx.Response(404, json={"results": []}) + return handle + + +@pytest.mark.asyncio +async def test_search_movie_returns_top_result_and_confidence(): + body = {"results": [{"id": 42, "title": "The Great Adventure", "release_date": "2015-01-01"}]} + client = TmdbClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"search/movie": body})), + ) + result, ratio = await client.search_movie("The Great Adventure", 2015) + + assert result is not None + assert result["id"] == 42 + assert ratio > 0.9 + await client.close() + + +@pytest.mark.asyncio +async def test_search_tv_returns_top_result(): + body = {"results": [{"id": 7, "name": "Some Show"}]} + client = TmdbClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"search/tv": body})), + ) + result, ratio = await client.search_tv("Some Show") + + assert result is not None and result["id"] == 7 + assert ratio > 0.9 + await client.close() + + +@pytest.mark.asyncio +async def test_configured_language_is_sent_to_tmdb(): + captured = {} + + def handle(request: httpx.Request) -> httpx.Response: + captured["language"] = request.url.params.get("language") + return httpx.Response(200, json={"results": []}) + + client = TmdbClient( + roster=FakeRoster(language="fr-FR"), + transport=httpx.MockTransport(handle), + ) + await client.search_movie("Anything") + + assert captured["language"] == "fr-FR" + await client.close() + + +@pytest.mark.asyncio +async def test_no_language_configured_omits_the_param(): + captured = {} + + def handle(request: httpx.Request) -> httpx.Response: + captured["has_language"] = "language" in request.url.params + return httpx.Response(200, json={"results": []}) + + client = TmdbClient( + roster=FakeRoster(language=None), + transport=httpx.MockTransport(handle), + ) + await client.search_movie("Anything") + + assert captured["has_language"] is False + await client.close() + + +@pytest.mark.asyncio +async def test_no_results_returns_none_and_zero_confidence(): + client = TmdbClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"search/movie": {"results": []}})), + ) + result, ratio = await client.search_movie("Nonexistent Obscure Title") + + assert result is None + assert ratio == 0.0 + await client.close() + + +@pytest.mark.asyncio +async def test_disabled_via_roster_setting_makes_no_request(): + calls = [] + + def handle(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response(200, json={"results": []}) + + client = TmdbClient( + roster=FakeRoster(enabled=False), + transport=httpx.MockTransport(handle), + ) + result, ratio = await client.search_movie("Anything") + + assert result is None + assert calls == [] # confirms the disabled check short-circuits before any request + await client.close() + + +@pytest.mark.asyncio +async def test_no_token_resolvable_makes_no_request(monkeypatch): + monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) + calls = [] + + def handle(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response(200, json={"results": []}) + + client = TmdbClient( + roster=FakeRoster(enabled=True, token=None), + transport=httpx.MockTransport(handle), + ) + result, ratio = await client.search_movie("Anything") + + assert result is None + assert calls == [] + await client.close() + + +@pytest.mark.asyncio +async def test_http_error_returns_none_gracefully(): + def handle(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"status_message": "server error"}) + + client = TmdbClient( + roster=FakeRoster(), + transport=httpx.MockTransport(handle), + ) + result, ratio = await client.search_movie("Anything") + + assert result is None + assert ratio == 0.0 + await client.close() + + +@pytest.mark.asyncio +async def test_poster_url_builds_full_url(): + assert TmdbClient.poster_url("/abc123.jpg") == "https://image.tmdb.org/t/p/w500/abc123.jpg" + assert TmdbClient.poster_url(None) is None diff --git a/packages/meshbay-node/tests/test_tmdb_config_policy.py b/packages/meshbay-node/tests/test_tmdb_config_policy.py new file mode 100644 index 0000000..29ef54c --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_config_policy.py @@ -0,0 +1,230 @@ +""" +The operator decides whether the node calls TMDB at all, and whether it uses +a custom API token — docs/mediacenter.md §5.5. Same shape as +test_apps_enabled_policy.py/test_scan_settings_policy.py: a signed operator +instruction, node-wide (group_id="") rather than per-group, stored via +roster.py's group_settings table. + +Specific to this one: the subject signed/audited must never contain the +token itself (it would end up in the audit log in plaintext) — only whether +one was supplied travels there. The token itself only ever travels in +`payload`, which is node-side context never re-sent or re-verified from the +wire (see _issue_admin_challenge's docstring). +""" + +from pathlib import Path + +import pytest + +from meshbay_common.adminop import OP_TMDB_CONFIG +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = None + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _fake_challenge(issued: list): + return lambda op, subject, payload=None, group_id=None: issued.append( + (op, subject, payload, group_id)) + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_missing_enabled_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_non_bool_enabled_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({"enabled": "yes"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_non_string_token_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({"enabled": True, "token": 12345}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", operator="the-operator") + session._has_admin_authority = lambda: False + + session._do_tmdb_config({"enabled": False}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Who may change it, and what gets signed ───────────────────────────────── + +async def test_changing_it_needs_a_signature(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({"enabled": True}) + + assert len(issued) == 1 + op, subject, payload, group_id = issued[0] + assert op == OP_TMDB_CONFIG + assert group_id == "", "node-wide, like group_attach/group_detach — not tied to self._group_id" + + +async def test_the_token_itself_never_appears_in_the_signed_subject(tmp_path): + """The subject is what gets audited (self._audit(pending['subject'])) — + a secret must never end up there in plaintext.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + secret = "sk-super-secret-tmdb-token" + session._do_tmdb_config({"enabled": True, "token": secret}) + + _, subject, payload, _ = issued[0] + assert secret not in subject + assert payload["token"] == secret, "the real value still has to reach the exec step somehow" + + +async def test_subject_reflects_enabled_and_whether_a_token_was_supplied(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({"enabled": False, "token": "x"}) + + _, subject, _, _ = issued[0] + assert subject == "enabled=False,custom_token=yes,language=default" + + +async def test_subject_says_no_custom_token_when_none_given(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({"enabled": True}) + + _, subject, _, _ = issued[0] + assert subject == "enabled=True,custom_token=no,language=default" + + +async def test_subject_reflects_a_configured_language(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({"enabled": True, "language": "fr-FR"}) + + _, subject, payload, _ = issued[0] + assert subject == "enabled=True,custom_token=no,language=fr-FR" + assert payload["language"] == "fr-FR" + + +async def test_non_string_language_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = _fake_challenge(issued) + + session._do_tmdb_config({"enabled": True, "language": 42}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + enabled, token, language = await roster.tmdb_config() + assert (enabled, token, language) == (True, None, None), ( + "absent must mean on, with the shipped default token, TMDB's own default language") + await roster.set_tmdb_config(True, "my-custom-token", "fr-FR", set_by="op") + enabled, token, language = await roster.tmdb_config() + assert (enabled, token, language) == (True, "my-custom-token", "fr-FR") + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.tmdb_config() == (True, "my-custom-token", "fr-FR") + finally: + await reopened.close() + + +async def test_clearing_the_token_reverts_to_the_default(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_tmdb_config(True, "a-token", set_by="op") + assert (await roster.tmdb_config())[1] == "a-token" + + await roster.set_tmdb_config(True, "", set_by="op") + enabled, token, language = await roster.tmdb_config() + assert token is None, "an explicit empty string clears the custom token" + finally: + await roster.close() + + +async def test_omitting_the_token_leaves_it_unchanged(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + await roster.set_tmdb_config(True, "a-token", set_by="op") + await roster.set_tmdb_config(False, None, set_by="op") + enabled, token, language = await roster.tmdb_config() + assert (enabled, token) == (False, "a-token") + finally: + await roster.close() diff --git a/packages/meshbay-node/tests/test_tmdb_language_fallback.py b/packages/meshbay-node/tests/test_tmdb_language_fallback.py new file mode 100644 index 0000000..1a85531 --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_language_fallback.py @@ -0,0 +1,98 @@ +""" +TMDB doesn't fall back server-side for a field with no translation in the +requested language — it returns "" (or an empty list) for that one field, +not the English text, confirmed live against a real French query. The TMDB +website itself covers exactly this gap client-side by falling back to +English per field; `_tmdb_build_meta` (webrtc_server.py) mirrors that, +rather than discarding an otherwise-good localized response over one empty +field, or silently showing a blank overview/poster. +""" + +import pytest + +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +class FakeTmdbClient: + def __init__(self, localized: dict, english: dict, credits: dict | None = None): + self._localized = localized + self._english = english + self._credits = credits or {"cast": [], "crew": []} + self.calls: list[tuple[str, str | None]] = [] + + async def movie_details(self, tmdb_id, language=None): + self.calls.append(("movie_details", language)) + return self._english if language == "en-US" else self._localized + + async def tv_details(self, tmdb_id, language=None): + self.calls.append(("tv_details", language)) + return self._english if language == "en-US" else self._localized + + async def movie_credits(self, tmdb_id): + return self._credits + + async def tv_credits(self, tmdb_id): + return self._credits + + +async def test_empty_overview_falls_back_to_english_but_keeps_localized_poster(): + localized = { + "title": "OVNI(s)", "original_title": "OVNI(s)", + "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)", + "overview": "A real English overview.", + "poster_path": "/en-poster.jpg", "backdrop_path": "/en-backdrop.jpg", + "genres": [{"name": "Comedy"}], "vote_average": 7.2, + "first_air_date": "2016-01-01", + } + client = FakeTmdbClient(localized, english) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "108353", "tv", {"id": 108353}) + + assert meta["overview"] == "A real English overview.", ( + "the empty localized field must fall back to the English value") + assert meta["poster_path"] == "/fr-poster.jpg", ( + "a non-empty localized field must NOT be overwritten by the English fallback" + ) + assert meta["genres"] == ["Comédie"], "localized genres were present — kept as-is" + assert ("tv_details", "en-US") in client.calls, "the fallback fetch must actually happen" + + +async def test_fully_populated_localized_response_never_triggers_a_fallback_call(): + localized = { + "title": "OVNI(s)", "overview": "Un résumé complet en français.", + "poster_path": "/fr-poster.jpg", "genres": [{"name": "Comédie"}], + "vote_average": 7.2, + } + client = FakeTmdbClient(localized, english={"overview": "should never be used"}) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "108353", "tv", {"id": 108353}) + + assert meta["overview"] == "Un résumé complet en français." + assert client.calls == [("tv_details", None)], ( + "a fully translated response must not cost a second TMDB request") + + +async def test_completely_untranslated_response_falls_back_entirely(): + localized = {"overview": "", "poster_path": None, "genres": []} + english = { + "title": "UFOs", "original_title": "OVNI(s)", + "overview": "A real English overview.", "poster_path": "/en-poster.jpg", + "genres": [{"name": "Comedy"}], "vote_average": 7.2, + "release_date": "2016-01-01", + } + client = FakeTmdbClient(localized, english) + + meta = await WebRTCPeerSession._tmdb_build_meta(client, "418517", "movie", {"id": 418517}) + + assert meta["overview"] == "A real English overview." + assert meta["poster_path"] == "/en-poster.jpg" + assert meta["genres"] == ["Comedy"] + assert meta["title"] == "UFOs" diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py new file mode 100644 index 0000000..df86a79 --- /dev/null +++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py @@ -0,0 +1,153 @@ +""" +Videos-app enrichment (ffprobe/thumbnailing/TMDB, mediacenter.md §5.2/§10) +only ever runs for a group that has a video_root configured, and only for +files under it — see daemon.py's _enrich_new_video_entries. Burning TMDB's +rate limit and the node's CPU on an operator's whole shared index before +they have chosen which folder is actually their media library would be +real, ongoing cost for files never meant to be in the Videos app at all. + +Setting or changing the root (ops.set_video_root) fires a one-off sweep +(_enrich_video_root_now) of whatever it already contains: the ordinary +per-broadcast path only ever looks at files new since the last broadcast, +so anything already sitting in a folder before it became the video_root +would otherwise never be picked up. +""" + +import asyncio + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.crypto import generate_gek +from meshbay_node import ops +from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig +from meshbay_node.daemon import NodeDaemon +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.indexer.enrich import Enricher +from meshbay_node.media_cache import MediaCache +from meshbay_node.roster import Roster + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _free_port() -> int: + import socket + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def _make_daemon(tmp_path, shared, group_id): + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()), + groups=[GroupConfig( + id=group_id, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29014, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 # real value would make these tests wait 0.5s + daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await daemon._media_cache.open() + daemon._enricher = Enricher(daemon._media_cache) + daemon._roster = Roster(db_path=tmp_path / "roster.db") + await daemon._roster.open() + return daemon + + +async def _teardown(daemon): + await daemon._media_cache.close() + await daemon._roster.close() + + +async def test_no_video_root_means_no_enrichment_at_all(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + (shared / "movie.mkv").write_bytes(b"not a real video, just needs to be indexed as one") + + daemon = await _make_daemon(tmp_path, shared, group_id) + try: + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + assert not daemon._enriched_attempted, ( + "a group with no video_root configured must not enrich anything, " + "not even fall back to the whole index") + finally: + await _teardown(daemon) + + +async def test_only_entries_under_the_configured_root_are_enriched(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + (shared / "Movies").mkdir() + # Distinct content: the index dedupes by content hash, and two files with + # the same bytes would otherwise collapse into a single entry. + (shared / "Movies" / "in-root.mkv").write_bytes(b"in-root content") + (shared / "outside.mkv").write_bytes(b"outside content") + + daemon = await _make_daemon(tmp_path, shared, group_id) + try: + await daemon._roster.set_video_root(group_id, "shared/Movies", set_by="op") + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + by_name = {e.name: e for e in indexer.index.entries} + assert by_name["in-root.mkv"].id in daemon._enriched_attempted + assert by_name["outside.mkv"].id not in daemon._enriched_attempted, ( + "a file outside the configured video_root must never be enriched") + finally: + await _teardown(daemon) + + +async def test_setting_the_video_root_sweeps_what_it_already_contains(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + (shared / "Movies").mkdir() + (shared / "Movies" / "already-there.mkv").write_bytes(b"x") + + daemon = await _make_daemon(tmp_path, shared, group_id) + try: + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek()) + await indexer.initial_scan() + daemon._state["indexers"][group_id] = indexer + + # Broadcast once with nothing configured — nothing should be scheduled. + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + assert not daemon._enriched_attempted + + # Now the operator points video_root at the folder that already held + # this file all along. + state = { + "roster": daemon._roster, + "groups_ctx": {group_id: {}}, + "enrich_video_root_fn": daemon._enrich_video_root_now, + } + await ops.set_video_root(state, group_id, "shared/Movies") + await asyncio.sleep(0.05) # let the fire-and-forget sweep actually run + + entry = next(iter(indexer.index.entries)) + assert entry.id in daemon._enriched_attempted, ( + "a file already sitting in the newly-chosen root must be picked " + "up by the sweep, not wait for some unrelated future change") + finally: + await _teardown(daemon) diff --git a/packages/meshbay-node/tests/test_video_root_policy.py b/packages/meshbay-node/tests/test_video_root_policy.py new file mode 100644 index 0000000..8cc1540 --- /dev/null +++ b/packages/meshbay-node/tests/test_video_root_policy.py @@ -0,0 +1,141 @@ +""" +Which folder (possibly a subfolder of a shared root) is the Videos app's +entry point for a group. Same shape as test_apps_enabled_policy.py: a +signed operator instruction, per-group (unlike tmdb_config, which is +node-wide), stored via roster.py's group_settings table. + +Specific to this one: a non-empty path must resolve to a real, readable +directory inside one of the group's own roots before a challenge is ever +issued — refusing a typo up front, the same way an empty apps set is +refused up front rather than round-tripped to the operator's browser. +""" + +from pathlib import Path + +import pytest + +from meshbay_common.adminop import OP_VIDEO_ROOT +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + (shared_root / "Movies").mkdir() + (shared_root / "Shows").mkdir() + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = None + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_missing_path_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_video_root({}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_nonexistent_folder_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_video_root({"path": "shared/Nonexistent"}) + + assert not issued, "a mistyped path must be refused before a signature round trip" + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_path_traversal_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_video_root({"path": "../../etc"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", operator="the-operator") + session._has_admin_authority = lambda: False + + session._do_video_root({"path": "shared/Movies"}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Accepted cases ─────────────────────────────────────────────────────────── + +async def test_an_empty_path_is_always_accepted(tmp_path): + """Empty means 'the whole group index' — always valid, nothing to resolve.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_video_root({"path": ""}) + + assert issued == [(OP_VIDEO_ROOT, "")] + + +async def test_a_real_subfolder_is_accepted_and_signed(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_video_root({"path": "shared/Movies"}) + + assert issued == [(OP_VIDEO_ROOT, "shared/Movies")] + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert await roster.video_root("g1") == "", "absent must mean the whole group index" + await roster.set_video_root("g1", "shared/Movies", set_by="op") + assert await roster.video_root("g1") == "shared/Movies" + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.video_root("g1") == "shared/Movies" + assert await reopened.video_root("g2") == "", "one group's setting must not answer for another" + finally: + await reopened.close() diff --git a/packaging/systemd/meshbay-node-user.service b/packaging/systemd/meshbay-node-user.service index 833f31a..f476841 100644 --- a/packaging/systemd/meshbay-node-user.service +++ b/packaging/systemd/meshbay-node-user.service @@ -25,6 +25,9 @@ Type=simple # Secrets: MESHBAY_UNLOCK_KEY (keystore). MESHBAY_PASSWORD is no longer read — # the node authenticates to the hub with an Ed25519 signature (NS7). +# MESHBAY_TMDB_DEFAULT_TOKEN (Videos app, optional) also lives here — never +# in source. Absent means TMDB lookups are inert until the operator sets one +# here or configures a custom token in Settings (docs/mediacenter.md §5.5). EnvironmentFile=-%h/.config/meshbay/node.env ExecStart=/usr/bin/meshbay-node --config %h/.config/meshbay/node.toml diff --git a/packaging/systemd/meshbay-node.service b/packaging/systemd/meshbay-node.service index edff7f1..a469484 100644 --- a/packaging/systemd/meshbay-node.service +++ b/packaging/systemd/meshbay-node.service @@ -12,7 +12,10 @@ Group=%i # Per-user service: systemctl enable --now meshbay-node@$USER WorkingDirectory=%h -# Secrets: MESHBAY_PASSWORD (hub login), MESHBAY_UNLOCK_KEY (keystore) +# Secrets: MESHBAY_PASSWORD (hub login), MESHBAY_UNLOCK_KEY (keystore), +# MESHBAY_TMDB_DEFAULT_TOKEN (Videos app — optional, no literal default +# ships in source; absent means TMDB lookups are inert until the operator +# sets one here or configures a custom token in Settings, docs/mediacenter.md §5.5) EnvironmentFile=-%h/.config/meshbay/node.env ExecStart=/usr/bin/meshbay-node --config %h/.config/meshbay/node.toml |