# Cinema Catcher extended playback controls handoff

Implemented in the source for **D-Cine Player 4.2.13**, responding to
[the 6 October 2026 request](DCP_CINE_PLAYER_EXTENDED_CONTROLS_REQUEST.md).
The service and native playback executable must both be updated. This document
describes the new source implementation; it does not imply an installed cinema
player has been upgraded or hardware-qualified.

## Support and discovery

The five requested native endpoints were missing in 4.2.12. Internal controls
existed, but did not provide the required native API contract. All five are now
implemented under `/api/player/v1`, with existing token/session authentication,
playback permission and licensing checks. Existing basic commands also accept
`Idempotency-Key`.

Connect using `Authorization: Bearer <token>` or `X-DCP-Player-API-Key: <token>`.
Fetch `GET /capabilities` before enabling transport buttons. With a permitted,
licensed connection to the updated native executable, the relevant fragment is:

```json
{
  "licensed": {"playback": true},
  "playback": {
    "select": true, "play": true, "pause": true, "resume": true, "stop": true,
    "seek": true, "previous": true, "next": true, "jump": true, "unload": true
  }
}
```

Extended flags are false if the control socket is disabled/disconnected, the
native executable does not advertise this implementation, or the caller lacks
playback permission/licensing. The separate `licensed.playback` field remains
the licence indication. Flags describe availability; state/media restrictions
below still apply. Refresh discovery after reconnection or a player update.

## Requests

Send `POST`, `Content-Type: application/json`, and a unique `Idempotency-Key` for
each operator action. Identifiers are opaque, case-sensitive strings.

| Endpoint | JSON body | Allowed state |
| --- | --- | --- |
| `/playback/seek` | `playlist_id`, current `item_id`, exactly one of integer `position_ms` or signed integer `offset_ms` | playing, paused |
| `/playback/previous` | `playlist_id`, current `expected_item_id` | playing, paused |
| `/playback/next` | `playlist_id`, current `expected_item_id` | playing, paused |
| `/playback/jump` | `playlist_id`, destination `item_id` | playing, paused |
| `/playback/unload` | `playlist_id` | stopped, idle |

Unknown, conflicting and inapplicable fields are rejected. A stopped selection
cannot be repositioned: Play first, or Play then Pause before positioning.
Previous/next/jump begin the destination item at zero. All reposition operations
preserve playing/paused state. None selects a different show or changes scheduler
configuration, schedules, automation configuration, content or stored shows.

For +1 minute:

```http
POST /api/player/v1/playback/seek
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: 63d9b132-00c2-4a1e-996e-d0fe14ad6612
```

```json
{"playlist_id":"show-123","item_id":"item-456","offset_ms":60000}
```

Other example bodies:

```json
{"playlist_id":"show-123","item_id":"item-456","offset_ms":-300000}
```

```json
{"playlist_id":"show-123","item_id":"item-456","position_ms":80000}
```

```json
{"playlist_id":"show-123","expected_item_id":"item-456"}
```

Use the last body with previous or next. For jump and Eject:

```json
{"playlist_id":"show-123","item_id":"destination-item-id"}
```

```json
{"playlist_id":"show-123"}
```

Relative seeks use the most recent confirmed native item position. They stay
within that item and clamp to zero or its last media frame, returning `clamped`.
Absolute positions must satisfy `0 <= position_ms < item_duration_ms`; the item
end is exclusive. Milliseconds are rounded down to a media frame at the item's
edit rate. Returned integer milliseconds are rounded from the effective frame
position; they are suitable for display but do not replace the edit rate when
formatting `HH:MM:SS:FF`.
`playback.timecode` supplies the native timecode, and
`playback.current_media_frame_rate` reports the confirmed media rate, including
BLACK output rates. CPL catalogue edit-rate numerator/denominator remain the
preferred rational rate for a CPL.

BLACKs and CPLs (including repeated CPL occurrences) are navigable. Cues are
attached to items and cannot be selected separately. The active executable item
order is used: unavailable CPLs excluded when playback was loaded do not become
navigation destinations. At either boundary previous/next returns a conflict;
there is no wrapping. A target absent from the active executable show is rejected.

Current or destination live SDI/event-stream media cannot be repositioned. Stop
live playback before selecting other content. Content catalogue readiness and
trusted time are checked before dispatch; native decoder/key/recipient/KDM/output
readiness is checked before stopping the current item. Native state, timeline,
item index and path preconditions protect against changes between request
validation and execution. Validation failures leave playback running/paused at
its existing position (normal elapsed playback can continue).

Unload clears selection and native loaded paths after acknowledgement. It never
deletes the show or content. Playing, paused, starting, stopping, live input and
calibration states require Stop and confirmation of stopped/idle first. The
scheduler stays enabled if it was enabled; future scheduled starts still occur.

