* feat(downloads): offline sync for mobile (downloads v2) Replace internal/download with a unified internal/downloads package and add fully-offline download + watch-sync support for mobile clients, across five independently-shippable phases: - Phase 0: reshape the downloads table and the /downloads contract to be device- and format-aware; add GET /downloads/capability; extend DownloadConfig (default-off keys); update the web download hooks/components in lockstep. This is the one approved pre-lock exception to the additive-only /api/v1 rule (the web app is the only consumer and is updated together). - Phase 1: managed device-library entries (create/list/PATCH/delete/serve), keyed on the X-Silo-Device-Id header. - Phase 2: offline playback manifest plus artwork/subtitle proxy endpoints that strip every presigned URL (inline thumbhashes + authenticated proxies). - Phase 3: prepare-to-file (remux + transcode-to-single-file) as a durable, leased artifact queue with startup recovery, hosted on the task manager; playback.PrepareFile emits one +faststart MP4. Adds the admin transcode toggle and per-artifact LRU cleanup. - Phase 4: offline progress reconciliation -- a clamped event_at LWW key plus a server-assigned synced_seq cursor on watch_progress; an optional clamped updated_at on POST /sync/progress and an opaque ?since= cursor on GET /progress (additive; existing callers unaffected). Security & reliability invariants, each with an acceptance test: 1. Server-owned sync ordering: ?since= delta delivery is driven only by the server-assigned synced_seq; the client clock is bounded (event_at, clamped to now+skew) and used only for last-write-wins on the caller's own profile. 2. Full profile+device authorization on every managed endpoint, with a per-profile content/library access re-check before serving any bytes/assets. 3. Durable artifact recovery: a transactionally-claimed (FOR UPDATE SKIP LOCKED), lease-heartbeat, attempt-counted queue with a startup sweep, so no crash strands a download in preparing and concurrent workers never double-encode. Migrations are timestamped Goose files: reshape downloads (device/format); download_artifacts (durable queue); watch_progress event_at/synced_seq. DB-backed acceptance tests skip without SILO_TEST_DATABASE_URL and run in CI; the invariant-1 progress test also runs against the real SQLite backend locally. Client repos (silo-android, silo-apple) consume the reshaped /downloads/* contract and the updated_at/?since= progress fields and require coordinated follow-up. Implements the maintainer-approved v1 capability proposal for offline sync (downloads v2). AI-use disclosure: implemented by Claude (Claude Code) from the approved design doc under docs/superpowers/specs, with human review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(downloads): series & season downloads + client-pull monitoring Build season downloads and a "monitor a series" capability on top of the downloads v2 (offline sync for mobile) work. Season downloads: - POST /downloads accepts season_number (with series:true) to download one season. CreateSeries/CreateSeason share one body via a listEpisodes closure and register managed entries under a shared batch_id (original-only). Episode files are resolved in a single batched query. Series monitoring (auto-download), client-driven: - New device-scoped download_subscriptions table with a Sonarr-style mode (all | future | latest_season | specific_seasons), a client-enforced delete_watched flag, and a max_storage_bytes cap. The server never deletes on-device files; retention and the hard cap are the client's, the server only soft-gates registration. - The client calls POST /downloads/subscriptions/sync on open / background refresh; the server registers the in-scope, not-yet-downloaded episodes (idempotent via the managed-entry unique index) and the device pulls them on its own schedule. No background worker and no dependency on the notifications subsystem. latest_season follows new seasons (>= subscribe-time season); future excludes the back catalog via air date. - Subscription CRUD + sync are profile+device authorized (device id from the X-Silo-Device-Id header only) with a per-request content-access re-check. The capability endpoint advertises season_download / series_monitoring / monitoring_modes. Also lands the downloads-v2 work already present in the tree: durable artifact (remux/transcode) preparation and offline watch-progress reconciliation, plus the design-spec updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * WIP: epitaxy pre-switch from feat/downloads-v2-offline-sync * test(downloads): fix deterministic ID collision in reconcile test Artifact IDs are time-sortable, so two artifacts created in the same moment share their first 8 chars; combined with a captured timestamp the two preparing-download IDs collided on downloads_pkey. Use the full artifact ID, which is unique per row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): support sqlite userdb backend for managed downloads With the sqlite userdb backend, profiles live only in per-user SQLite stores and public.user_profiles stays empty, so user_devices' profile FK made every managed create/subscription/offline-sync request fail with an FK violation. Drop the FK (shared Postgres tables must not FK profile tables — same rule as notifications) and replace the lost cascade with an app-level purge on profile deletion, wired through ProfileHandler for both backends. DB-backed regression tests cover the no-Postgres-profile-row path and the purge cascade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): dispatch encode kick asynchronously triggerDrain invoked the kick inline, and the kick (taskmanager RunTask) executes the encode task on the caller's goroutine — so a POST /api/v1/downloads with a bitrate quality blocked the HTTP request on the entire queue drain, ffmpeg encodes included, delaying the 202 by minutes on an idle queue. Dispatch the kick on a goroutine; the task manager already serializes concurrent runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): enforce per-user quota on the encode pipeline Two gaps let a user bypass MaxConcurrentPerUser entirely for prepared downloads: artifact-backed rows are created in 'preparing' (never 'queued'/'downloading'), which CountActiveByUser didn't count, and createArtifactDownload enqueued the encode job before limiter.Check, so even a 429-rejected request left a job the worker would transcode. Count 'preparing' as active and check the limiter before Ensure; managed replacements stay quota-exempt since they don't add a row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): protect ephemeral artifact links from LRU eviction HasActiveLink only counted managed (device_id IS NOT NULL) rows, so under a byte budget Cleanup could delete an artifact still referenced by a ready-but-unfetched ephemeral web download — permanently 404ing a row the API kept listing as ready (the artifact row is gone, so recovery can't re-queue it). Any non-terminal link now protects the artifact; only artifacts whose links are all cancelled/failed/revoked are evictable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): batch manifests skip bad entries instead of failing whole batch One deleted or access-filtered episode made GET /downloads/batches/{id}/manifests 404 for the entire season, so a client could no longer fetch manifests for the still-valid entries. Report unbuildable entries in a skipped[] array (revoked | not_found | error) alongside the delivered manifests, mirroring the create path's skip idiom. Also cut the batch cost: the shared series detail is resolved once per batch instead of once per episode, and buildSubtitles reuses the already-loaded media file instead of re-querying it per manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): wrap DO block in StatementBegin/End markers Under NO TRANSACTION goose splits statements on semicolons, so the dollar-quoted DO block failed every fresh install with 'unterminated dollar-quoted string' (SQLSTATE 42601). Already-applied databases are unaffected. Same fix is being applied to main; identical content merges cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): allow season 0 (Specials) in season downloads season_number was a plain int dispatched with '> 0', so requesting the Specials season was indistinguishable from omitting the field and silently broadened to a full-series download. Dispatch on pointer presence, treat 0 as the Specials season, and reject negatives with 400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): capability quality_presets is never JSON null PresetsFor returned a nil slice when downloads are disabled or the user lacks the permission, and Capability's []string{} initialization was immediately overwritten by it — so GET /downloads/capability serialized "quality_presets": null where the contract documents an array. Normalize at the source so every caller inherits the guarantee. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): subscription sync correctness + batched registration Three subscription fixes: - A paused subscription no longer syncs: PATCHing scope (or pausing and changing scope in one request) registered episodes for a monitor the user had just stopped, inconsistently with SyncSubscriptions' guard. - SubModeFuture compares calendar days (UTC): air_date is date-only, so the strict instant comparison permanently excluded episodes airing the same day the user subscribed; episodes with no air date now fall back to their ingest time instead of never registering. - Registration is one batched fetch (GetManagedEntriesByKeys) plus one batched INSERT ... ON CONFLICT DO NOTHING RETURNING (CreateManagedEntriesBatch) instead of a SELECT+INSERT per episode — a 300-episode series cost ~600 sequential round trips per request and every no-op sync re-walked the full set. RETURNING yields exactly the new rows, so the sync response's 'registered' count now honestly reports 0 in the steady state instead of the full in-scope count on every app open. The now-unused InsertManagedEntryIfAbsent is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(userstore): stamp triggers own the event_at LWW key MarkProgressBatch (jellycompat series mark-played) advanced updated_at but never event_at, and both stamp triggers only defaulted event_at when NULL — so a queued offline event with a client time between the row's old event_at and the mark could win SetProgressIfNewer and resurrect a stale resume position that then re-synced to every device. Make the triggers authoritative instead of adding a tenth hand-written SET clause: whenever an UPDATE changes updated_at without explicitly changing event_at, the trigger advances the LWW key; writes that do set event_at (offline sync's clamped client event time) keep their value. Postgres gets a CREATE OR REPLACE migration; SQLite gets a v12 userdb migration that drops and reinstalls the trigger bodies (CREATE TRIGGER IF NOT EXISTS never replaces). Conformance tests cover both batch paths, the preserved-client-time invariant, and the v11→v12 upgrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): lifecycle hygiene — squash migrations, dead status, stale-row sweeps Migrations: fold the 20260621 corrective migration back into the base Downloads V2 migrations (its columns/constraints already exist there) and fix the reshape Down, which re-added the narrow status CHECK without collapsing managed-lifecycle rows first — rollback aborted on any DB with preparing/ready/revoked rows; validated against a live row. Branch databases that applied the corrective migration need its version row removed: DELETE FROM goose_db_version WHERE version_id = 20260621020459. Code: drop the dead 'registered' status (nothing ever wrote it; the lifecycle is preparing -> ready; 'revoked' stays reserved for the planned admin revoke flow) along with unused KindDirect and ErrInvalidFormat. Sweeps: Cleanup now runs an age-based hygiene pass independent of the byte budget — cold terminally-failed artifacts (with .part leftovers), orphaned ready artifacts no download row references, and ephemeral web rows older than their convenience-record lifetime (also unpinning their artifacts and bounding GET /downloads growth). The byte budget remains the disk quota per the limits & restrictions design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(downloads): sync API doc with v2 fixes; HEAD on file route; Android handoff Document the contract changes from the review fixes: batch-manifest skipped[] shape, honest subscription 'registered' semantics, season 0 = Specials, always-array quality_presets, bytes_sent actual behavior, ephemeral 7-day retention, header-pairing requirement, progress-delta deletion caveat, and the ready/failed push event schema (new §9.4). Add an Android client handoff section (§11) mirroring the Apple one, register HEAD on /downloads/{id}/file for download stacks that probe before ranged GETs, and add season_number to the web create-request type. Flag the /direct-download session-token-in-URL tradeoff; a short-lived download-scoped URL is a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: consolidate download/progress helpers, prune dead code, gate sweeps Behavior-preserving consolidation from the Downloads V2 review: - appendVideoFilterArgs: one home for the burn-in/hwaccel -vf selection, shared by the HLS builder and the single-file prepare builder (the drift pattern that already bit tone-mapping once). - userstore.ResolveProgressState: one home for the min-resume/watched threshold rule, replacing five identical copies across both store backends and the offline-sync ingest. - Download file selection ranks resolutions via access.CompareQuality (adds 4320p, agrees with playback) instead of a private switch. - writeSubtitle uses the shared subtitles.SubtitleContentType mapping. - config.DefaultTranscodeDir replaces three '/tmp/silo-transcode' literals. - Read-side quality/revision defaulting helpers removed: insertArgs plus the NOT NULL/CHECK schema already guarantee the invariant. - Dead code removed: Repository.ListByUser, SubscriptionRepository. ListActiveBySeries, and the stale auto-register-worker comments (the design is client-pull; no worker exists). - Redundant left-prefix indexes dropped from the base migrations (their unique indexes serve the same prefixes). - recover()'s disk-presence sweep and the stale-row hygiene sweep run on startup then hourly instead of every 30s tick (both are O(cache size)). - gofmt/prettier fixes for pre-existing drift in handlers/playback.go and pages/Profiles.tsx. Deferred (noted for follow-ups): quality-ladder preset table collides with the drafted download limits & restrictions design, which specifies its own ladder helper; Download-literal construction consolidation and the managed-identity value object remain open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(downloads): draft download limits & restrictions design Design input for the follow-up v1 capability proposal (quality ceiling, batch size cap, per-user quantity/bandwidth overrides). Committed with downloads v2 because the remediation work explicitly defers the quality ladder refactor and revocation wiring to this spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(progress): reject malformed updated_at; clamp negative progress inputs Review findings on #258: - A malformed (non-RFC3339) updated_at in POST /sync/progress previously parsed to the zero time, which clampEventAt treated as "now" — letting a stale offline event win LWW as a fresh server-time write. The item is now rejected with a per-item error instead. - ResolveProgressState now clamps negative position/duration before classification so no backend can persist negative progress through UpdateProgress/SetProgress. - The online-write event_at invariant test is table-driven over both SetProgress and UpdateProgress, which share the same contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): close review gaps — permission gates, file-access recheck, artifact-true manifests Review findings on #258: - UpdateSubscription now applies the same feature/DownloadAllowed gate as CreateSubscription and SyncSubscriptions; a PATCH could previously re-activate or widen a monitor and register managed rows after an admin disabled downloads or revoked the user. - Serving download bytes (managed and ephemeral) and /direct-download now mirror playback's per-file authorization via catalog.FileAllowedByAccess: library scope and the profile's max playback quality are re-checked at serve time, with artifact-backed rows checked against the artifact's resolution (a 720p transcode of a 4K source stays servable under a 1080p ceiling). - Offline manifests for remux/transcode entries now describe the prepared artifact (container, codecs, resolution, single selected audio track) instead of the catalog source file the client never receives. - ArtifactRepository.Requeue reports ErrNotFound when the row was concurrently swept; ArtifactManager.Ensure recreates the job in that case instead of linking downloads to a dead artifact id. - "No downloadable episodes" is a sentinel (mapped to 404 no_downloadable_episodes) rather than a bare error that surfaced as 500. - Subscription season_numbers are bounds-checked (0–9999) before the int32 narrowing in the repo could silently wrap them. - HandlePatchDownload reuses requireManaged instead of hand-rolling the same managed-identity checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1172 lines
45 KiB
Markdown
1172 lines
45 KiB
Markdown
# Downloads & Offline Sync API (client integration guide)
|
||
|
||
This is the client-facing integration guide for downloads v2 / offline sync. It is
|
||
the contract the Apple (`silo-apple`) and Android (`silo-android`) apps should use
|
||
to download movies and episodes for fully offline playback and reconcile watch
|
||
state after reconnect.
|
||
|
||
It documents the current HTTP contract implemented by this server. Design rationale
|
||
and server internals live in
|
||
[`docs/superpowers/specs/2026-06-18-offline-sync-mobile-design.md`](superpowers/specs/2026-06-18-offline-sync-mobile-design.md).
|
||
|
||
> All endpoints are under `/api/v1`. Examples use `https://your-server` as the origin.
|
||
|
||
---
|
||
|
||
## 1. Concepts
|
||
|
||
Downloads v2 has three pillars:
|
||
|
||
1. **Device-scoped download registry.** The server tracks what each device has
|
||
registered, what media file was selected, whether an artifact is still preparing,
|
||
and whether the client confirmed local completion.
|
||
2. **Offline playback manifest.** One stable bundle per download containing metadata,
|
||
artwork references, subtitle references, chapters, markers, media stream details,
|
||
and integrity metadata. It contains no presigned or expiring URLs.
|
||
3. **Offline progress reconciliation.** Clients queue progress writes while offline,
|
||
flush them when online, then pull server-ordered deltas made by other devices.
|
||
|
||
### Two download row lifecycles
|
||
|
||
The `/downloads` family serves two lifecycles. The presence of
|
||
`X-Silo-Device-Id` selects the managed path.
|
||
|
||
| | Ephemeral / web row | Managed device entry |
|
||
| ------------------------------- | ---------------------------- | ---------------------------------- |
|
||
| Selected by | No `X-Silo-Device-Id` header | `X-Silo-Device-Id` header present |
|
||
| Scope | Account (`user_id`) | `(user_id, profile_id, device_id)` |
|
||
| Durable "device has this file"? | No | Yes |
|
||
| Manifest / artwork / subtitles | Not applicable | Yes |
|
||
| Progress reconciliation target | No | Yes |
|
||
| Intended clients | Web convenience download | Mobile / TV offline library |
|
||
|
||
Mobile clients should always send `X-Silo-Device-Id` and operate on managed entries.
|
||
|
||
Ephemeral rows are one-shot convenience records: the server prunes them
|
||
automatically about 7 days after their last update. Managed device entries are
|
||
never auto-pruned.
|
||
|
||
### Quality vs delivery format
|
||
|
||
Clients request a **quality preset**. The server records the concrete
|
||
**delivery format** it produced.
|
||
|
||
| Public quality | Meaning |
|
||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `original` | Prefer source quality. If device caps show the source cannot be delivered directly, the server may transparently prepare a compatibility artifact. |
|
||
| `20mbps` | Single-file transcode capped at about 20 Mbps. |
|
||
| `10mbps` | Single-file transcode capped at about 10 Mbps. |
|
||
| `5mbps` | Single-file transcode capped at about 5 Mbps. |
|
||
| `2mbps` | Single-file transcode capped at about 2 Mbps. |
|
||
| `1mbps` | Single-file transcode capped at about 1 Mbps. |
|
||
|
||
`remux` is **not** a public quality preset. It is an internal delivery format used
|
||
when `original` is requested but device caps show the source only needs container
|
||
or audio compatibility work. Rows expose both:
|
||
|
||
- `quality`: what the client requested.
|
||
- `effective_quality`: what the server actually delivered after compatibility fallback.
|
||
- `delivery_format`: `original`, `remux`, or `transcode`.
|
||
- `target_bitrate_kbps`: `0` for original/remux; bitrate cap for transcodes.
|
||
|
||
The ordered preset ladder is:
|
||
|
||
```
|
||
original > 20mbps > 10mbps > 5mbps > 2mbps > 1mbps
|
||
```
|
||
|
||
Series and season batch requests are original-quality only. If some episodes do
|
||
not have a local file, the batch response includes them in `skipped` rather than
|
||
failing the whole batch.
|
||
|
||
### Metadata included offline
|
||
|
||
Yes, manifests include metadata needed to make the offline item feel native:
|
||
|
||
- Title, year, overview, runtime, content rating, genres.
|
||
- Series, season, and episode context for episodes.
|
||
- Poster/backdrop thumbhashes and authenticated artwork proxy URLs for poster,
|
||
backdrop, and logo when available.
|
||
- Chapters, intro/credits/recap/preview markers.
|
||
- External and downloaded subtitle fetch URLs plus known subtitle file sizes.
|
||
- Container, codecs, resolution, HDR, duration, selected audio track, and audio
|
||
track inventory. For remux/transcode entries these describe the prepared
|
||
artifact the file endpoint actually delivers (single audio track, target
|
||
container/codecs), not the catalog source it was prepared from.
|
||
- Stable provider identity and integrity metadata for local validation/rescan recovery.
|
||
|
||
The client still needs to fetch artwork/subtitle bytes once while online and cache
|
||
them locally beside the media file and manifest.
|
||
|
||
### Key invariants
|
||
|
||
- **No DRM, expiry, or lease.** Already-downloaded files remain playable until the
|
||
user deletes them. The server can revoke future serves, not reach into a device.
|
||
- **Device authority is the header only.** A `device_id` in body/query is ignored.
|
||
- **Every managed asset re-checks profile access.** A download id alone never grants
|
||
content access.
|
||
- **Server-owned progress cursors.** `?since=` uses a server sequence, not a client
|
||
timestamp. Client timestamps are only last-write-wins inputs for that profile.
|
||
|
||
---
|
||
|
||
## 2. Authentication & headers
|
||
|
||
All endpoints require authentication. Managed operations require a profile and a
|
||
device id.
|
||
|
||
| Header | Required when | Notes |
|
||
| ------------------------------------ | -------------------------- | ----------------------------------------------------------- |
|
||
| `Authorization: Bearer <token>` | Always | JWT access token or API key (`sa_...`). |
|
||
| `X-Profile-Id: <profile_id>` | Managed ops, progress sync | Active household profile. |
|
||
| `X-Silo-Device-Id: <device_id>` | Managed downloads | Stable per-install UUID; its presence selects managed mode. |
|
||
| `X-Silo-Device-Name: <name>` | Optional | Display name, clamped server-side. |
|
||
| `X-Silo-Device-Platform: <platform>` | Optional | Example: `android`, `ios`, `tvos`. |
|
||
|
||
A managed call without `X-Silo-Device-Id` returns `400 device_id_required`; one
|
||
without profile scope returns `400 profile_required`.
|
||
|
||
> **Warning:** any client that sends `X-Silo-Device-Id` on download routes MUST
|
||
> also send `X-Profile-Id`. A device header without a profile is rejected with
|
||
> `400 profile_required`. The first-party web client sends both headers globally.
|
||
|
||
---
|
||
|
||
## 3. Feature detection
|
||
|
||
Call this at login and after profile switch. Do not sniff server versions.
|
||
|
||
```http
|
||
GET /api/v1/downloads/capability
|
||
```
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"enabled": true,
|
||
"download_allowed": true,
|
||
"quality_presets": [
|
||
"original",
|
||
"20mbps",
|
||
"10mbps",
|
||
"5mbps",
|
||
"2mbps",
|
||
"1mbps"
|
||
],
|
||
"transcode_enabled": true,
|
||
"transcode_user_allowed": true,
|
||
"season_download": true,
|
||
"series_monitoring": true,
|
||
"monitoring_modes": ["all", "future", "latest_season", "specific_seasons"]
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
| ------------------------ | --------------------------------------------------------------------------------- |
|
||
| `enabled` | Downloads feature is enabled on the server. |
|
||
| `download_allowed` | This user may download at all. |
|
||
| `quality_presets` | Ordered quality values this user may request now. Only offer values in this list. |
|
||
| `transcode_enabled` | Server-level transcode-to-file gate. |
|
||
| `transcode_user_allowed` | Per-user transcode-to-file permission. |
|
||
| `season_download` | Per-season batch downloads are available. |
|
||
| `series_monitoring` | Auto-download subscriptions are available. |
|
||
| `monitoring_modes` | Subscription modes the client may request. |
|
||
|
||
`quality_presets` is always an array — `[]` (never `null`) when downloads are
|
||
disabled or the user lacks download permission — so clients can rely on
|
||
`quality_presets.length === 0` meaning "downloads unavailable for this account."
|
||
|
||
If `enabled` or `download_allowed` is false, hide download actions.
|
||
|
||
---
|
||
|
||
## 4. Endpoint reference
|
||
|
||
### 4.1 Create a download
|
||
|
||
```http
|
||
POST /api/v1/downloads
|
||
```
|
||
|
||
Send `X-Silo-Device-Id` and `X-Profile-Id` for a managed entry.
|
||
|
||
Request body:
|
||
|
||
| Field | Type | Notes |
|
||
| --------------- | ------ | ---------------------------------------------------------------------------- |
|
||
| `content_id` | string | Required. Movie or series content id. |
|
||
| `episode_id` | string | Episode content id for an episode download. |
|
||
| `file_id` | int | Optional explicit media-file/version id. |
|
||
| `quality` | string | `original` by default, or one of `quality_presets`. |
|
||
| `series` | bool | `true` means download every episode of `content_id` at original quality. |
|
||
| `season_number` | int | With `series: true`, restrict to one season. `0` is the Specials season; negative values are rejected with `400`. Dispatch is on field presence: omit the field entirely for a whole-series download. |
|
||
| `caps` | object | Device decode capabilities. Important for `original` compatibility fallback. |
|
||
|
||
Capabilities mirror streaming playback caps:
|
||
|
||
```json
|
||
{
|
||
"caps": {
|
||
"codecs_video": ["h264", "hevc"],
|
||
"codecs_audio": ["aac", "ac3"],
|
||
"audio_passthrough_codecs": ["ac3", "eac3"],
|
||
"containers": ["mp4", "mkv"],
|
||
"max_resolution": "1080p",
|
||
"hdr": false
|
||
}
|
||
}
|
||
```
|
||
|
||
Single-item response (`202 Accepted`):
|
||
|
||
```json
|
||
{
|
||
"id": "dl_01H...",
|
||
"content_id": "mv_123",
|
||
"media_file_id": 4567,
|
||
"device_id": "device-uuid",
|
||
"file_size": 8589934592,
|
||
"bytes_sent": 0,
|
||
"kind": "queued",
|
||
"status": "ready",
|
||
"quality": "original",
|
||
"effective_quality": "original",
|
||
"delivery_format": "original",
|
||
"target_bitrate_kbps": 0,
|
||
"revision": 1,
|
||
"created_at": "2026-06-19T16:04:05Z"
|
||
}
|
||
```
|
||
|
||
Readiness behavior:
|
||
|
||
- Direct original rows are `ready` immediately.
|
||
- Compatibility remux or bitrate transcode rows are `preparing` until the artifact
|
||
completes, then `ready`.
|
||
- If an equivalent artifact already exists, the row may be `ready` immediately.
|
||
|
||
Series/season response (`202 Accepted`):
|
||
|
||
```json
|
||
{
|
||
"downloads": [
|
||
{
|
||
"id": "dl_...",
|
||
"batch_id": "b_...",
|
||
"content_id": "sr_123",
|
||
"episode_id": "ep_1",
|
||
"status": "ready",
|
||
"quality": "original",
|
||
"effective_quality": "original",
|
||
"delivery_format": "original",
|
||
"revision": 1
|
||
}
|
||
],
|
||
"skipped": [{ "episode_id": "ep_missing", "reason": "no_file" }]
|
||
}
|
||
```
|
||
|
||
Re-registering the same `(profile, device, content, episode)` is idempotent. If the
|
||
same entry is re-requested with a different quality or target, the server updates
|
||
the existing managed row, increments `revision`, and clients should replace the
|
||
local media + manifest for that row.
|
||
|
||
### 4.2 List downloads
|
||
|
||
```http
|
||
GET /api/v1/downloads
|
||
```
|
||
|
||
With `X-Silo-Device-Id`, returns that device's managed entries. Without it, returns
|
||
the user's ephemeral web rows.
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"downloads": [
|
||
/* download rows */
|
||
]
|
||
}
|
||
```
|
||
|
||
Use this to poll for `ready` and reconcile rows on app launch.
|
||
|
||
### 4.3 Confirm local state
|
||
|
||
```http
|
||
PATCH /api/v1/downloads/{id}
|
||
```
|
||
|
||
Managed-only. Body:
|
||
|
||
```json
|
||
{ "status": "downloading" }
|
||
```
|
||
|
||
or:
|
||
|
||
```json
|
||
{ "status": "completed" }
|
||
```
|
||
|
||
Returns `204 No Content`.
|
||
|
||
### 4.4 Delete a download
|
||
|
||
```http
|
||
DELETE /api/v1/downloads/{id}
|
||
```
|
||
|
||
Deletes the row owned by `(user, profile, header device)` and returns `204`. The
|
||
client is responsible for deleting local files.
|
||
|
||
### 4.5 Serve the media file
|
||
|
||
```http
|
||
GET /api/v1/downloads/{id}/file
|
||
HEAD /api/v1/downloads/{id}/file
|
||
```
|
||
|
||
Streams either the source file or the prepared artifact. Range requests are
|
||
supported for resumable/background downloads. `HEAD` is accepted like
|
||
`/direct-download`: it returns the same headers with no body so clients can
|
||
probe size and resumability before issuing ranged `GET`s.
|
||
|
||
Common responses:
|
||
|
||
- `200` or `206`: media bytes.
|
||
- `409 download_inactive`: row is revoked or otherwise not servable.
|
||
- `404 not_found`: row/content missing or outside profile access.
|
||
- A `preparing` artifact is not servable yet; wait for `ready`.
|
||
|
||
### 4.6 Offline manifest
|
||
|
||
```http
|
||
GET /api/v1/downloads/{id}/manifest
|
||
```
|
||
|
||
Managed-only. Fetch when the row reaches `ready`, store beside the media file, and
|
||
use it for offline playback UI.
|
||
|
||
### 4.7 Batch manifests
|
||
|
||
```http
|
||
GET /api/v1/downloads/batches/{batch_id}/manifests
|
||
```
|
||
|
||
Managed-only. Returns manifests for all ready/servable entries in a series or
|
||
season batch owned by the calling device.
|
||
|
||
```json
|
||
{
|
||
"manifests": [
|
||
/* OfflineManifest */
|
||
],
|
||
"skipped": [{ "download_id": "dl_...", "reason": "not_found" }]
|
||
}
|
||
```
|
||
|
||
One unbuildable episode (deleted from the catalog, access-filtered, revoked)
|
||
does not fail the whole batch: it lands in `skipped` and the remaining
|
||
manifests are still delivered. `skipped` is omitted when empty.
|
||
|
||
| Reason | Meaning |
|
||
| ----------- | -------------------------------------------------------------------- |
|
||
| `revoked` | The row is revoked and no longer servable. |
|
||
| `not_found` | The row or its content is missing or outside profile access. |
|
||
| `error` | The server failed to build this manifest; safe to retry later. |
|
||
|
||
Clients should drop or refresh local entries whose manifests come back
|
||
`not_found`.
|
||
|
||
Use this after a batch download if the client wants to fetch metadata for the
|
||
whole batch in one request.
|
||
|
||
### 4.8 Artwork proxy
|
||
|
||
```http
|
||
GET /api/v1/downloads/{id}/artwork/{kind}
|
||
```
|
||
|
||
`kind` is `poster`, `backdrop`, or `logo`. The manifest's `artwork_urls` point
|
||
here. Fetch each available image once while online and cache the bytes locally.
|
||
|
||
### 4.9 Subtitle proxy
|
||
|
||
```http
|
||
GET /api/v1/downloads/{id}/subtitles/{ref}
|
||
```
|
||
|
||
`ref` comes from `subtitles[].fetch_url` and encodes either `external:{index}` or
|
||
`downloaded:{id}`. Invalid refs return `400 invalid_subtitle_ref`.
|
||
|
||
### 4.10 Direct download
|
||
|
||
```http
|
||
GET /api/v1/direct-download?file_id={id}
|
||
HEAD /api/v1/direct-download?file_id={id}
|
||
```
|
||
|
||
Browser/web convenience path. It is synchronous and original-only. Mobile clients
|
||
should use managed `POST /downloads` plus `/downloads/{id}/file`.
|
||
|
||
For browser-friendly links, the endpoint accepts the session access token as a
|
||
`?token=` query parameter in place of the `Authorization` header.
|
||
|
||
> **Security note:** the query token is the session access token. Treat
|
||
> direct-download URLs as secrets — they end up in browser history and proxy
|
||
> logs. A short-lived download-scoped URL is a planned follow-up.
|
||
|
||
---
|
||
|
||
## 5. Download row shape
|
||
|
||
| Field | Type | Notes |
|
||
| --------------------- | ------ | ---------------------------------------------------------------------- |
|
||
| `id` | string | Opaque download id. |
|
||
| `content_id` | string | Movie or series id. |
|
||
| `episode_id` | string | Present for episode rows. |
|
||
| `batch_id` | string | Present for series/season batch members. |
|
||
| `device_id` | string | Present on managed entries. |
|
||
| `media_file_id` | int | Selected media file/version. |
|
||
| `file_size` | int64 | Bytes; may be an estimate while preparing. |
|
||
| `bytes_sent` | int64 | Set to `file_size` when an ephemeral row completes; not a live transfer counter. Managed rows report 0. |
|
||
| `kind` | string | `direct` or `queued`. |
|
||
| `status` | string | Lifecycle state. |
|
||
| `quality` | string | Requested public quality. |
|
||
| `effective_quality` | string | Actual quality delivered after compatibility fallback. |
|
||
| `delivery_format` | string | `original`, `remux`, or `transcode`. |
|
||
| `target_bitrate_kbps` | int | `0` for original/remux; bitrate cap for transcode. |
|
||
| `revision` | int | Increments when an existing managed row is replaced with a new target. |
|
||
| `created_at` | string | RFC3339. |
|
||
| `completed_at` | string | Present once completed. |
|
||
|
||
Managed lifecycle:
|
||
|
||
```text
|
||
original: ready -> downloading -> completed
|
||
compat/remux: preparing -> ready -> downloading -> completed
|
||
bitrate/transcode: preparing -> ready -> downloading -> completed
|
||
revoked: any -> revoked (reserved)
|
||
failed artifact job: preparing -> failed
|
||
```
|
||
|
||
Direct original rows are `ready` immediately; remux and transcode rows start at
|
||
`preparing` and become `ready` when the artifact completes. `failed` means the
|
||
artifact job exhausted its retries. `revoked` is reserved: nothing sets it
|
||
today, but an admin revoke flow is planned in a separate effort, so clients
|
||
must handle it. `downloading` and `completed` are set by the client via `PATCH`.
|
||
|
||
---
|
||
|
||
## 6. OfflineManifest shape
|
||
|
||
Manifests are stable and safe to persist offline.
|
||
|
||
```json
|
||
{
|
||
"download_id": "dl_...",
|
||
"content_id": "mv_123",
|
||
"episode_id": "",
|
||
"type": "movie",
|
||
"revision": 1,
|
||
"quality": "original",
|
||
"effective_quality": "original",
|
||
"delivery_format": "original",
|
||
"target_bitrate_kbps": 0,
|
||
"media_file_id": 4567,
|
||
"file_size": 8589934592,
|
||
|
||
"title": "Example Movie",
|
||
"year": 2024,
|
||
"overview": "...",
|
||
"runtime": 7200,
|
||
"content_rating": "PG-13",
|
||
"genres": ["Drama"],
|
||
"series_id": "",
|
||
"series_title": "",
|
||
"season_number": null,
|
||
"episode_number": null,
|
||
|
||
"poster_thumbhash": "iQ...",
|
||
"backdrop_thumbhash": "iA...",
|
||
"artwork_urls": {
|
||
"poster": "/api/v1/downloads/dl_.../artwork/poster",
|
||
"backdrop": "/api/v1/downloads/dl_.../artwork/backdrop",
|
||
"logo": "/api/v1/downloads/dl_.../artwork/logo"
|
||
},
|
||
|
||
"container": "mp4",
|
||
"codec_video": "h264",
|
||
"codec_audio": "aac",
|
||
"resolution": "1080p",
|
||
"hdr": false,
|
||
"duration_seconds": 7200,
|
||
"selected_audio_track_index": 0,
|
||
"audio_tracks": [
|
||
{
|
||
"index": 0,
|
||
"language": "en",
|
||
"codec": "aac",
|
||
"channels": 6,
|
||
"default": true
|
||
}
|
||
],
|
||
|
||
"chapters": [
|
||
{
|
||
"index": 0,
|
||
"title": "Cold Open",
|
||
"start_seconds": 0,
|
||
"end_seconds": 142.5,
|
||
"thumbnail_thumbhash": "iC..."
|
||
}
|
||
],
|
||
"intro": { "start": 60.0, "end": 90.0 },
|
||
"credits": { "start": 7100.0, "end": 7200.0 },
|
||
"recap": null,
|
||
"preview": null,
|
||
|
||
"subtitles": [
|
||
{
|
||
"language": "en",
|
||
"format": "srt",
|
||
"forced": false,
|
||
"hearing_impaired": false,
|
||
"external": true,
|
||
"fetch_url": "/api/v1/downloads/dl_.../subtitles/external:0",
|
||
"file_size": 41234
|
||
}
|
||
],
|
||
|
||
"stable_identity": {
|
||
"stable_type": "movie",
|
||
"provider_ids": { "tmdb": "12345", "imdb": "tt1234567" },
|
||
"season": null,
|
||
"episode": null
|
||
},
|
||
"integrity": {
|
||
"expected_bytes": 8589934592,
|
||
"media_file_hash": "sha256-or-scanner-hash",
|
||
"metadata_etag": "opaque-server-value"
|
||
},
|
||
|
||
"manifest_version": 2,
|
||
"generated_at": "2026-06-19T16:05:00Z"
|
||
}
|
||
```
|
||
|
||
Notes:
|
||
|
||
- Artwork and subtitle URLs are authenticated proxy paths on this server. Fetch
|
||
them once while online and cache the bytes locally.
|
||
- Thumbhash fields are inline placeholders for fast offline UI rendering.
|
||
- `stable_identity` is for rescan recovery when a server-side `content_id` changes.
|
||
- `integrity.expected_bytes` should match the local media file size after download.
|
||
- `revision` should match the download row revision. If a row revision increases,
|
||
refresh the media file and manifest.
|
||
- Optional fields are omitted when empty; clients should treat absent values as
|
||
"not set."
|
||
|
||
---
|
||
|
||
## 7. Progress reconciliation
|
||
|
||
### 7.1 Flush queued progress
|
||
|
||
```http
|
||
POST /api/v1/sync/progress
|
||
```
|
||
|
||
Requires `X-Profile-Id`. Include `updated_at` for offline queued events.
|
||
|
||
```json
|
||
{
|
||
"items": [
|
||
{
|
||
"media_item_id": "mv_123",
|
||
"position": 1830.5,
|
||
"duration": 7200,
|
||
"updated_at": "2026-06-19T14:55:12Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"results": [{ "media_item_id": "mv_123", "status": "ok" }]
|
||
}
|
||
```
|
||
|
||
Server behavior:
|
||
|
||
- `updated_at` is clamped to `server_now + 2m`. A malformed (non-RFC3339)
|
||
`updated_at` fails that item with `updated_at must be RFC3339`; it is never
|
||
treated as "now".
|
||
- Completion is calculated by server watched-threshold logic.
|
||
- Completed is a one-way latch; a lower later position does not unwatch an item.
|
||
|
||
### 7.2 Pull deltas
|
||
|
||
```http
|
||
GET /api/v1/progress?since={cursor}
|
||
```
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"progress": [
|
||
{
|
||
"media_item_id": "mv_123",
|
||
"position_seconds": 1830.5,
|
||
"duration_seconds": 7200,
|
||
"completed": false,
|
||
"updated_at": "2026-06-19T14:55:12Z"
|
||
}
|
||
],
|
||
"next_cursor": "opaque"
|
||
}
|
||
```
|
||
|
||
Persist `next_cursor` and pass it as `since` next time. Treat it as opaque.
|
||
|
||
Row deletions (for example, dismissing an item from Continue Watching) do not
|
||
currently produce delta entries, so an offline device's cached resume point for
|
||
a deleted row goes stale until a full (cursor-less) refetch; clients should
|
||
treat the full snapshot as authoritative for removals.
|
||
|
||
---
|
||
|
||
## 8. Series monitoring
|
||
|
||
Series monitoring is a device-scoped opt-in to keep a series downloaded on this
|
||
device. It is client-driven: there is no server background worker. Call sync on
|
||
app open or background refresh, then pull rows from `GET /downloads`.
|
||
|
||
All subscription endpoints are managed-only.
|
||
|
||
### 8.1 Create
|
||
|
||
```http
|
||
POST /api/v1/downloads/subscriptions
|
||
```
|
||
|
||
```json
|
||
{
|
||
"series_id": "sr_55",
|
||
"mode": "latest_season",
|
||
"delete_watched": true,
|
||
"max_storage_bytes": 21474836480
|
||
}
|
||
```
|
||
|
||
| Field | Type | Notes |
|
||
| ------------------- | ------ | ----------------------------------------------------------------------------------- |
|
||
| `series_id` | string | Required. |
|
||
| `mode` | string | `all`, `future`, `latest_season`, or `specific_seasons`. |
|
||
| `season_numbers` | int[] | Required for `specific_seasons`. |
|
||
| `delete_watched` | bool | Client-enforced retention hint. |
|
||
| `max_storage_bytes` | int64 | `0` means unlimited. Client-enforced hard cap; server soft-gates auto-registration. |
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"subscription": {
|
||
/* subscription */
|
||
},
|
||
"registered": 12
|
||
}
|
||
```
|
||
|
||
### 8.2 Sync
|
||
|
||
```http
|
||
POST /api/v1/downloads/subscriptions/sync
|
||
```
|
||
|
||
Registers newly in-scope episodes across this device's subscriptions.
|
||
|
||
```json
|
||
{ "registered": 3 }
|
||
```
|
||
|
||
`registered` counts only episodes newly registered by this sync call; a
|
||
steady-state sync returns `{ "registered": 0 }`. Clients can skip refetching
|
||
`GET /downloads` when it is `0`.
|
||
|
||
### 8.3 List, get, update, delete
|
||
|
||
```http
|
||
GET /api/v1/downloads/subscriptions
|
||
GET /api/v1/downloads/subscriptions/{id}
|
||
PATCH /api/v1/downloads/subscriptions/{id}
|
||
DELETE /api/v1/downloads/subscriptions/{id}
|
||
```
|
||
|
||
`PATCH` is partial:
|
||
|
||
```json
|
||
{ "mode": "specific_seasons", "season_numbers": [2, 3], "active": true }
|
||
```
|
||
|
||
Subscription shape:
|
||
|
||
```json
|
||
{
|
||
"id": "sub_...",
|
||
"series_id": "sr_55",
|
||
"mode": "latest_season",
|
||
"target_season": 4,
|
||
"delete_watched": true,
|
||
"max_storage_bytes": 21474836480,
|
||
"active": true,
|
||
"created_at": "2026-06-19T16:00:00Z",
|
||
"updated_at": "2026-06-19T16:00:00Z"
|
||
}
|
||
```
|
||
|
||
Deleting a subscription stops future auto-registration. It does not delete local
|
||
files or existing download rows.
|
||
|
||
---
|
||
|
||
## 9. Recommended client strategy
|
||
|
||
### 9.1 Download one title
|
||
|
||
1. Call `GET /downloads/capability` and offer only `quality_presets`.
|
||
2. User picks Download: `POST /downloads` with `quality`, `caps`, profile, and device headers.
|
||
3. If the row is `preparing`, poll `GET /downloads` or listen on events (see 9.4) until `ready`.
|
||
4. Fetch and store `GET /downloads/{id}/manifest`.
|
||
5. Fetch and store all `artwork_urls` and `subtitles[].fetch_url` assets.
|
||
6. Download `GET /downloads/{id}/file` with Range/background support.
|
||
7. `PATCH /downloads/{id}` to `downloading` on start and `completed` on finish.
|
||
8. Play the local media file using the stored manifest.
|
||
|
||
### 9.2 Offline to online
|
||
|
||
1. While offline, queue `{media_item_id, position, duration, updated_at}` locally.
|
||
2. On reconnect, `POST /sync/progress`.
|
||
3. `GET /progress?since=<saved_cursor>` and save the returned `next_cursor`.
|
||
4. `POST /downloads/subscriptions/sync`.
|
||
5. If the sync response has `registered > 0`, `GET /downloads` to find the newly
|
||
registered rows.
|
||
|
||
### 9.3 Robustness rules
|
||
|
||
- Re-check capability on profile switch.
|
||
- Keep already-downloaded files playable after `revoked` or `download_inactive`.
|
||
- Retry `POST /downloads`; registration is idempotent.
|
||
- If `revision` changes for an existing row, replace local media and manifest.
|
||
- Enforce subscription storage caps locally; the server only soft-gates.
|
||
- Treat `content_id` as rescan-sensitive; use `stable_identity` to recover.
|
||
|
||
### 9.4 Ready/failed push events
|
||
|
||
When an artifact completes or fails, the server publishes an event on the
|
||
existing user-state events channel (the SSE/WebSocket events hub), scoped to
|
||
the owning `(user, profile)`. The event type is `download` and the payload is:
|
||
|
||
```json
|
||
{
|
||
"download_id": "dl_...",
|
||
"status": "ready",
|
||
"media_item_id": "mv_123",
|
||
"format": "remux"
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
| --------------- | ------------------------------------------------------------- |
|
||
| `download_id` | The download row id. |
|
||
| `status` | `ready` or `failed`. |
|
||
| `media_item_id` | The row's content id. |
|
||
| `format` | Delivery format: `original`, `remux`, or `transcode`. |
|
||
|
||
Clients that hold an events connection can use this instead of polling
|
||
`GET /downloads` for `preparing` rows; polling remains the fallback.
|
||
|
||
---
|
||
|
||
## 10. Apple client implementation notes
|
||
|
||
This section is the handoff checklist for `silo-apple` across iOS, iPadOS, tvOS,
|
||
and macOS. Use the same HTTP contract above; these notes only pin the Apple-side
|
||
storage, background transfer, and playback choices.
|
||
|
||
### 10.1 Required local state
|
||
|
||
Persist these records in the app's local database:
|
||
|
||
| Local model | Required fields |
|
||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `OfflineDownload` | `download_id`, `content_id`, `episode_id`, `batch_id`, `quality`, `effective_quality`, `delivery_format`, `target_bitrate_kbps`, `revision`, `status`, local media path, local manifest path, byte count, created/updated timestamps. |
|
||
| `OfflineAsset` | `download_id`, asset kind (`media`, `poster`, `backdrop`, `logo`, `subtitle`), remote proxy path, local path, expected bytes if known, fetch status. |
|
||
| `OfflineProgressEvent` | `media_item_id`, `position`, `duration`, `updated_at`, retry/ack state. |
|
||
| `DownloadSubscription` | Server subscription id, `series_id`, mode, season filters, retention settings, active state. |
|
||
|
||
Use the server `download_id` as the durable primary key for a downloaded item.
|
||
When a listed row has the same `download_id` but a larger `revision`, treat the
|
||
local media file, manifest, artwork, and subtitles as stale and re-fetch them.
|
||
|
||
### 10.2 Device identity and headers
|
||
|
||
Every managed request must include:
|
||
|
||
```http
|
||
Authorization: Bearer <access_token>
|
||
X-Profile-Id: <active_profile_id>
|
||
X-Silo-Device-Id: <stable_install_id>
|
||
X-Silo-Device-Name: <user_visible_device_name>
|
||
X-Silo-Device-Platform: ios
|
||
```
|
||
|
||
Recommended device id behavior:
|
||
|
||
- iOS/tvOS: use `UIDevice.identifierForVendor` when available, but persist the
|
||
first value the app uses so the server sees a stable install id.
|
||
- macOS: generate a UUID once and persist it in the app's container/keychain.
|
||
- Do not send device id in JSON bodies or query strings; the server ignores it.
|
||
|
||
Use the platform value that matches the target (`ios`, `tvos`, or `macos`).
|
||
|
||
### 10.3 Capability and quality UI
|
||
|
||
On login, profile switch, and app foreground:
|
||
|
||
1. `GET /downloads/capability`.
|
||
2. Hide download actions unless `enabled && download_allowed`.
|
||
3. Offer only `quality_presets`, in the order returned by the server.
|
||
4. Label `original` as Original. Label bitrate presets as `20 Mbps`, `10 Mbps`,
|
||
`5 Mbps`, `2 Mbps`, and `1 Mbps`.
|
||
5. Do not expose Remux. If the server chooses remux for compatibility, show that
|
||
only in diagnostics/detail UI via `delivery_format`.
|
||
|
||
### 10.4 Suggested Apple decode caps
|
||
|
||
Send `caps` on create so `original` can fall back to a compatibility artifact
|
||
when needed. Start conservative and refine per device/OS if the Apple app already
|
||
has richer playback capability detection.
|
||
|
||
```json
|
||
{
|
||
"caps": {
|
||
"codecs_video": ["h264", "hevc"],
|
||
"codecs_audio": ["aac", "ac3", "eac3"],
|
||
"audio_passthrough_codecs": ["ac3", "eac3"],
|
||
"containers": ["mp4", "mov", "m4v"],
|
||
"max_resolution": "1080p",
|
||
"hdr": false
|
||
}
|
||
}
|
||
```
|
||
|
||
Use `max_resolution` and `hdr` from actual device/display capability where known.
|
||
For Apple TV 4K or modern HDR-capable devices, the client may advertise `4k` and
|
||
`hdr: true`; older phones/tablets should stay conservative. These caps affect
|
||
only server-side compatibility decisions and bitrate transcode targets.
|
||
|
||
### 10.5 Download orchestration
|
||
|
||
For a single movie or episode:
|
||
|
||
1. `POST /downloads` with `quality`, `caps`, and managed headers.
|
||
2. Store the returned row immediately.
|
||
3. If `status == "preparing"`, keep polling `GET /downloads` or consume server
|
||
events until the row becomes `ready` or `failed`.
|
||
4. Once `ready`, fetch the manifest.
|
||
5. Queue artwork/subtitle asset downloads from the manifest.
|
||
6. Download `/downloads/{id}/file` with a background `URLSession`.
|
||
7. Patch `downloading` when the media transfer starts, and `completed` only after
|
||
the media file and required manifest/assets have been moved into durable local
|
||
storage.
|
||
|
||
For series or season download:
|
||
|
||
1. `POST /downloads` with `series: true`, optional `season_number`, and
|
||
`quality: "original"`.
|
||
2. Persist each returned row under the shared `batch_id`.
|
||
3. Record `skipped` entries for user-visible diagnostics.
|
||
4. Fetch `GET /downloads/batches/{batch_id}/manifests` after rows are ready, or
|
||
fetch individual manifests if the client is processing rows one at a time.
|
||
5. Handle `skipped` entries in the manifests response: drop or refresh local
|
||
entries whose reason is `not_found`; retry later for `error`.
|
||
|
||
### 10.6 Background transfers
|
||
|
||
Use a background `URLSessionConfiguration` for media files so downloads can
|
||
continue across app suspension. Keep artwork and subtitles in the same queue or a
|
||
separate foreground queue; media bytes are the only large transfer.
|
||
|
||
Recommended transfer behavior:
|
||
|
||
- Always use the authenticated `/downloads/{id}/file` URL, not `direct-download`.
|
||
- Resume using HTTP Range support when the platform gives resume data.
|
||
- Move finished temporary files into the app's Application Support container.
|
||
- Avoid Caches for media and manifests; iOS may purge it.
|
||
- Mark local DB state after the file move succeeds, not when the transfer
|
||
callback first fires.
|
||
- If auth expires before a queued background request starts, recreate the request
|
||
with a fresh token and resume the transfer.
|
||
|
||
### 10.7 Local file layout
|
||
|
||
Suggested layout inside Application Support:
|
||
|
||
```text
|
||
OfflineDownloads/
|
||
<download_id>/
|
||
manifest.json
|
||
media.mp4
|
||
artwork/
|
||
poster
|
||
backdrop
|
||
logo
|
||
subtitles/
|
||
external-0.srt
|
||
downloaded-123.vtt
|
||
```
|
||
|
||
The media extension may be `.mp4` for prepared artifacts and may reflect the
|
||
source file extension for direct original delivery. The manifest's `container`
|
||
and the response `Content-Type` are better playback hints than the filename.
|
||
|
||
### 10.8 Offline playback
|
||
|
||
When offline, build the playback screen from `manifest.json` and play the local
|
||
media file URL with AVFoundation. Do not call server artwork/subtitle URLs during
|
||
offline playback; those URLs are fetch-once online proxy paths.
|
||
|
||
Use manifest fields as follows:
|
||
|
||
- Title/overview/year/rating/genres drive the detail header.
|
||
- `series_id`, `series_title`, `season_number`, and `episode_number` drive episode
|
||
grouping.
|
||
- `poster_thumbhash` and `backdrop_thumbhash` are placeholders while local artwork
|
||
bytes load.
|
||
- `chapters`, `intro`, `credits`, `recap`, and `preview` drive the same skip and
|
||
chapter UI as online playback.
|
||
- `audio_tracks` and `selected_audio_track_index` seed the audio-track picker when
|
||
the local player can expose matching tracks.
|
||
- External subtitles should be loaded from local cached subtitle files, not from
|
||
`fetch_url`.
|
||
|
||
### 10.9 Offline progress sync
|
||
|
||
Queue progress locally whenever playback stops, pauses for a meaningful interval,
|
||
or crosses the watched threshold:
|
||
|
||
```json
|
||
{
|
||
"media_item_id": "ep_88",
|
||
"position": 120.0,
|
||
"duration": 1500,
|
||
"updated_at": "2026-06-19T14:55:12Z"
|
||
}
|
||
```
|
||
|
||
On reconnect:
|
||
|
||
1. `POST /sync/progress` with queued events.
|
||
2. Delete events acknowledged as `ok`.
|
||
3. `GET /progress?since=<saved_cursor>`.
|
||
4. Apply remote deltas to local resume state and save `next_cursor`.
|
||
5. `POST /downloads/subscriptions/sync`.
|
||
6. If the sync response has `registered > 0`, `GET /downloads` to register the
|
||
new rows locally.
|
||
|
||
### 10.10 Retention and deletion
|
||
|
||
Deleting from the Apple offline library should:
|
||
|
||
1. Cancel any active `URLSessionTask` for that `download_id`.
|
||
2. Delete local media, manifest, artwork, and subtitle files.
|
||
3. Delete local DB rows.
|
||
4. Call `DELETE /downloads/{id}` while online, or queue that delete for the next
|
||
reconnect.
|
||
|
||
If the server later returns `revoked` or `download_inactive`, keep existing local
|
||
files playable but stop retrying server fetches for that row.
|
||
|
||
---
|
||
|
||
## 11. Android client implementation notes
|
||
|
||
This section is the handoff checklist for `silo-android` across phone, tablet,
|
||
and Android TV. Use the same HTTP contract above; these notes only pin the
|
||
Android-side identity, storage, transfer, and playback choices. The required
|
||
local state mirrors the Apple table in 10.1.
|
||
|
||
### 11.1 Device identity and headers
|
||
|
||
Every managed request must include:
|
||
|
||
```http
|
||
Authorization: Bearer <access_token>
|
||
X-Profile-Id: <active_profile_id>
|
||
X-Silo-Device-Id: <stable_install_id>
|
||
X-Silo-Device-Name: <user_visible_device_name>
|
||
X-Silo-Device-Platform: android
|
||
```
|
||
|
||
Recommended device id behavior:
|
||
|
||
- Generate a UUID once on first launch and persist it in app-private storage
|
||
(DataStore or equivalent); do not derive it from hardware identifiers.
|
||
- Remember the pairing rule from section 2: `X-Silo-Device-Id` without
|
||
`X-Profile-Id` is rejected with `400 profile_required`. Attach both headers to
|
||
every downloads call.
|
||
- Do not send device id in JSON bodies or query strings; the server ignores it.
|
||
|
||
### 11.2 Capability gating
|
||
|
||
On login, profile switch, and app start:
|
||
|
||
1. `GET /downloads/capability`.
|
||
2. Hide download actions unless `enabled && download_allowed`.
|
||
3. `quality_presets` is always an array; an empty array means downloads are
|
||
unavailable for this account, so hide the downloads UI.
|
||
4. Offer only `quality_presets`, in the order returned, with the same labeling
|
||
rules as 10.3 (Original plus `N Mbps`; never expose Remux).
|
||
|
||
### 11.3 Local storage
|
||
|
||
Persist the same records as 10.1 as Room tables: download rows (server fields
|
||
plus local paths and fetch status), per-download assets, queued progress events,
|
||
and subscriptions. Recommendations:
|
||
|
||
- Use the server `download_id` as the durable primary key. A larger `revision`
|
||
for the same `download_id` marks local media, manifest, artwork, and subtitles
|
||
stale.
|
||
- Store the manifest JSON verbatim beside the media file instead of exploding
|
||
every field into columns; parse it at playback time.
|
||
- Keep media, manifests, and cached assets in app-internal storage (`filesDir`),
|
||
never the cache directory, laid out per download id as in 10.7:
|
||
|
||
```text
|
||
offline_downloads/
|
||
<download_id>/
|
||
manifest.json
|
||
media.mp4
|
||
artwork/
|
||
poster
|
||
backdrop
|
||
logo
|
||
subtitles/
|
||
external-0.srt
|
||
downloaded-123.vtt
|
||
```
|
||
|
||
### 11.4 Download engine
|
||
|
||
Run media transfers as WorkManager-scheduled foreground work (a foreground
|
||
service with a progress notification), or the system `DownloadManager` if its
|
||
constraints fit the app:
|
||
|
||
1. `HEAD /downloads/{id}/file` first to probe size and resumability.
|
||
2. Download with ranged `GET`s; after process death or network loss, resume from
|
||
the last persisted offset with a `Range` header.
|
||
3. Verify the final byte count against the manifest's `integrity.expected_bytes`
|
||
before marking the row done locally.
|
||
4. Fetch artwork and subtitle assets once at download time from the manifest's
|
||
`artwork_urls` and `subtitles[].fetch_url`.
|
||
5. `PATCH` `downloading` when the media transfer starts and `completed` only
|
||
after the file and required assets are moved into durable storage.
|
||
6. If auth expires while a transfer is queued, recreate the request with a fresh
|
||
token and resume.
|
||
|
||
### 11.5 Offline playback
|
||
|
||
Play the local media file with ExoPlayer (Media3) and build the detail and
|
||
playback UI from the stored `manifest.json`, following the same field mapping as
|
||
10.8:
|
||
|
||
- Side-load cached subtitle files as local subtitle tracks; never call
|
||
`fetch_url` during offline playback.
|
||
- `chapters`, `intro`, `credits`, `recap`, and `preview` drive the same skip and
|
||
chapter UI as online playback.
|
||
- Thumbhash fields are placeholders while local artwork bytes load.
|
||
|
||
### 11.6 Offline progress queue
|
||
|
||
Queue watch events in Room whenever playback stops, pauses for a meaningful
|
||
interval, or crosses the watched threshold, recording the client event time. On
|
||
reconnect:
|
||
|
||
1. `POST /sync/progress` with `updated_at` per item; delete events acknowledged
|
||
as `ok`.
|
||
2. `GET /progress?since=<saved_cursor>` and persist `next_cursor` per profile.
|
||
3. Per the caveat in 7.2, row deletions produce no delta entries; periodically
|
||
run a full cursor-less refetch and treat that snapshot as authoritative for
|
||
removals.
|
||
|
||
### 11.7 Readiness: events and polling
|
||
|
||
While the app holds an events connection, act on `download` events (9.4) to move
|
||
rows out of `preparing`: start the transfer on `ready`, surface `failed` in the
|
||
downloads UI. Without an events connection, poll `GET /downloads` per 9.1.
|
||
|
||
### 11.8 Series monitoring
|
||
|
||
Call `POST /downloads/subscriptions/sync` on app open and from a periodic
|
||
WorkManager job. If the response has `registered > 0`, `GET /downloads` and
|
||
enqueue the newly registered rows. Enforce `delete_watched` and
|
||
`max_storage_bytes` locally; the server only soft-gates auto-registration (8.1).
|
||
|
||
---
|
||
|
||
## 12. Error code reference
|
||
|
||
Errors use a flat envelope:
|
||
|
||
```json
|
||
{ "error": "download_inactive", "message": "This download is no longer active" }
|
||
```
|
||
|
||
| HTTP | `error` | When |
|
||
| ---- | -------------------------- | ------------------------------------------------------------------------- |
|
||
| 400 | `bad_request` | Malformed body or missing required input. |
|
||
| 400 | `device_id_required` | Managed endpoint called without `X-Silo-Device-Id`. |
|
||
| 400 | `profile_required` | Managed endpoint called without profile scope. |
|
||
| 400 | `invalid_status` | Patch status is not `downloading` or `completed`. |
|
||
| 400 | `invalid_quality` | Unknown public `quality`. |
|
||
| 400 | `invalid_format` | Legacy/internal format value is invalid. |
|
||
| 400 | `invalid_subtitle_ref` | Subtitle ref is not `external:{i}` or `downloaded:{id}`. |
|
||
| 400 | `invalid_mode` | Unknown subscription mode. |
|
||
| 400 | `seasons_required` | `specific_seasons` without `season_numbers`. |
|
||
| 400 | `invalid_season_numbers` | A `season_numbers` value is outside `0–9999`. |
|
||
| 400 | `not_series` | Subscription target is not a series. |
|
||
| 401 | `unauthorized` | Missing or invalid auth. |
|
||
| 403 | `feature_disabled` | Downloads disabled on create paths. |
|
||
| 403 | `forbidden` | User not allowed to download. |
|
||
| 403 | `transcode_disabled` | Bitrate quality requested while transcode is disabled. |
|
||
| 404 | `no_downloadable_episodes` | Series/season download found no episode with a downloadable file. |
|
||
| 404 | `not_found` | Row/content/asset missing or outside profile access. |
|
||
| 409 | `download_inactive` | Row is revoked or not servable. |
|
||
| 429 | `download_limit_exceeded` | Concurrent download cap hit. |
|
||
| 429 | `download_quota_exceeded` | Period quota hit. |
|
||
| 500 | `internal_error` | Unexpected server error. |
|
||
| 501 | `quality_unavailable` | Requested quality cannot be produced right now. |
|
||
| 501 | `bulk_quality_unavailable` | Series/season batch requested a non-original quality. |
|
||
| 501 | `format_unavailable` | Legacy/internal non-original direct download or missing prepare pipeline. |
|
||
| 503 | `unavailable` | Downloads/offline assets/series monitoring service missing. |
|
||
|
||
Access denials intentionally surface as `404` on manifest, artwork, subtitle, and
|
||
file endpoints so ids do not reveal out-of-scope content.
|
||
|
||
---
|
||
|
||
## 13. Out of scope for v1
|
||
|
||
Cross-device download visibility, DRM/leases, cumulative per-user storage quotas,
|
||
and server-initiated deletion of client files remain out of scope. Artifact garbage
|
||
collection may remove server-side prepared files only when no managed row still
|
||
references them.
|