From 6af05abf410bbd038ce7fa6915a659defc509071 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 10:04:46 +0200 Subject: feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one. --- .../meshbay-common/src/meshbay_common/protocol.py | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) (limited to 'packages/meshbay-common/src/meshbay_common/protocol.py') 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 ──────────────────────────────────────────────────── -- cgit v1.2.3 From 0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 14:33:20 +0200 Subject: feat(node,hub): season-specific overviews, manual TMDB match correction, and wizard polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY --- CLAUDE.md | 2 +- docs/apps.md | 19 +- docs/mediacenter.md | 80 +++++++ .../meshbay-common/src/meshbay_common/__init__.py | 7 +- .../meshbay-common/src/meshbay_common/adminop.py | 5 + .../meshbay-common/src/meshbay_common/protocol.py | 6 + packages/meshbay-hub/src/meshbay_hub/static/app.js | 66 +++++- .../src/meshbay_hub/static/group-settings.js | 20 +- .../src/meshbay_hub/static/locales/de.js | 7 + .../src/meshbay_hub/static/locales/en.js | 7 + .../src/meshbay_hub/static/locales/es.js | 7 + .../src/meshbay_hub/static/locales/fr.js | 7 + .../src/meshbay_hub/static/locales/it.js | 7 + .../src/meshbay_hub/static/locales/ja.js | 7 + .../src/meshbay_hub/static/locales/nl.js | 7 + .../src/meshbay_hub/static/locales/pl.js | 7 + .../src/meshbay_hub/static/locales/pt-BR.js | 7 + .../src/meshbay_hub/static/locales/zh-CN.js | 7 + .../meshbay-hub/src/meshbay_hub/static/style.css | 61 ++++++ .../src/meshbay_hub/static/transport.js | 91 +++++++- .../src/meshbay_hub/static/video-app.js | 239 ++++++++++++++++++++- packages/meshbay-node/src/meshbay_node/daemon.py | 39 ++++ .../meshbay-node/src/meshbay_node/media_cache.py | 35 +++ packages/meshbay-node/src/meshbay_node/tmdb.py | 22 +- .../src/meshbay_node/transport/webrtc_server.py | 163 ++++++++++++++ packages/meshbay-node/src/meshbay_node/ui/app.py | 16 +- .../meshbay-node/tests/test_rename_reenrichment.py | 163 ++++++++++++++ .../tests/test_season_and_search_requests.py | 187 ++++++++++++++++ .../tests/test_tmdb_override_policy.py | 172 +++++++++++++++ .../tests/test_wizard_apps_endpoint.py | 76 +++++++ 30 files changed, 1504 insertions(+), 35 deletions(-) create mode 100644 packages/meshbay-node/tests/test_rename_reenrichment.py create mode 100644 packages/meshbay-node/tests/test_season_and_search_requests.py create mode 100644 packages/meshbay-node/tests/test_tmdb_override_policy.py create mode 100644 packages/meshbay-node/tests/test_wizard_apps_endpoint.py (limited to 'packages/meshbay-common/src/meshbay_common/protocol.py') diff --git a/CLAUDE.md b/CLAUDE.md index ece3828..b4b5242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -703,7 +703,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay | | Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel | | Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router. Routing and every page except a group's — see `docs/apps.md` for the group UI's file layout (2026-08-23 split) | -| Group applications (adding one) | `docs/apps.md` | Chat/Files today, Videos/Music/Photos planned. Props contract, enablement mechanism, checklist | +| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction), Music/Photos planned. Props contract, enablement mechanism, checklist | | File download (large) | `static/file-utils.js` | `downloadEntry`/`_openDownloadTarget`, File System Access API (`showSaveFilePicker`) — stream to disk | | Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected | | Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave | diff --git a/docs/apps.md b/docs/apps.md index 931114d..a032f56 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -5,13 +5,18 @@ > `meshbay-draft-v6.md` §2.7 for why this exists and what it changes; this > document is the how-to. -A group has "applications" — Chat and Files today, Videos/Music/Photos planned -(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`/ -`stream_req` messages Files and `VideoPlayer` already use. Adding one is a new -file plus one registry entry — nothing about the group shell changes. +A group has "applications" — Chat, Files, and Videos today (a poster-grid +browser; see `docs/mediacenter.md`), Music/Photos planned (a music player, an +album viewer). 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`/`stream_req` messages Files and +`VideoPlayer` already use — Music/Photos need no MNP change beyond that. +Videos itself did need one: TMDB metadata (`media_meta_req`/`resp`), per-season +overview (`season_meta_req`/`resp`), and operator match correction +(`tmdb_search_req`/`resp`, `tmdb_override`/`_ack`) are all additive message +pairs on top of the same index/chunk plumbing, not a replacement for it. +Adding a new app is still a new file plus one registry entry — nothing about +the group shell changes. --- diff --git a/docs/mediacenter.md b/docs/mediacenter.md index a165d64..66232f8 100644 --- a/docs/mediacenter.md +++ b/docs/mediacenter.md @@ -530,6 +530,85 @@ makes outbound third-party network calls (once TMDB is on) — the operator opts a group into it explicitly via the existing Settings checklist, same gesture as any other app. +### 5.7 Season-specific overview, and correcting a wrong automatic match + +Found live, 2026-08-24, on a real 3-season show: TMDB's own search +consistently resolved every season's folder to the same *season-3-specific* +promotional entry — a wrong `tmdb_id`, not a MeshBay grouping bug (§3.3's +`_best_match` deliberately trusts TMDB's own top result, per that section's +own postmortem). Two independent problems, two independent fixes: + +**A show's own `overview` (§5.4) is one static field that does not +necessarily describe every season alike.** New request/response pair, read +lazily per selected tab (same virtualization discipline as `media_meta_req`, +never fetched for a season the operator hasn't clicked): + +``` +season_meta_req { tmdb_id, season } # tmdb_id is whatever media_meta_resp + # already resolved — never re-searched here +season_meta_resp { tmdb_id, season, confidence, name, overview, air_date, + poster_thumb_hash } +``` + +`video-app.js`'s `VideoDetailModal` shows a season tab bar +(`Season 1` / `Season 2` / … / `Specials`) whenever a show has more than one +season, defaulting to whichever season the representative episode belongs +to. Selecting a tab both filters the episode list to that season and swaps +in that season's own `overview`/`air_date` — falling back to the show-level +`overview` when a season's own comes back empty (TMDB has no season-level +text for every show), the same per-field fallback shape §5.4's English +fallback already established, just one level further down when there is +nothing at all to show otherwise. + +**An operator needs a way to correct a wrong match** when TMDB's own +top-ranked result is simply wrong — no amount of local re-ranking fixes +this (§3.3's last row is exactly the mistake that would repeat). Two more +message pairs, the second an admin op: + +``` +tmdb_search_req { query, media_type } # media_type: "movie" | "tv" +tmdb_search_resp { query, media_type, results: [{ tmdb_id, title, year, + poster_thumb_hash }] } + +tmdb_override { path, tmdb_id, media_type } # admin op, subject = + # "path={path},tmdb_id={tmdb_id},media_type={media_type}" +tmdb_override_ack { path, tmdb_id, media_type } # broadcast to every connected peer +``` + +`tmdb_search_req` is deliberately **not** admin-gated — it is read-only (the +same TMDB lookup the automatic matcher already performs on everyone's +behalf) and returns nothing that isn't already visible in the search +results a browser could get by hand. `media_type` is echoed back in the +response, not only the query: a client that fires a movie search and a tv +search for the same title in close succession needs it to tell the two +responses apart for keyed matching (`transport.js`), the same reordering +hazard §5.4's first postmortem already covers for `media_meta_req`. + +`tmdb_override`, once signed, is applied to **every index entry sharing the +resolved file's `display_title`** (`webrtc_server.py`'s +`_admin_exec_tmdb_override`) — the same grouping the poster grid itself uses +(§3.4/V6) — not just the one file the operator happened to right-click, +so the correction actually sticks for every episode of the show, and +broadcasts a `tmdb_override_ack` to every connected peer so an already-open +grid/modal picks up the change without a reconnect (`video-app.js`'s +`useMediaMeta` subscribes to a module-level generation counter, bumped on a +successful override, that forces every mounted tile/modal to refetch). + +New adminop: `OP_TMDB_OVERRIDE = "tmdb_override"` (`adminop.py`), following +`OP_VIDEO_ROOT`/`OP_TMDB_CONFIG`'s exact shape — signed for the same reason: +`media_cache` is shared node-wide, not per-viewer, so an unsigned override +would let any member vandalize another show's metadata for everyone. + +**Bug found live, 2026-08-24**: the search overlay (a second, later +`.video-overlay` sibling, opened on top of the detail modal) rendered +completely invisibly the first time it shipped — present in the DOM +(confirmed via the accessibility tree), inert on screen. Cause: its own +`z-index: 1` lost to the base `.video-overlay` class's `z-index: 200` that +the detail modal underneath it already used; both are `position: fixed`, +so an explicit z-index always wins over DOM order regardless of which +element mounted later. Fixed by giving `.video-search-overlay` an explicit +`z-index: 210`. + --- ## 6. Node-side implementation, concretely @@ -620,6 +699,7 @@ centralization was for (one computation, reused by every member). | 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 | +| ~~V7~~ | ~~A show's automatic TMDB match can be wrong at the show level (not just mis-ranked locally), and a wrong match's `overview` can read as scoped to one season~~ | **Closed, 2026-08-24 — §5.7.** Per-season tabs (own `overview`/`air_date`, falling back to the show-level text when empty) plus an operator-only "Fix match…" search-and-correct affordance, applied to every file sharing the resolved `display_title`. Confirmed live on the operator's real "War of the Worlds" 3-season show, itself matched to a wrong season-3-specific 1988 promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction | ## 11. Acceptance before shipping diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index 4c3bea0..938f5d4 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -13,5 +13,10 @@ __version__ = "0.6.0" # `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" +# 0.6: added `season_meta_req`/`season_meta_resp` (per-season TMDB overview, +# rather than one static show-level summary applied to every season alike) +# and `tmdb_search_req`/`tmdb_search_resp` + `tmdb_override`/`tmdb_override_ack` +# (an operator correcting a wrong automatic TMDB match, found live against a +# real show fragmented across TMDB entries per season). All additive. +MNP_VERSION = "0.6" 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 e86f322..51396c7 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -71,6 +71,11 @@ OP_TMDB_CONFIG = "tmdb_config" # 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" +# Correcting a wrong automatic TMDB match — signed for the same reason as +# tmdb_config: it changes what every member sees for a show/movie, node-wide +# (media_cache is shared, not per-viewer), so an unsigned override would let +# any member vandalize another show's metadata. +OP_TMDB_OVERRIDE = "tmdb_override" 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 246bd00..900acc2 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -96,6 +96,12 @@ class MNP: 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) + SEASON_META_REQ = "season_meta_req" # client → node: TMDB overview/poster for one season + SEASON_META_RESP = "season_meta_resp" # node → client: season-level TMDB fields (or none) + TMDB_SEARCH_REQ = "tmdb_search_req" # client → node: candidate TMDB matches for a query + TMDB_SEARCH_RESP = "tmdb_search_resp" # node → client: candidate list (id, title, year, poster) + TMDB_OVERRIDE = "tmdb_override" # operator → node: replace a show/movie's TMDB match + TMDB_OVERRIDE_ACK = "tmdb_override_ack" # 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. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 58ffe8e..f9bcd43 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -839,6 +839,15 @@ function CreateGroupWizard({ token, username, onCreated }) { const [joinPolicy, setJoinPolicy] = useState('invite'); const [roots, setRoots] = useState([]); const [uploadIdx, setUploadIdx] = useState(0); + // Every registered app, on by default — narrowing this down here means + // members never briefly see one the operator meant to leave off, the way + // toggling it afterward from Settings would. + const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key)); + const toggleWizardApp = useCallback((key) => { + setEnabledApps(prev => prev.includes(key) + ? prev.filter(k => k !== key) + : [...prev, key]); + }, []); // Step 2 progress const [setupSteps, setSetupSteps] = useState([]); @@ -915,8 +924,13 @@ function CreateGroupWizard({ token, username, onCreated }) { const steps = [ { label: t('wizard.step_create_hub'), status: 'pending' }, { label: t('wizard.step_attach'), status: 'pending' }, - { label: t('wizard.step_index'), status: 'pending' }, ]; + // Only a step at all when it does something — the common case (every + // app left on, the default) has nothing to set and no reason to show a + // step for it. + if (enabledApps.length < APPS.length) + steps.push({ label: t('wizard.step_apps'), status: 'pending' }); + steps.push({ label: t('wizard.step_index'), status: 'pending' }); if (roots.length > 1) steps.push({ label: t('wizard.step_add_roots'), status: 'pending' }); steps.push({ label: t('wizard.step_gek'), status: 'pending' }); @@ -980,11 +994,24 @@ function CreateGroupWizard({ token, username, onCreated }) { update('done'); advance(); - // 3. Wait for the node's own initial scan of this group to finish — + // 3. Narrow the enabled apps down, if the operator unchecked any — + // before the scan below, so a member who joins while it is still + // running never briefly sees an app meant to be off. Same + // not-hosted-yet race as steps 4+ below: the group is attached, but + // may not have reached groups_ctx yet. + if (enabledApps.length < APPS.length) { + update('running'); + await withRetry(() => platform.node.call( + 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps })); + update('done'); + advance(); + } + + // 4. Wait for the node's own initial scan of this group to finish — // the group is not usable for anything below (extra roots, GEK) until // this finishes, so nobody lands on a page that looks broken, or hits // a "not configured" error from racing ahead of it. Can take tens of - // minutes on a slow disk (see the StarWars benchmark) — the node + // minutes on a slow disk with a large library — the node // keeps scanning on its own either way (test_hot_reload_survives_ // client_close.py); this step is only about not lying about it. update('running'); @@ -992,7 +1019,7 @@ function CreateGroupWizard({ token, username, onCreated }) { update('done'); advance(); - // 4. Add extra roots (if >1) + // 5. Add extra roots (if >1) if (roots.length > 1) { update('running'); for (let i = 0; i < roots.length; i++) { @@ -1007,13 +1034,13 @@ function CreateGroupWizard({ token, username, onCreated }) { advance(); } - // 5. GEK init + // 6. GEK init update('running'); await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`)); update('done'); advance(); - // 6. Generate pairing code + // 7. Generate pairing code update('running'); const pairResult = await platform.node.call('POST', '/api/operator/pair'); if (pairResult && pairResult.code) { @@ -1031,7 +1058,7 @@ function CreateGroupWizard({ token, username, onCreated }) { update('error'); setSetupError(platform.bridgeMessage(err)); } - }, [name, description, joinPolicy, roots, uploadIdx, token, onCreated]); + }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]); // Step 0: Node detection if (step === 0) { @@ -1065,7 +1092,7 @@ function CreateGroupWizard({ token, username, onCreated }) { // Step 1: Group details + directories if (step === 1) { - const canProceed = name.trim() && roots.length > 0; + const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0; return html`

${t('wizard.title')}

${error && html`
${error}
`} @@ -1111,6 +1138,25 @@ function CreateGroupWizard({ token, username, onCreated }) {
+
+

${t('members.apps_title')}

+

+ ${t('members.apps_hint')}

+
    + ${APPS.map(a => html` +
  • + +
  • + `)} +
+ ${enabledApps.length === 0 && html` +

${t('members.apps_need_one')}

`} +
+

${t('wizard.directories')}

@@ -1161,8 +1207,8 @@ function CreateGroupWizard({ token, username, onCreated }) { ${setupSteps.map((s, i) => html`

- ${s.status === 'done' ? '✓' : - s.status === 'running' ? '●' : + ${s.status === 'running' ? html`` : + s.status === 'done' ? '✓' : s.status === 'error' ? '✗' : '○'} ${s.label} 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 4ae832f..22532fa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -1,5 +1,5 @@ import { - html, useState, useEffect, useCallback, useMemo, + html, useState, useEffect, useCallback, useMemo, useRef, } from './vendor/htm-preact.js'; import { t, getLocale, LOCALES } from './i18n.js'; import { Icon } from './icon.js'; @@ -311,6 +311,24 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]); + // A node that has never had a language explicitly set would otherwise + // query TMDB with none at all — which TMDB itself resolves to English, + // regardless of who the operator is — even though this form already + // *suggests* their own UI language as the value. Applied once, + // automatically, the first time the operator (the only one who can sign + // this) is actually connected to see it: a real default tied to whoever + // runs this particular node, never a single hardcoded language for every + // node. `tmdbConfig.language` being set at all — from this or from an + // explicit save — is what stops it from ever firing again, so "unless + // manually changed" holds regardless of which of the two set it first. + const autoLanguageSetRef = useRef(false); + useEffect(() => { + if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return; + if (autoLanguageSetRef.current) return; + autoLanguageSetRef.current = true; + saveTmdbConfig(tmdbEnabled); + }, [isNodeAdmin, connected, tmdbConfig, tmdbEnabled, saveTmdbConfig]); + // 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 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 6634cfb..7f05441 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -169,6 +169,12 @@ export default { one: '{n} Episode', other: '{n} Episoden', }, + 'video.fix_match': 'Übereinstimmung korrigieren…', + 'video.search_title': 'TMDB-Übereinstimmung korrigieren', + 'video.search_placeholder': 'TMDB durchsuchen…', + 'video.search_button': 'Suchen', + 'video.search_no_results': 'Keine Treffer gefunden.', + 'video.search_apply_hint': 'Gilt für alle Dateien, die derzeit unter diesem Titel gruppiert sind.', // LAN-Cast 'cast.start': 'Auf Gerät übertragen', @@ -620,6 +626,7 @@ export default { 'wizard.setting_up': 'Ihre Gruppe wird eingerichtet…', 'wizard.step_create_hub': 'Gruppe auf dem Hub erstellen', 'wizard.step_attach': 'An Node anbinden', + 'wizard.step_apps': 'Anwendungen auswählen', 'wizard.step_add_roots': 'Verzeichnisse hinzufügen', 'wizard.step_gek': 'Verschlüsselungsschlüssel initialisieren', 'wizard.step_pair': 'Kopplung einrichten', 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 ebdb3e2..7f31bb5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -167,6 +167,12 @@ export default { one: '{n} episode', other: '{n} episodes', }, + 'video.fix_match': 'Fix match…', + 'video.search_title': 'Correct the TMDB match', + 'video.search_placeholder': 'Search TMDB…', + 'video.search_button': 'Search', + 'video.search_no_results': 'No matches found.', + 'video.search_apply_hint': 'Applies to every file currently grouped under this title.', // LAN cast 'cast.start': 'Cast to device', @@ -374,6 +380,7 @@ export default { 'wizard.setting_up': 'Setting up your group...', 'wizard.step_create_hub': 'Creating group on hub', 'wizard.step_attach': 'Attaching to node', + 'wizard.step_apps': 'Choosing applications', 'wizard.step_add_roots': 'Adding directories', 'wizard.step_gek': 'Initializing encryption key', 'wizard.step_pair': 'Setting up pairing', 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 d028279..d7f0f1e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -167,6 +167,12 @@ export default { one: '{n} episodio', other: '{n} episodios', }, + 'video.fix_match': 'Corregir coincidencia…', + 'video.search_title': 'Corregir la coincidencia de TMDB', + 'video.search_placeholder': 'Buscar en TMDB…', + 'video.search_button': 'Buscar', + 'video.search_no_results': 'No se encontraron coincidencias.', + 'video.search_apply_hint': 'Se aplica a todos los archivos agrupados actualmente bajo este título.', // LAN cast 'cast.start': 'Enviar a dispositivo', @@ -615,6 +621,7 @@ export default { 'wizard.setting_up': 'Configurando su grupo…', 'wizard.step_create_hub': 'Creando grupo en el hub', 'wizard.step_attach': 'Conectando al node', + 'wizard.step_apps': 'Eligiendo aplicaciones', 'wizard.step_add_roots': 'Añadiendo directorios', 'wizard.step_gek': 'Inicializando clave de cifrado', 'wizard.step_pair': 'Configurando emparejamiento', 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 621650d..2befdb9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -168,6 +168,12 @@ export default { one: '{n} épisode', other: '{n} épisodes', }, + 'video.fix_match': 'Corriger la correspondance…', + 'video.search_title': 'Corriger la correspondance TMDB', + 'video.search_placeholder': 'Rechercher sur TMDB…', + 'video.search_button': 'Rechercher', + 'video.search_no_results': 'Aucune correspondance trouvée.', + 'video.search_apply_hint': 'S’applique à tous les fichiers actuellement regroupés sous ce titre.', // LAN cast 'cast.start': 'Diffuser sur un appareil', @@ -631,6 +637,7 @@ export default { 'wizard.setting_up': 'Configuration de votre groupe…', 'wizard.step_create_hub': 'Création du groupe sur le hub', 'wizard.step_attach': 'Rattachement au node', + 'wizard.step_apps': 'Choix des applications', 'wizard.step_add_roots': 'Ajout des répertoires', 'wizard.step_gek': 'Initialisation de la clé de chiffrement', 'wizard.step_pair': 'Mise en place de l\'appariement', 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 71fb9ba..bdfda75 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -168,6 +168,12 @@ export default { one: '{n} episodio', other: '{n} episodi', }, + 'video.fix_match': 'Correggi corrispondenza…', + 'video.search_title': 'Correggi la corrispondenza TMDB', + 'video.search_placeholder': 'Cerca su TMDB…', + 'video.search_button': 'Cerca', + 'video.search_no_results': 'Nessuna corrispondenza trovata.', + 'video.search_apply_hint': 'Si applica a tutti i file attualmente raggruppati sotto questo titolo.', // LAN cast 'cast.start': 'Trasmetti al dispositivo', @@ -629,6 +635,7 @@ export default { 'wizard.setting_up': 'Configurazione del gruppo…', 'wizard.step_create_hub': 'Creazione del gruppo sul hub', 'wizard.step_attach': 'Collegamento al node', + 'wizard.step_apps': 'Scelta delle applicazioni', 'wizard.step_add_roots': 'Aggiunta delle directory', 'wizard.step_gek': 'Inizializzazione della chiave di cifratura', 'wizard.step_pair': 'Configurazione del pairing', 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 3336705..f00a0ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -165,6 +165,12 @@ export default { one: '{n}話', other: '{n}話', }, + 'video.fix_match': '一致を修正…', + 'video.search_title': 'TMDBの一致を修正', + 'video.search_placeholder': 'TMDBを検索…', + 'video.search_button': '検索', + 'video.search_no_results': '一致する結果が見つかりません。', + 'video.search_apply_hint': '現在このタイトルでグループ化されているすべてのファイルに適用されます。', // LAN cast 'cast.start': 'デバイスにキャスト', @@ -613,6 +619,7 @@ export default { 'wizard.setting_up': 'グループをセットアップ中…', 'wizard.step_create_hub': 'hub 上にグループを作成中', 'wizard.step_attach': 'node に接続中', + 'wizard.step_apps': 'アプリを選択中', 'wizard.step_add_roots': 'ディレクトリを追加中', 'wizard.step_gek': '暗号化鍵を初期化中', 'wizard.step_pair': 'ペアリングをセットアップ中', 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 28be024..d9ed12e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -169,6 +169,12 @@ export default { one: '{n} aflevering', other: '{n} afleveringen', }, + 'video.fix_match': 'Overeenkomst corrigeren…', + 'video.search_title': 'TMDB-overeenkomst corrigeren', + 'video.search_placeholder': 'Zoeken in TMDB…', + 'video.search_button': 'Zoeken', + 'video.search_no_results': 'Geen overeenkomsten gevonden.', + 'video.search_apply_hint': 'Geldt voor alle bestanden die momenteel onder deze titel zijn gegroepeerd.', // LAN cast 'cast.start': 'Naar apparaat casten', @@ -631,6 +637,7 @@ export default { 'wizard.setting_up': 'Uw groep wordt ingesteld…', 'wizard.step_create_hub': 'Groep aanmaken op hub', 'wizard.step_attach': 'Koppelen aan node', + 'wizard.step_apps': 'Apps kiezen', 'wizard.step_add_roots': 'Mappen toevoegen', 'wizard.step_gek': 'Versleutelingssleutel initialiseren', 'wizard.step_pair': 'Koppeling instellen', 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 d4eb325..cbd27b1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -176,6 +176,12 @@ export default { many: '{n} odcinków', other: '{n} odcinka', }, + 'video.fix_match': 'Popraw dopasowanie…', + 'video.search_title': 'Popraw dopasowanie TMDB', + 'video.search_placeholder': 'Szukaj w TMDB…', + 'video.search_button': 'Szukaj', + 'video.search_no_results': 'Nie znaleziono dopasowań.', + 'video.search_apply_hint': 'Dotyczy wszystkich plików obecnie zgrupowanych pod tym tytułem.', // LAN cast 'cast.start': 'Przesyłaj na urządzenie', @@ -654,6 +660,7 @@ export default { 'wizard.setting_up': 'Konfigurowanie grupy…', 'wizard.step_create_hub': 'Tworzenie grupy na hub', 'wizard.step_attach': 'Podłączanie do node', + 'wizard.step_apps': 'Wybieranie aplikacji', 'wizard.step_add_roots': 'Dodawanie katalogów', 'wizard.step_gek': 'Inicjalizacja klucza szyfrowania', 'wizard.step_pair': 'Konfigurowanie parowania', 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 bba07a2..e7bf45c 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 @@ -169,6 +169,12 @@ export default { one: '{n} episódio', other: '{n} episódios', }, + 'video.fix_match': 'Corrigir correspondência…', + 'video.search_title': 'Corrigir a correspondência do TMDB', + 'video.search_placeholder': 'Pesquisar no TMDB…', + 'video.search_button': 'Pesquisar', + 'video.search_no_results': 'Nenhuma correspondência encontrada.', + 'video.search_apply_hint': 'Aplica-se a todos os arquivos atualmente agrupados sob este título.', // LAN cast 'cast.start': 'Transmitir para dispositivo', @@ -616,6 +622,7 @@ export default { 'wizard.setting_up': 'Configurando seu grupo…', 'wizard.step_create_hub': 'Criando grupo no hub', 'wizard.step_attach': 'Anexando ao node', + 'wizard.step_apps': 'Escolhendo aplicativos', 'wizard.step_add_roots': 'Adicionando diretórios', 'wizard.step_gek': 'Inicializando chave de criptografia', 'wizard.step_pair': 'Configurando pareamento', 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 c9199c4..33a0e25 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 @@ -162,6 +162,12 @@ export default { one: '{n} 集', other: '{n} 集', }, + 'video.fix_match': '修正匹配…', + 'video.search_title': '更正 TMDB 匹配', + 'video.search_placeholder': '搜索 TMDB…', + 'video.search_button': '搜索', + 'video.search_no_results': '未找到匹配项。', + 'video.search_apply_hint': '将应用于当前归类在此标题下的所有文件。', // LAN cast 'cast.start': '投射到设备', @@ -600,6 +606,7 @@ export default { 'wizard.setting_up': '正在设置您的群组…', 'wizard.step_create_hub': '在 hub 上创建群组', 'wizard.step_attach': '关联到 node', + 'wizard.step_apps': '选择应用', 'wizard.step_add_roots': '添加目录', 'wizard.step_gek': '初始化加密密钥', 'wizard.step_pair': '设置配对', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index e1900d9..446a751 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2624,3 +2624,64 @@ a.transfer-name { .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; } + +/* Season tab bar — docs/mediacenter.md §5.4's per-season overview view */ +.video-season-tabs { + display: flex; + gap: 4px; + margin: 10px 0; + overflow-x: auto; +} +.video-season-tab { + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-surface); + color: var(--text-dim); + font-size: 0.85em; + white-space: nowrap; + cursor: pointer; +} +.video-season-tab:hover { border-color: var(--accent); color: var(--text); } +.video-season-tab.active { border-color: var(--accent); color: var(--accent); background: var(--bg-raised); } + +.video-fix-match { margin: 8px 0; font-size: 0.85em; } + +/* Operator search-and-correct overlay, layered on top of the detail modal. + Must beat .video-overlay's own z-index: 200 — both are position: fixed, + so without an explicit higher value here this one loses the stacking + order despite being the later sibling in the DOM. */ +.video-search-overlay { z-index: 210; } +.video-search-panel { max-width: 480px; } +.video-search-form { display: flex; gap: 8px; } +.video-search-form input { + flex: 1; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-surface); + color: var(--text); +} +.video-search-hint { font-size: 0.78em; color: var(--text-dim); margin: 6px 0 2px; } +.video-search-error { font-size: 0.85em; color: var(--error); } +.video-search-results { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; } +.video-search-result { + display: flex; + align-items: center; + gap: 10px; + padding: 6px; + border: 1px solid transparent; + border-radius: 6px; + background: none; + text-align: left; + cursor: pointer; +} +.video-search-result:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-raised); } +.video-search-result:disabled { opacity: 0.5; cursor: not-allowed; } +.video-search-result-thumb { width: 46px; height: 68px; flex-shrink: 0; } +.video-search-result-poster { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index dd39df8..b4bfe87 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -480,6 +480,61 @@ class MeshBayTransport { return msg; } + /** + * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's + * per-season view) — a show's own tmdb_meta is one static field that does + * not necessarily describe every season alike, found live: a 3-season + * show whose overview read as season-3-specific for every season. + * Keyed like media_meta_req: a season-tab bar can fire a request per tab + * before the previous one lands, and matching by arrival order would hand + * one season's data to a different season's tab whenever two responses + * reordered. + */ + async fetchSeasonMeta(tmdbId, season) { + const msg = await this._sendAndWait({ + type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Raw TMDB search candidates for an operator correcting a wrong automatic + * match — unlike fetchMediaMeta, this never collapses to one best guess: + * a human picks from several, so several is the point. Read-only, not an + * admin op: it looks nothing up in this node's own state and changes + * nothing, so it needs no signature (mirrors why media_meta_req isn't + * signed either). + */ + async searchTmdb(mediaType, query) { + const msg = await this._sendAndWait({ + type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Correct a wrong automatic TMDB match. Signed like setVideoRoot/ + * setTmdbConfig: it replaces what every member sees for a show/movie, + * node-wide (media_cache is shared, not per-viewer) — an unsigned + * override would let any member vandalize another show's metadata. + * Applies to every file sharing the representative one's display_title, + * not just the file the operator happened to be looking at (webrtc_ + * server.py's _admin_exec_tmdb_override). + */ + async overrideTmdbMatch(path, tmdbId, mediaType, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_override', v: '0.6', path, tmdb_id: tmdbId, media_type: mediaType, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + const subject = `path=${path},tmdb_id=${tmdbId},media_type=${mediaType}`; + return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn); + } + 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. @@ -1243,7 +1298,12 @@ class MeshBayTransport { _key: obj.type === 'file_req' ? `chunk:${obj.file_id}:${obj.chunk_index}` : obj.type === 'ping' ? `ping:${obj.token}` - : obj.type === 'media_meta_req' ? `media_meta:${obj.path}` : null, + : obj.type === 'media_meta_req' ? `media_meta:${obj.path}` + // Same reordering hazard as media_meta_req: a season-tab bar or a + // search box can have more than one of these in flight at once. + : obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}` + : obj.type === 'tmdb_search_req' ? `tmdb_search:${obj.media_type}:${obj.query}` + : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); @@ -1355,6 +1415,17 @@ class MeshBayTransport { }); } + // Same shape: an operator corrected a wrong automatic TMDB match, and + // everyone connected needs to know their poster grid/detail modal for + // this show is now stale — falls through so the operator's own + // admin_response promise resolves on this same message, exactly like + // member_upload_ack/apps_enabled_ack above. + if (msg.type === 'tmdb_override_ack' && this._onTmdbOverride) { + this._onTmdbOverride({ + path: msg.path || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '', + }); + } + // 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) { @@ -1461,6 +1532,24 @@ class MeshBayTransport { return; } + // Same reasoning as media_meta_resp: keyed, not arrival-order, and + // "nobody's waiting any more" must not fall through either. + if (msg.type === 'season_meta_resp') { + const key = `season_meta:${msg.tmdb_id}:${msg.season}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + return; + } + + if (msg.type === 'tmdb_search_resp') { + const key = `tmdb_search:${msg.media_type}:${msg.query}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + 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 index 250bf8b..00643f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -189,8 +189,31 @@ function MediaThumb({ // ── TMDB metadata, fetched once per visible tile ──────────────────────────── +// An operator correcting a wrong match (TmdbSearchOverlay below) changes +// what `media_meta_req` returns for a path every already-mounted tile/modal +// already has cached in its own useMediaMeta state — nothing would ever +// refetch otherwise, since path/active don't change. Bumping this and +// telling every subscribed hook to redo its fetch is simpler than trying to +// know which paths a given override actually affects (that's server-side +// knowledge — display_title grouping — this module doesn't have). +const _mediaMetaListeners = new Set(); +function bumpMediaMetaGeneration() { + for (const fn of _mediaMetaListeners) fn(); +} + function useMediaMeta(transportRef, path, active) { const [meta, setMeta] = useState(null); + const [refetchToken, setRefetchToken] = useState(0); + + useEffect(() => { + // Clears immediately (so the spinner shows right away, not only once + // the new fetch resolves) and bumps the token, which re-runs the fetch + // effect below regardless of whether path/active changed at all. + const listener = () => { setMeta(null); setRefetchToken((n) => n + 1); }; + _mediaMetaListeners.add(listener); + return () => _mediaMetaListeners.delete(listener); + }, []); + useEffect(() => { if (!active || !path) return; let cancelled = false; @@ -203,7 +226,40 @@ function useMediaMeta(transportRef, path, active) { } catch { if (!cancelled) setMeta({ confidence: 0 }); } })(); return () => { cancelled = true; }; - }, [path, active]); + }, [path, active, refetchToken]); + return meta; +} + +// ── per-season TMDB metadata (overview/air_date/poster), season-tab view ─── +// +// Found live: a show's own tmdb_meta.overview is one static field that does +// not necessarily describe every season alike (a season-3-specific +// promotional summary read as the synopsis for all three seasons). Cached +// for the session by tmdb_id+season, mirroring _thumbBlobCache — the same +// season is revisited every time its tab is reselected. +const _seasonMetaCache = new Map(); + +function useSeasonMeta(transportRef, tmdbId, season, active) { + const cacheKey = active && tmdbId != null && season != null ? `${tmdbId}:${season}` : null; + const [meta, setMeta] = useState(() => (cacheKey ? _seasonMetaCache.get(cacheKey) || null : null)); + useEffect(() => { + if (!cacheKey) return; + const cached = _seasonMetaCache.get(cacheKey); + if (cached) { setMeta(cached); return; } + setMeta(null); + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const resp = await transport.fetchSeasonMeta(tmdbId, season); + if (cancelled) return; + _seasonMetaCache.set(cacheKey, resp); + setMeta(resp); + } catch { if (!cancelled) setMeta({ confidence: 0 }); } + })(); + return () => { cancelled = true; }; + }, [cacheKey]); return meta; } @@ -280,8 +336,150 @@ function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, g `; } -function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay }) { +// ── season tab bar (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─ + +function SeasonTabs({ seasons, selected, onSelect }) { + return html` +
+ ${seasons.map((s) => html` + + `)} +
+ `; +} + +// ── operator: correct a wrong automatic TMDB match ────────────────────────── + +// Same shape as group-settings.js's own signFn construction (setVideoRoot, +// setTmdbConfig, ...) — there is no group-wide "sign this" helper to share, +// each caller builds one from the connection it already has. +function buildSignFn(transportRef) { + const transport = transportRef.current; + const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64; + return (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; +} + +function TmdbSearchOverlay({ + initialQuery, mediaType, path, transportRef, gekRef, onClose, onApplied, +}) { + const [query, setQuery] = useState(initialQuery || ''); + const [results, setResults] = useState(null); // null = not searched yet + const [searching, setSearching] = useState(false); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(''); + + const runSearch = useCallback(async (e) => { + if (e) e.preventDefault(); + const q = query.trim(); + if (!q || searching) return; + setSearching(true); + setError(''); + try { + const transport = transportRef.current; + const resp = await transport.searchTmdb(mediaType, q); + setResults(resp.results || []); + } catch (err) { + setError(err.message); + setResults([]); + } finally { + setSearching(false); + } + }, [query, mediaType, transportRef, searching]); + + const apply = useCallback(async (tmdbId) => { + if (applying) return; + setApplying(true); + setError(''); + try { + const signFn = buildSignFn(transportRef); + await transportRef.current.overrideTmdbMatch(path, tmdbId, mediaType, signFn); + bumpMediaMetaGeneration(); + onApplied(); + } catch (err) { + setError(err.message); + setApplying(false); + } + }, [applying, path, mediaType, transportRef, onApplied]); + + return html` +
{ + if (e.target.classList.contains('video-search-overlay')) onClose(); + }}> +
+
+ ${t('video.search_title')} + +
+
+
+ setQuery(e.target.value)} /> + +
+