In 4.2.15, the [complete TMS handoff](DCP_CINE_PLAYER_TMS_PROGRAMMING_HANDOFF.md) adds a native manual-transport scheduler interlock: `/playback/*` returns `SCHEDULER_LOCKED` while enabled. The preceding unload behaviour applies after an explicit authorized switch to manual mode. Do not disable scheduling simply to probe/test controls.

## IDs and status

Use `GET /shows/{playlist_id}` and retain each `items[].id` unchanged. These are
stable, unique IDs for the stored show, including repeated CPLs and BLACKs. After
editing/replacing a show, fetch its IDs again. Never use CPL IDs or array indices
as jump targets. `GET /status` now takes playback/progress from the same snapshot:

```json
{
  "selected_show":"show-123",
  "playback": {
    "state":"paused", "playlist_id":"show-123", "timeline_pending":false,
    "current_playlist_item_id":"item-456", "timecode":"00:01:20:00"
  },
  "progress": {
    "item_id":"item-456", "cpl_id":"cpl-789",
    "item_position_ms":80000, "item_duration_ms":600000,
    "show_position_ms":680000, "show_duration_ms":1800000
  }
}
```

BLACK has a null `cpl_id`. Without a confirmed current item, `item_id` and item
progress are null. Show progress is based on the active executable show. While
`timeline_pending` is true, retain the last confirmed display and show an operation
in progress; do not display a requested position as an actual position.

## Receipts, retries and errors

Every new endpoint returns HTTP **202** after dispatch, with `Location` pointing
to its pollable receipt:

```json
{"command_id":"job_abc","state":"accepted","operation":"seek","clamped":false}
```

Poll `GET /commands/job_abc` until `state` is `completed` or `failed`, then refresh
`GET /status`. Acceptance/seek acknowledgements alone do not prove media was
presented. Completion requires a matching native progress sample (or the native
unload acknowledgement). A completed seek receipt looks like:

```json
{
  "command_id":"job_abc", "state":"completed", "error":null,
  "result": {
    "playlist_id":"show-123", "item_id":"item-456",
    "position_ms":80000, "show_position_ms":680000,
    "state":"paused", "clamped":false
  }
}
```

Completed unload returns `result.state:"idle"`, positions zero and no item/show
ID. Receipts persist in the job database and can also be inspected through
`GET /jobs/{id}`. Existing job-history retention/deletion rules apply. The command
endpoint uses `accepted`, `completed`, `failed`; do not confuse these with native
job storage states such as `running`/`succeeded`.

For an asynchronous native validation failure:

```json
{
  "command_id":"job_abc", "state":"failed",
  "error":"destination KDM could not be decrypted",
  "result": {
    "code":"CONTENT_VALIDATION_FAILED",
    "error":"destination KDM could not be decrypted",
    "playlist_id":"show-123", "item_id":"item-456",
    "position_ms":20000, "show_position_ms":620000,
    "state":"playing", "clamped":false
  }
}
```

Validation before dispatch returns the existing `{"error":"message","code":"CODE"}`
envelope. Authentication/licensing failures retain the existing service envelope.

| HTTP/code | Catcher handling |
| --- | --- |
| 400 `INVALID_REQUEST`, `INVALID_POSITION`, `INVALID_TARGET` | Correct payload/target; do not retry unchanged. |
| 409 `STALE_SHOW`, `STALE_ITEM`, `POSITION_UNKNOWN` | Refresh status and show IDs before a new operator action. |
| 409 `INVALID_STATE`, `COMMAND_PENDING`, `PLAYLIST_BOUNDARY` | Wait/Stop or disable the unavailable action. |
| 422 `MEDIA_UNSEEKABLE`, `CONTENT_UNAVAILABLE`, `KDM_UNAVAILABLE`, `TRUSTED_TIME_REQUIRED` | Explain the media/readiness restriction to the operator. |
| 503 `CONTROL_UNAVAILABLE` | Refresh capabilities and connection status. |
| 409 `IDEMPOTENCY_CONFLICT` | Same key was reused for different method/path/body/If-Match; use the original request. |
| 409/500 `REQUEST_INDETERMINATE` | Reconcile status; do not invent another key to repeat an uncertain action. |
| Receipt `CONTENT_VALIDATION_FAILED`, `STALE_ITEM`, `INVALID_STATE`, `INVALID_TARGET`, `MEDIA_UNSEEKABLE` | Native rejection; refresh actual status and show readiness. |
| Receipt `STORAGE_ERROR` | Persistence failed after native acknowledgement; reconcile selection/status before another action. |
| Receipt `CONFIRMATION_TIMEOUT`, `CONTROL_WRITE_FAILED`, `CONTROL_DISCONNECTED`, `SERVICE_RESTARTED`, `COMMAND_CANCELLED`, `PLAYBACK_FAILED` | Result is uncertain or interrupted; reconcile actual status and Stop before issuing a replacement action. |

