Files
silo-server/docs/downloads-api.md
9cae868a27 feat(downloads): offline sync for mobile — downloads v2 (#258)
* 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>
2026-07-01 22:05:36 -04:00

45 KiB
Raw Permalink Blame History

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.

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.

GET /api/v1/downloads/capability

Response:

{
  "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

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:

{
  "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):

{
  "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):

{
  "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

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:

{
  "downloads": [
    /* download rows */
  ]
}

Use this to poll for ready and reconcile rows on app launch.

4.3 Confirm local state

PATCH /api/v1/downloads/{id}

Managed-only. Body:

{ "status": "downloading" }

or:

{ "status": "completed" }

Returns 204 No Content.

4.4 Delete a download

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

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 GETs.

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

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

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.

{
  "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

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

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

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:

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.

{
  "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

POST /api/v1/sync/progress

Requires X-Profile-Id. Include updated_at for offline queued events.

{
  "items": [
    {
      "media_item_id": "mv_123",
      "position": 1830.5,
      "duration": 7200,
      "updated_at": "2026-06-19T14:55:12Z"
    }
  ]
}

Response:

{
  "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

GET /api/v1/progress?since={cursor}

Response:

{
  "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

POST /api/v1/downloads/subscriptions
{
  "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:

{
  "subscription": {
    /* subscription */
  },
  "registered": 12
}

8.2 Sync

POST /api/v1/downloads/subscriptions/sync

Registers newly in-scope episodes across this device's subscriptions.

{ "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

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:

{ "mode": "specific_seasons", "season_numbers": [2, 3], "active": true }

Subscription shape:

{
  "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.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:

{
  "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:

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.

{
  "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:

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:

{
  "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:

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:
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 GETs; 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:

{ "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 09999.
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.