${t('video.search_apply_hint')}

+ ${error && html`

${error}

`} + ${results && results.length === 0 && !searching && html` +

${t('video.search_no_results')}

+ `} + ${results && results.length > 0 && html` +
+ ${results.map((r) => html` + + `)} +
+ `} +
+
+
+ `; +} + +function VideoDetailModal({ + title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay, isNodeAdmin, +}) { const confident = meta && meta.confidence && meta.tmdb_id; + const [searching, setSearching] = useState(false); + const mediaType = show ? 'tv' : 'movie'; + + // Reset whenever a different file/show is opened in this same modal + // instance — repEntry/show change identity, selectedSeason must not + // silently keep pointing at whatever the previous show's season 4 was. + const [selectedSeason, setSelectedSeason] = useState(null); + useEffect(() => { + if (!show) { setSelectedSeason(null); return; } + const preferred = repEntry.season != null && show.seasons.some((s) => s.season === repEntry.season) + ? repEntry.season + : (show.seasons.find((s) => s.season !== 0) || show.seasons[0]).season; + setSelectedSeason(preferred); + }, [show, repEntry]); + + const showMultiSeason = Boolean(show && show.seasons.length > 1); + const seasonMeta = useSeasonMeta( + transportRef, confident ? meta.tmdb_id : null, selectedSeason, + showMultiSeason && Boolean(confident) && selectedSeason != null); + const seasonConfident = showMultiSeason && seasonMeta && seasonMeta.confidence; + return html`
{ if (e.target.classList.contains('video-overlay')) onClose(); @@ -294,11 +492,12 @@ function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, o
${confident && html` -

${meta.overview}

+

${(seasonConfident && seasonMeta.overview) || meta.overview}

${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''} ${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''} ${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''} + ${seasonConfident && seasonMeta.air_date ? ` · ${yearOf(seasonMeta.air_date)}` : ''}

${meta.cast && meta.cast.length > 0 && html`

@@ -306,6 +505,15 @@ function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, o

`} `} + ${isNodeAdmin && html` + + `} + ${showMultiSeason && html` + <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason} + onSelect=${setSelectedSeason} /> + `} ${!show && html`
+ ${searching && html` + <${TmdbSearchOverlay} initialQuery=${(confident && meta.title) || title} mediaType=${mediaType} + path=${repEntry.path} transportRef=${transportRef} gekRef=${gekRef} + onClose=${() => setSearching(false)} + onApplied=${() => setSearching(false)} /> + `} `; } -function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled }) { +function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin }) { 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({}); @@ -449,7 +666,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable ${detail && html` <${VideoDetailModal} title=${detail.title} meta=${detailMeta} repEntry=${detail.repEntry} show=${detail.show} - transportRef=${transportRef} gekRef=${gekRef} + transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} onClose=${() => setDetail(null)} onPlay=${(entry) => { setDetail(null); onPreview(entry); }} /> `} @@ -537,7 +754,7 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview }) { // ── shell ──────────────────────────────────────────────────────────────────── function VideoApp({ - groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, + groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin, }) { const [mode, setMode] = useState(loadViewMode); const [filter, setFilter] = useState(''); @@ -589,7 +806,7 @@ function VideoApp({ ${mode === 'poster' ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows} transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} - tmdbEnabled=${tmdbEnabled} />` + tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} />` : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows} transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`} `} diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 65c7da8..bf6bd64 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -938,6 +938,7 @@ class NodeDaemon: prev = self._last_broadcast_snapshot.get(group_id) delta = None + previous = None if prev is not None: prev_version, prev_entries = prev previous = GroupIndex._snapshot( @@ -953,6 +954,18 @@ class NodeDaemon: new_entries = delta.additions if delta is not None else list(idx.entries) asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries)) + # A rename/move changes the very filename (or season folder) that + # §3.3/§3.4's title-parse read display_title/season/episode from, + # but leaves the file's content — and so its id and everything + # ffprobe/thumbnailing already found — untouched. Only entries + # whose name or path actually differ from the last broadcast get a + # fresh pass; an update that is enrichment's own field-fill + # (duration/thumb_hash/... landing via _on_enriched below) leaves + # name/path alone and must not re-trigger itself forever. + if delta is not None and delta.updates and previous is not None: + asyncio.ensure_future( + self._reenrich_renamed_video_entries(indexer, delta.updates, previous)) + # 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 @@ -1060,6 +1073,32 @@ class NodeDaemon: return await self._enrich_new_video_entries(indexer, list(indexer.index.entries)) + async def _reenrich_renamed_video_entries( + self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex, + ) -> None: + """ + Videos app: found live — a French-named episode file, renamed by + the operator to match its English-named siblings, kept showing as + its own separate poster-grid card (and its own row in Flat list) + indefinitely, because `_enriched_attempted` — there specifically to + stop enrichment's own field-fill from re-triggering itself forever + (see the caller) — also silently blocked the *new* filename from + ever being title-parsed at all. `entry.id in self._enriched_attempted` + is the same content, so simply discarding it here and re-running + the ordinary enrichment path is enough: a fresh ffprobe/thumbnail + for an unchanged file is redundant work, not a correctness issue, + and renames are rare enough that the redundancy is not worth a + separate "title-parse only" code path. + """ + for entry in updates: + if entry.type != "video": + continue + old = previous.get_entry(entry.id) + if old is None or (old.name == entry.name and old.path == entry.path): + continue + self._enriched_attempted.discard(entry.id) + await self._enrich_new_video_entries(indexer, updates) + async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None: """ Merge enrichment fields into the live index and re-trigger a diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 129927d..6daae3f 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -41,6 +41,13 @@ CREATE TABLE IF NOT EXISTS thumbs ( jpeg BLOB NOT NULL ); CREATE INDEX IF NOT EXISTS idx_thumbs_file ON thumbs(file_id); +CREATE TABLE IF NOT EXISTS season_meta ( + tmdb_id TEXT NOT NULL, + season INTEGER NOT NULL, + json TEXT NOT NULL, + fetched_at REAL NOT NULL, + PRIMARY KEY (tmdb_id, season) +); """ # TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not @@ -109,6 +116,34 @@ class MediaCache: ) await self._db.commit() + # ── tmdb id + season number -> season-level metadata json ──────────────── + # + # A show's own overview (tmdb_meta above) is one static field an operator + # found does not necessarily describe every season alike (mediacenter.md + # §5.4) — this is TMDB's per-season `overview`/`air_date`/`poster_path`, + # fetched and cached independently, on the same staleness schedule. + + async def get_season_meta(self, tmdb_id: str, season: int) -> dict | None: + async with self._db.execute( + "SELECT json, fetched_at FROM season_meta WHERE tmdb_id = ? AND season = ?", + (tmdb_id, season), + ) 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_season_meta(self, tmdb_id: str, season: int, meta: dict) -> None: + await self._db.execute( + "INSERT OR REPLACE INTO season_meta (tmdb_id, season, json, fetched_at) " + "VALUES (?, ?, ?, ?)", + (tmdb_id, season, json.dumps(meta), time.time()), + ) + await self._db.commit() + # ── thumbnails ──────────────────────────────────────────────────────────── async def get_thumb(self, thumb_hash: str) -> bytes | None: diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py index 5e5ed9f..448a2b9 100644 --- a/packages/meshbay-node/src/meshbay_node/tmdb.py +++ b/packages/meshbay-node/src/meshbay_node/tmdb.py @@ -132,8 +132,26 @@ class TmdbClient: 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 search_movie_results(self, title: str) -> list[dict]: + """ + The raw candidate list (capped), for an operator correcting a wrong + automatic match (§ webrtc_server.py's tmdb_search_req) — unlike + `search_movie`, this doesn't collapse to TMDB's own top result: a + human picks from several, so several is the point. + """ + data = await self._get("search/movie", {"query": title, "include_adult": "false"}) + return (data or {}).get("results", [])[:8] + + async def search_tv_results(self, title: str) -> list[dict]: + data = await self._get("search/tv", {"query": title}) + return (data or {}).get("results", [])[:8] + + async def tv_season(self, tmdb_id: str | int, season: int, + language: str | None = None) -> dict | None: + """`language`, when given, overrides the configured one — same + English-fallback use as `movie_details`/`tv_details`.""" + params = {"language": language} if language else {} + return await self._get(f"tv/{tmdb_id}/season/{season}", params) async def movie_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None: """ 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 938ce3b..f4d1dee 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -67,6 +67,7 @@ from meshbay_common.adminop import ( OP_SET_SCAN_SETTINGS, OP_TMDB_CONFIG, OP_VIDEO_ROOT, + OP_TMDB_OVERRIDE, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -378,6 +379,12 @@ class WebRTCPeerSession: self._do_video_root(msg) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) + elif mtype == MNP.SEASON_META_REQ: + self._spawn(self._do_season_meta_request(msg)) + elif mtype == MNP.TMDB_SEARCH_REQ: + self._spawn(self._do_tmdb_search_request(msg)) + elif mtype == MNP.TMDB_OVERRIDE: + self._do_tmdb_override(msg) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -2452,6 +2459,159 @@ class WebRTCPeerSession: resp["episode"] = entry.episode self._send(resp) + async def _do_season_meta_request(self, msg: dict) -> None: + """ + Per-season TMDB overview/poster/air_date for a multi-season show — + found live: `media_meta_resp`'s one static show-level overview does + not necessarily describe every season alike (a season-3-specific + promotional summary applied to all three seasons of a show). + `tmdb_id` is whatever the client's own prior `media_meta_resp` + already resolved — never re-derived from a path here, so this + never re-runs a TMDB search of its own. + """ + tmdb_id = msg.get("tmdb_id") + season = msg.get("season") + if not isinstance(tmdb_id, str) or not tmdb_id or not isinstance(season, int): + self._send({"type": "error", "detail": "Missing tmdb_id or season"}) + return + media_cache = self._ctx.get("media_cache") + tmdb_client = self._ctx.get("tmdb_client") + if media_cache is None or tmdb_client is None: + self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION, + "tmdb_id": tmdb_id, "season": season, "confidence": 0}) + return + + details = await media_cache.get_season_meta(tmdb_id, season) + if details is None: + fetched = await tmdb_client.tv_season(tmdb_id, season) + if fetched is None: + self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION, + "tmdb_id": tmdb_id, "season": season, "confidence": 0}) + return + # Same per-field English fallback as _tmdb_build_meta: TMDB + # returns "" for an untranslated field rather than falling back + # itself. + if not fetched.get("overview"): + fallback = await tmdb_client.tv_season(tmdb_id, season, language="en-US") or {} + fetched = {**fallback, **{k: v for k, v in fetched.items() if v not in (None, "", [])}} + await media_cache.set_season_meta(tmdb_id, season, fetched) + details = fetched + + poster_thumb_hash = await self._fetch_and_cache_poster( + media_cache, tmdb_client, details.get("poster_path")) + self._send({ + "type": MNP.SEASON_META_RESP, "v": MNP_VERSION, + "tmdb_id": tmdb_id, "season": season, "confidence": 1.0, + "name": details.get("name"), + "overview": details.get("overview"), + "air_date": details.get("air_date"), + "poster_thumb_hash": poster_thumb_hash, + }) + + async def _do_tmdb_search_request(self, msg: dict) -> None: + """ + Candidate TMDB matches for an operator correcting a wrong automatic + match (docs/mediacenter.md, §V-whatever this becomes) — a plain + lookup, not a mutation, so unlike `tmdb_override` this needs no + admin authority: any member can see what TMDB itself would offer, + the same as the automatic search already silently does on their + behalf. Only `tmdb_override` actually changes what everyone sees. + """ + query = msg.get("query") + media_type = msg.get("media_type") + if not isinstance(query, str) or not query.strip() or media_type not in ("movie", "tv"): + self._send({"type": "error", "detail": "Missing query or media_type"}) + return + media_cache = self._ctx.get("media_cache") + tmdb_client = self._ctx.get("tmdb_client") + if media_cache is None or tmdb_client is None: + self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION, + "query": query, "media_type": media_type, "results": []}) + return + + raw = (await tmdb_client.search_movie_results(query) if media_type == "movie" + else await tmdb_client.search_tv_results(query)) + results = [] + for r in raw: + poster_thumb_hash = await self._fetch_and_cache_poster( + media_cache, tmdb_client, r.get("poster_path")) + results.append({ + "tmdb_id": str(r.get("id")), + "title": r.get("title") or r.get("name"), + "year": (r.get("release_date") or r.get("first_air_date") or "")[:4], + "poster_thumb_hash": poster_thumb_hash, + }) + # media_type echoed back, not just query: a client can fire a "war" + # tv search and a "war" movie search close together, and without it + # the two responses are indistinguishable for keyed matching + # (transport.js's tmdb_search_resp handler). + self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION, + "query": query, "media_type": media_type, "results": results}) + + def _do_tmdb_override(self, msg: dict) -> None: + """ + An operator correcting a wrong automatic TMDB match. Signed like + video_root/tmdb_config: it replaces what every member sees for a + show/movie, node-wide (media_cache is shared, not per-viewer). + + Applied to every entry sharing the representative file's + display_title — the same grouping the poster grid itself uses + (§3.4/§V6) — not just the one file the operator happened to be + looking at, so the correction actually sticks regardless of which + episode a future render picks as representative. + """ + path = msg.get("path") + tmdb_id = msg.get("tmdb_id") + media_type = msg.get("media_type") + if not isinstance(path, str) or not path: + self._send({"type": "error", "detail": "Missing path"}) + return + if not isinstance(tmdb_id, str) or not tmdb_id or media_type not in ("movie", "tv"): + self._send({"type": "error", "detail": "Missing tmdb_id or media_type"}) + return + ctx = self._group_ctx() + entry = ctx["index"].get_entry_by_path(path) + if not entry: + self._send({"type": "error", "detail": "File not found"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + subject = f"path={path},tmdb_id={tmdb_id},media_type={media_type}" + self._issue_admin_challenge(OP_TMDB_OVERRIDE, subject) + + async def _admin_exec_tmdb_override( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + subject = pending["subject"] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"tmdb_override:{subject}") + return + fields = dict(part.split("=", 1) for part in subject.split(",")) + path, tmdb_id, media_type = fields["path"], fields["tmdb_id"], fields["media_type"] + + ctx = self._group_ctx() + entry = ctx["index"].get_entry_by_path(path) + media_cache = self._ctx.get("media_cache") + if entry is None or media_cache is None: + self._send({"type": "error", "detail": "File or media cache not available"}) + return + target_title = entry.display_title or entry.name + matched = [e for e in ctx["index"].entries + if e.type == "video" and (e.display_title or e.name) == target_title] + for e in matched: + await media_cache.set_file_tmdb(e.id, tmdb_id, media_type) + self._audit("tmdb_override", subject) + + notice = {"type": MNP.TMDB_OVERRIDE_ACK, "v": MNP_VERSION, "path": path, + "tmdb_id": tmdb_id, "media_type": media_type} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + async def _tmdb_search(self, tmdb_client, entry, is_show: bool): """ §3.3's retry ladder: the parsed title first, then a couple of @@ -3045,6 +3205,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_VIDEO_ROOT: self._spawn( self._admin_exec_video_root(pending, transcript, sig_bytes)) + elif pending["op"] == OP_TMDB_OVERRIDE: + self._spawn( + self._admin_exec_tmdb_override(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index d5b3f94..18764e8 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -20,7 +20,7 @@ import time from html import escape from pathlib import Path -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ @@ -361,6 +361,20 @@ def create_ui_app(state: dict) -> FastAPI: state, group_id, bool(payload.get("allowed", False)), )) + # ── Enabled apps (operator only, localhost) ──────────────────────────── + # + # Same loopback shape as member-upload: the Create Group wizard sets this + # once, right after creating the group and before the (potentially long) + # initial scan, so an operator narrowing this down to just Files+Videos + # never briefly has Chat live for other members to notice. + + @app.put("/api/groups/{group_id}/apps") + async def set_enabled_apps(group_id: str, payload: dict): + apps = payload.get("apps") + if not isinstance(apps, list) or not apps: + raise HTTPException(400, "apps must be a non-empty list") + return await _op(lambda: ops.set_enabled_apps(state, group_id, apps)) + # ── Scan settings (operator only, localhost) ────────────────────────── @app.put("/api/groups/{group_id}/scan-settings") diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py new file mode 100644 index 0000000..87e0d1a --- /dev/null +++ b/packages/meshbay-node/tests/test_rename_reenrichment.py @@ -0,0 +1,163 @@ +""" +Bug found live, 2026-08-24: an episode file first named in French (its +release folder mixed languages across seasons) was renamed by the operator +to match its English-named siblings — but kept showing as its own +separate poster-grid card, and its own row in Flat list, indefinitely. + +`_enriched_attempted` (daemon.py) exists so that enrichment's own +field-fill (duration/thumb_hash/display_title/... landing back via +`_on_enriched`) does not re-trigger itself forever — but it also silently +blocked the *new* filename from ever being title-parsed at all, since the +file's content (and so its id) is unchanged by a rename. A rename/move is +exactly the case `_reenrich_renamed_video_entries` exists to detect: same +id, but `name` or `path` differs from the version last broadcast. +""" + +import asyncio + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +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 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] + + +class _StubRoster: + async def video_root(self, group_id): + return "shared" + + +class _SpyEnricher: + """Records which file ids were actually (re-)scheduled, without + needing a real ffmpeg/ffprobe pipeline for this test.""" + + def __init__(self): + self.spawned = [] + + def spawn(self, entry, file_path, on_done): + self.spawned.append(entry.id) + + async def _noop(): + return None + + return asyncio.ensure_future(_noop()) + + +async def test_a_renamed_file_gets_re_enriched(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + old_path = shared / "la-guerre-des-mondes-s03e02.mkv" + old_path.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=group_id, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29016, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 + daemon._enricher = _SpyEnricher() + daemon._roster = _StubRoster() + + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(), + # reconcile() below calls this itself — the initial scan doesn't + # (see test_startup_scan_enrichment.py), so that first broadcast is + # still triggered manually, matching _bg_scan's real sequence. + on_change=daemon._on_index_change) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + entry = next(iter(indexer.index.entries)) + file_id = entry.id + assert daemon._enricher.spawned == [file_id], ( + "the file must be scheduled for enrichment once, under its original name") + + new_path = shared / "war-of-the-worlds-s03e02.mkv" + old_path.rename(new_path) + changed = await indexer.reconcile() + assert changed, "the rename must actually be picked up by reconcile()" + # The re-broadcast is a fire-and-forget task chained behind the + # coalescing timer, itself scheduling another fire-and-forget task — + # poll rather than guess a single sleep long enough for both hops. + for _ in range(30): + if len(daemon._enricher.spawned) >= 2: + break + await asyncio.sleep(0.02) + + renamed_entry = indexer.index.get_entry(file_id) + assert renamed_entry is not None + assert renamed_entry.name == "war-of-the-worlds-s03e02.mkv" + assert daemon._enricher.spawned == [file_id, file_id], ( + "a rename must re-schedule enrichment for the same file id — " + "_enriched_attempted must not permanently block the new filename " + "from ever being title-parsed") + + +async def test_an_unrelated_update_does_not_re_trigger_enrichment(tmp_path): + """ + The other half of the same fix: an update whose name/path did *not* + change (the ordinary case — enrichment's own field-fill, or the + reconcile sweep confirming a file unmodified) must not re-schedule + enrichment. Without this, `_on_enriched` merging a file's own results + back into the index would count as its own trigger and loop forever. + """ + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + path = shared / "movie.mkv" + path.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=group_id, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29017, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 + daemon._enricher = _SpyEnricher() + daemon._roster = _StubRoster() + + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(), + on_change=daemon._on_index_change) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + entry = next(iter(indexer.index.entries)) + assert daemon._enricher.spawned == [entry.id] + + # A reconcile pass that finds nothing changed at all — not even a + # rename — must not re-schedule anything. + changed = await indexer.reconcile() + await asyncio.sleep(0.05) + assert not changed + assert daemon._enricher.spawned == [entry.id] diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py new file mode 100644 index 0000000..8ba55fb --- /dev/null +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -0,0 +1,187 @@ +""" +`_do_season_meta_request` (per-season TMDB overview/poster/air_date, for the +season-tab view — docs/mediacenter.md §5.4's fix for a 3-season show whose +overview read as season-3-specific for every season) and +`_do_tmdb_search_request` (raw TMDB candidates for an operator correcting a +wrong automatic match). Neither is a signed admin op — see each handler's own +docstring for why — so these tests only exercise the read path, unlike +test_tmdb_override_policy.py. +""" + +import pytest + +from meshbay_common.protocol import MNP +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession: + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client} + session.sent = [] + session._send = session.sent.append + return session + + +@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, season_json=None): + self.season_json = season_json + self.tv_season_calls = [] + self.movie_search_calls = [] + self.tv_search_calls = [] + + @staticmethod + def poster_url(path): + return f"https://image.tmdb.org/t/p/w500{path}" + + async def fetch_image(self, url): + return b"jpeg-bytes-for-" + url.encode() + + async def tv_season(self, tmdb_id, season, language=None): + self.tv_season_calls.append((tmdb_id, season, language)) + return self.season_json + + async def search_movie_results(self, title): + self.movie_search_calls.append(title) + return [{"id": 111, "title": title, "release_date": "2019-05-01", "poster_path": "/m.jpg"}] + + async def search_tv_results(self, title): + self.tv_search_calls.append(title) + return [{"id": 222, "name": title, "first_air_date": "2021-03-01", "poster_path": "/t.jpg"}] + + +# ── season_meta_req ────────────────────────────────────────────────────────── + +async def test_season_meta_missing_tmdb_id_is_refused(): + session = _session() + await session._do_season_meta_request({"season": 1}) + assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}] + + +async def test_season_meta_non_int_season_is_refused(): + session = _session() + await session._do_season_meta_request({"tmdb_id": "42", "season": "1"}) + assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}] + + +async def test_season_meta_with_no_cache_or_client_reports_zero_confidence(): + session = _session(media_cache=None, tmdb_client=None) + await session._do_season_meta_request({"tmdb_id": "42", "season": 1}) + assert session.sent == [{ + "type": MNP.SEASON_META_RESP, "v": session.sent[0]["v"], + "tmdb_id": "42", "season": 1, "confidence": 0, + }] + + +async def test_season_meta_cache_hit_skips_the_tmdb_call(media_cache): + await media_cache.set_season_meta("42", 3, { + "name": "Season 3", "overview": "cached overview", "air_date": "2023-01-01", + "poster_path": "/cached.jpg", + }) + client = FakeTmdbClient() + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_season_meta_request({"tmdb_id": "42", "season": 3}) + + assert client.tv_season_calls == [], "a cached season must not be re-fetched" + resp = session.sent[0] + assert resp["type"] == MNP.SEASON_META_RESP + assert resp["confidence"] == 1.0 + assert resp["overview"] == "cached overview" + + +async def test_season_meta_cache_miss_fetches_and_caches(media_cache): + client = FakeTmdbClient(season_json={ + "name": "Season 1", "overview": "fresh overview", "air_date": "2020-01-01", + "poster_path": "/fresh.jpg", + }) + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_season_meta_request({"tmdb_id": "7", "season": 1}) + + assert client.tv_season_calls == [("7", 1, None)] + resp = session.sent[0] + assert resp["overview"] == "fresh overview" + assert resp["poster_thumb_hash"] is not None + cached = await media_cache.get_season_meta("7", 1) + assert cached["overview"] == "fresh overview", "a fetched season must be cached for next time" + + +async def test_season_meta_empty_overview_falls_back_to_english(media_cache): + async def tv_season(tmdb_id, season, language=None): + if language == "en-US": + return {"name": "S1", "overview": "English overview", "air_date": "2020-01-01", + "poster_path": "/p.jpg"} + return {"name": "S1", "overview": "", "air_date": "2020-01-01", "poster_path": "/p.jpg"} + + client = FakeTmdbClient() + client.tv_season = tv_season + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_season_meta_request({"tmdb_id": "9", "season": 1}) + + assert session.sent[0]["overview"] == "English overview" + + +# ── tmdb_search_req ────────────────────────────────────────────────────────── + +async def test_search_missing_query_is_refused(): + session = _session() + await session._do_tmdb_search_request({"media_type": "movie"}) + assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}] + + +async def test_search_bad_media_type_is_refused(): + session = _session() + await session._do_tmdb_search_request({"query": "war", "media_type": "album"}) + assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}] + + +async def test_search_with_no_cache_or_client_returns_empty_results(): + session = _session(media_cache=None, tmdb_client=None) + await session._do_tmdb_search_request({"query": "war", "media_type": "tv"}) + assert session.sent == [{ + "type": MNP.TMDB_SEARCH_RESP, "v": session.sent[0]["v"], + "query": "war", "media_type": "tv", "results": [], + }] + + +async def test_search_movie_calls_movie_search_and_echoes_media_type(media_cache): + client = FakeTmdbClient() + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "movie"}) + + assert client.movie_search_calls == ["War of the Worlds"] + assert client.tv_search_calls == [] + resp = session.sent[0] + assert resp["type"] == MNP.TMDB_SEARCH_RESP + assert resp["media_type"] == "movie", ( + "media_type must be echoed back — otherwise a movie search and a tv " + "search for the same query are indistinguishable to the client's " + "keyed response matching (transport.js tmdb_search_resp handler)") + assert resp["results"] == [{ + "tmdb_id": "111", "title": "War of the Worlds", "year": "2019", + "poster_thumb_hash": resp["results"][0]["poster_thumb_hash"], + }] + + +async def test_search_tv_calls_tv_search(media_cache): + client = FakeTmdbClient() + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "tv"}) + + assert client.tv_search_calls == ["War of the Worlds"] + assert client.movie_search_calls == [] + assert session.sent[0]["media_type"] == "tv" diff --git a/packages/meshbay-node/tests/test_tmdb_override_policy.py b/packages/meshbay-node/tests/test_tmdb_override_policy.py new file mode 100644 index 0000000..b8fa6f9 --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_override_policy.py @@ -0,0 +1,172 @@ +""" +An operator correcting a wrong automatic TMDB match (found live: a real +show's search consistently matched a season-3-specific promotional TMDB +entry instead of the show itself). Signed like video_root/tmdb_config — +it changes what every member sees, node-wide (media_cache is shared, not +per-viewer) — and, once authorized, applies to every index entry sharing +the representative file's display_title, the same grouping the poster +grid itself uses (§3.4/§V6), not just the one file the operator happened +to be looking at. +""" + +import hashlib +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.adminop import OP_TMDB_OVERRIDE +from meshbay_common.protocol import IndexEntry, MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +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 _entry(path: str, name: str, display_title: str) -> IndexEntry: + # A real id is a blake3 content hash; sha256 here is just a stand-in with + # the same property that matters for these tests — deterministic and + # effectively collision-free across the handful of entries a test builds. + # (`hash((path, name)) % 10` was tried here before and is NOT that: it's + # randomized per-process by PYTHONHASHSEED and collides constantly across + # only 10 possible values, silently dropping entries in GroupIndex's + # id-keyed dict.) + digest = hashlib.sha256(f"{path}/{name}".encode()).hexdigest() + return IndexEntry( + id=digest, name=name, path=path, + size=1, type="video", added_at=0, display_title=display_title, + season=1, episode=1, + ) + + +# ── 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_tmdb_override({"tmdb_id": "123", "media_type": "tv"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_missing_tmdb_id_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show")) + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_tmdb_override({"path": "shared", "media_type": "tv"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_unknown_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_tmdb_override({"path": "nope", "tmdb_id": "123", "media_type": "tv"}) + + 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._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show")) + session._has_admin_authority = lambda: False + + session._do_tmdb_override({"path": "shared", "tmdb_id": "123", "media_type": "tv"}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_valid_request_is_signed(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "War of the Worlds")) + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_tmdb_override({"path": "shared", "tmdb_id": "2255", "media_type": "tv"}) + + assert issued == [(OP_TMDB_OVERRIDE, + "path=shared,tmdb_id=2255,media_type=tv")] + + +# ── Applying the override ─────────────────────────────────────────────────── + +async def test_override_updates_every_entry_sharing_the_display_title(tmp_path): + session = _session(tmp_path, "op", operator="op") + index = session._ctx["index"] + s1 = _entry("shared/S1", "s01e01.mkv", "War of the Worlds") + s2 = _entry("shared/S2", "s02e01.mkv", "War of the Worlds") + s3 = _entry("shared/S3", "s03e02.mkv", "War of the Worlds") + other_show = _entry("shared/Other", "ep.mkv", "A Different Show") + for e in (s1, s2, s3, other_show): + index.add_entry(e) + + media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await media_cache.open() + try: + session._ctx["media_cache"] = media_cache + # Signature verification itself is exercised generically elsewhere + # (test_roster_pairing.py) — this test is about the policy once a + # signature is known good: which entries actually get updated, and + # who is told about it. + session._verify_admin_sig = lambda transcript, sig: _true() + peer = type("Peer", (), {"sent": []})() + peer._send = peer.sent.append + session._peer_registry = lambda: {"peer-1": peer} + + await session._admin_exec_tmdb_override( + {"subject": "path=shared/S1,tmdb_id=999,media_type=tv"}, + b"transcript", b"sig") + + for e in (s1, s2, s3): + assert await media_cache.get_file_tmdb(e.id) == ("999", "tv"), ( + "every entry sharing the representative file's display_title " + "must be corrected, not just the one the operator clicked on") + assert await media_cache.get_file_tmdb(other_show.id) is None, ( + "a different show's own match must be left alone") + # The ack is broadcast to other connected peers, never echoed onto + # the requester's own `sent` — see the loop in + # _admin_exec_tmdb_override, which sends via each peer's own _send. + assert [m for m in peer.sent if m.get("type") == MNP.TMDB_OVERRIDE_ACK] + finally: + await media_cache.close() + + +async def _true(): + return True diff --git a/packages/meshbay-node/tests/test_wizard_apps_endpoint.py b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py new file mode 100644 index 0000000..10ab489 --- /dev/null +++ b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py @@ -0,0 +1,76 @@ +""" +Create Group wizard: choosing which apps a brand-new group offers, before +the (potentially long) initial scan — see app.js's CreateGroupWizard. This +is a loopback-only, operator-authenticated endpoint (11.5.3), same shape as +the existing member-upload one: a thin adapter over `ops.set_enabled_apps`, +with only the validation `_do_apps_enabled` (the signed MNP front door) +already does client-side in the wizard, but worth enforcing at this front +door too since nothing else would. +""" + +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi.testclient import TestClient + +from conftest import one_root +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.ui.app import create_ui_app + +pytestmark = pytest.mark.asyncio + + +async def _client(tmp_path: Path): + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state = { + "status": "running", + "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(shared)}}, + "indexes": {"g" * 32: index}, + "roster": roster, + } + app = create_ui_app(state) + return TestClient(app), roster + + +async def test_narrowing_the_apps_persists_to_the_roster(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": ["files", "video"]}) + assert resp.status_code == 200, resp.text + assert sorted(resp.json()["apps"]) == ["files", "video"] + assert sorted(await roster.enabled_apps("g" * 32)) == ["files", "video"] + finally: + await roster.close() + + +async def test_empty_apps_list_is_refused(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": []}) + assert resp.status_code == 400 + finally: + await roster.close() + + +async def test_missing_apps_field_is_refused(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put(f"/api/groups/{'g' * 32}/apps", json={}) + assert resp.status_code == 400 + finally: + await roster.close() + + +async def test_unhosted_group_is_refused(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put("/api/groups/" + "z" * 32 + "/apps", json={"apps": ["files"]}) + assert resp.status_code == 404 + finally: + await roster.close() -- cgit v1.2.3