The same key with identical method/path/body/If-Match replays the original HTTP
response and command ID, with `Idempotency-Replayed: true`, even after service
restart. It never applies the offset or navigation twice. Preserve the exact
serialized body for retries. An executing/interrupted idempotency claim returns
`REQUEST_INDETERMINATE` instead of executing again. Keys are scoped to the API
identity; keep credentials consistent. Keys are optional for compatibility,
but Catcher should always send them.

Confirmation times out after 30 seconds. A timeout does not prove the native
operation never happened; a late position sample can still update status.
Pending timeline state blocks further extended commands until confirmed or Stop.
After service restart, unfinished receipts become `failed`/`SERVICE_RESTARTED`;
the command is never automatically replayed.

## Cue policy

Repositioning does not execute macros to reconstruct hardware state. Cues before
the destination, including cues on skipped items, are suppressed. Destination
cues are eligible once through normal playing progression and stay pending while
paused. Cues at/after a backward destination are rearmed: crossing them again
fires them on that new pass. Start/end references retain their offsets relative
to the actual item duration. While a timeline change is unconfirmed, automatic
cue dispatch is suspended. Catcher must offer manual automation actions if an
operator needs to restore lighting, sound or format after skipping their cues.

## Catcher adapter changes

1. Map player `previous` support to Catcher `prev`, and `unload` to `eject`.
2. Translate seek buttons to signed offsets: ±60000 and ±300000 milliseconds.
   Translate absolute timeline selection to `position_ms`, and item selection to
   `/playback/jump` with the stored opaque `items[].id`.
3. Supply the current status `progress.item_id` for seek and as
   `expected_item_id` for previous/next. Supply `playback.playlist_id` while
   active; use `selected_show` when unloading a stopped selection.
4. Gate each button on its own capability and the state/media restrictions.
   Serialize operator transport actions while a receipt is pending. For Eject,
   Stop, wait for stopped/idle status, then Unload with a new key.
5. Store the key, exact request bytes and returned command ID. Poll receipts and
   refresh status to update both timelines. Surface terminal failure messages.

## Verification and remaining commissioning

The [supplied 30 October 2026 site report](catcher-4.1.3-player-4.2.30-qualification-2026-10-30.json)
records three published-image navigation runs of Catcher 4.1.3 against player
4.2.30, each with 19 passes, zero failures and four explicit skips. These used
synthetic BLACK and do not establish real DCP, encrypted-media, physical cue or
SDI qualification. The report also records read and temporary-booking tests,
cleanup, scheduler restoration and no pending/recovery commands. Raw exported
reports were referenced by the supplier and were not independently inspected
for this documentation update.

Catcher must wait for receipt completion **and** timeline/state confirmation;
an early position match does not authorize the next command. Its 4.1.3 lock
acquisition fix waits up to two seconds before native dispatch while keeping
background reads nonblocking. It retains pending-command/recovery checks and
never retries or replays a control request. These are adapter-side fixes and
do not change the player endpoints or safety interlocks.

Automated tests cover service payload/state/precondition validation, frame
quantization and relative clamping, BLACK item navigation, repeated paths,
native-confirmed receipts, stale progress rejection, durable retries, unload
without deletion, capability negotiation and destination/backward cue policy.
The native control test uses synthetic BLACKs and a generated MPEG-4 clip with SDL's dummy renderer and
tests playing/paused reposition, repeated paths, native preconditions, content
rejection, paused final-frame holding and Resume, and unload. It requires
`ffmpeg` on PATH to generate the temporary clip. See `extended_playback_test.go` and
`tests/extended_playback_control_test.py`.

Results from this workspace on 6 October 2026:

| Check | Result |
| --- | --- |
| `go test ./...` in `player-service` | Passed across all packages. |
| `go test -race ./cmd/dcp-player-service -run 'TestExtended\|TestTimeline\|TestPlaylistAutomationTracker' -count=1 -timeout=90s` | Passed; no race reports. |
| `cmake --build build --target dcp-player-app -j 4` | Passed with native decoding and DeckLink support compiled. |
| `python3 tests/extended_playback_control_test.py --binary build/dcp-player-app` | Passed against the built native executable. |
| CTest `frame_cache` and `sdl_hud_overlay` | Passed. HUD required runtime library paths and access to local audio services outside the sandbox. |
| Shell syntax checks for both documentation packaging/install scripts | Passed. |

Example local commands (test suites needing local HTTP/Unix/audio sockets require
an environment that permits them):

```sh
cd player-service
GOCACHE=/tmp/dcp-player-go-cache go test ./...
```

```sh
python3 tests/extended_playback_control_test.py --binary build/dcp-player-app
```

Use a dedicated non-public test show to qualify actual CPL media, encrypted
recipient keys/KDM windows, edit rates and SDI hardware. Include repeated CPLs,
BLACKs, start/end cues and a feature. Verify ±1/±5 minute and absolute seeks,
boundaries, cue dispatch counts, paused output/audio, live-input rejection and
timeouts/reconnects. Automated synthetic-media results do not certify those
hardware/encrypted-content cases.
