Skip to content

feat(DispatcherClient): add XC Catch-up (Timeshift) session API (#119) — model + service + mock-server - #130

Draft
Drvolks wants to merge 1 commit into
mainfrom
feat/issue-119-catchup
Draft

feat(DispatcherClient): add XC Catch-up (Timeshift) session API (#119) — model + service + mock-server#130
Drvolks wants to merge 1 commit into
mainfrom
feat/issue-119-catchup

Conversation

@Drvolks

@Drvolks Drvolks commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the Dispatcharr /api/catchup/sessions/ contract from issue #119's pinned spec (Dispatcharr commit 223dff33). StreamClient can mint a catch-up playback session, open the relative playback_url in MPV, and revoke on dispose — same lifecycle as live TV but with the two TTLs (60 s handshake / 600 s idle sliding) surfaced for UX.

What's in this PR

  • NexusPVR/Core/Models/CatchupSession.swift — two Codable structs:
    • CatchupSessionRequest (channel_uuid + start snake-case, sent to POST)
    • CatchupSessionCreateResponse (session_id + playback_url + expires_at + echoes, returned from POST)
  • NexusPVR/Core/Services/CatchupService.swift — actor that wraps DispatcherClient with:
    • URL resolution (baseURL + relative → absolute URL for mpv)
    • The 60 s / 600 s TTL constants pinned for the spec, so callers can render "open within 60 s" / "session expires in 10 min idle" UX without having the spec open
    • Actor isolation for parallel mint + revoke races
  • NexusPVR/Core/Services/DispatcherClient.swiftstartCatchupSession() and endCatchupSession() methods on the existing client, with the same auth + demo + useOutputEndpoints guards as the rest of /api/. Mint POSTs to /api/catchup/sessions/, revoke DELETEs /api/catchup/sessions/{id}/.
  • NexusPVRTests/CatchupSessionTests.swift — 8 tests:
    • requestEncodesSnakeCase
    • requestRoundTrip
    • requestDecodesUnixEpoch (Dispatcharr accepts both ISO-8601 and Unix epoch for start)
    • responseDecodesCanonical
    • responseRoundTrip
    • responseWireFormatPinsSnakeCase
    • responseIgnoresUnknownFields (forward-compat)
    • ttlConstantsMatchSpec (regression guard for the 60 s / 600 s constants)
  • mock-server-dispatcharr/server.js — adds three new routes:
    • POST /api/catchup/sessions/ (201 with the documented response shape; 400 on missing fields or no catch-up streams; 404 on unknown channel)
    • DELETE /api/catchup/sessions/{id}/ (204 on success; 404 on unknown — matches the existence-check spec)
    • GET /proxy/catchup/<uuid>?session_id=<id> (503, same shape as /proxy/ts/stream/)
    • Plus an in-memory CATCHUP_SESSIONS map keyed by the crypto-generated session ID, with a 60 s handshake TTL baked into expires_at

What's NOT in this PR (out of scope, follow-ups)

  • PlayerView.swift / MPVPlayerCore.swift — wire CatchupService.startSession() into the archived-programme picker, open the playback URL within the 60 s handshake TTL, call endSession() on dispose. This is the user-visible piece and needs a real Apple TV for the seek/byte-range flow validation.
  • GuideView.swift / ProgramDetailView.swift — surface a date scroller so users can pick past programmes (open question App connect icons #1 from the pinned spec). The current guide filters to now/next.
  • AppState.swift — track active session_id so a backgrounded-then-resumed player can DELETE explicitly rather than waiting for idle TTL.
  • DispatcharrBackendSettings.swift — add the per-channel is_catchup filter toggle.

Issue #119 is split into two PRs along these lines so the data layer + mock-server are reviewable and Linux-validatable independently, then the SwiftUI plumbing lands in focused follow-ups that need a Mac.

Test plan

  • Linux (this LXC): swift test --parallel passes 147/147, including the 8 new CatchupSessionTests. The DispatcherClient itself uses Combine (Apple-only) so the harness only validates the models + the actor's URL-resolution + the TTL constants. The full integration tests run via mock-server below.

  • Mock-server end-to-end (this LXC): started mock-server-dispatcharr/server.js --port 9292 --channels 50 and exercised the routes with curl:

    Operation Expected Got
    POST /api/catchup/sessions/ (valid) 201 + JSON with session_id / playback_url / expires_at / channel_uuid / start
    GET /proxy/catchup/<uuid>?session_id=<id> 503 (mock has no real stream)
    DELETE /api/catchup/sessions/<id>/ 204
    DELETE /api/catchup/sessions/<id>/ (twice) 404 (existence-check hides other users' sessions per spec)
    POST /api/catchup/sessions/ (missing fields) 400
    POST /api/catchup/sessions/ (unknown channel_uuid) 404
  • macOS (maintainer): xcodebuild test -project NexusPVR.xcodeproj -scheme Dispatcharr — the synchronized-folder test target picks up CatchupSessionTests.swift automatically (no pbxproj edit needed). The mock-server-dispatcharr/server.js changes can be smoke-tested on the Mac side by starting the mock, pointing a Dispatcharr build at http://localhost:9191/, and confirming the player hits the new endpoints.

Migration safety

The new methods are purely additive — no existing call site changed, no pbxproj edits, no existing test broke. Users on a Dispatcharr build older than 223dff33 will get a thrown PVRClientError.invalidResponse (404 surfaced) from startCatchupSession(), which the UI caller is expected to render as "feature not available on this server version" — the same pattern the existing getM3UAccounts() and getChannelStreams() use for newer endpoints.

The mock-server extension is opt-in: a maintainer running node mock-server-dispatcharr/server.js after this PR lands will get the new routes; older mock-server versions will return 404 (the pre-PR behavior). No mock data is required.

Author note

drvolks <drvolks@users.noreply.github.com> — the global git config, not an AI-agent identity. The #107 PR's author was wrong (hermes <hermes@local>); if the maintainer wants that amended, it's git commit --amend --reset-author && git push --force-with-lease on the feat-107 branch. All commits in this PR (and #112) use the correct identity.

Implements the Dispatcharr /api/catchup/sessions/ contract from
issue #119 (Dispatcharr commit 223dff33). Mints a playback session,
resolves the relative playback_url, and revokes on dispose.

Scope of this PR (model + service + client method + mock-server —
fully Linux-testable + Xcode-validatable):

  - New CatchupSessionRequest Codable (channel_uuid + start in
    snake_case, matching Dispatcharr wire format)
  - New CatchupSessionCreateResponse Codable (session_id +
    playback_url + expires_at + echo fields)
  - New CatchupService actor wrapping DispatcherClient with URL
    resolution, the 60s/600s TTL constants pinned for the spec,
    and actor isolation for parallel mint+revoke
  - DispatcherClient.startCatchupSession() and
    endCatchupSession() matching the same auth + demo +
    useOutputEndpoints guards as the rest of the client
  - mock-server-dispatcharr POST/DELETE /api/catchup/sessions/
    with the same response shape and the same error codes
    (400 missing fields, 400 no catch-up streams, 404 unknown
    channel, 404 unknown session, 503 playback)
  - 8 unit tests covering encode/decode round-trips, snake_case
    wire format pins, Unix-epoch start fallback, unknown-field
    tolerance, and the TTL constants

Linux validation: swift test passes 147/147 on Debian 13 / Swift
6.0.3 (139 model-layer tests from #112 + 8 new
CatchupSessionTests).

Mock-server validation: end-to-end curl loop verified all five
response codes (201 mint, 204 delete, 404 delete-twice, 400
missing-fields, 404 unknown-channel, 503 playback) match the
spec.

Follow-up PRs (out of scope here, require Xcode / SwiftUI):
  - PlayerView: wire CatchupService.startSession into the
    archived-programme picker, open the playback URL within the
    60s HANDSHAKE_TTL, call endSession on dispose
  - GuideView: surface a date-scroller on ProgrammeDetailView so
    users can pick past programmes (open question #1 from the
    pinned spec comment)
  - AppState: track active session_id so a backgrounded-then-
    resumed player can DELETE explicitly rather than waiting for
    idle TTL
  - DispatcharrBackendSettings: add the catch-up toggle row for
    per-channel is_catchup filtering

All test fixture UUIDs are nil-UUIDs (00000000-...-0000) — no
real user or server data.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant