Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd3d737a58 | ||
|
|
86a02a6f61 | ||
|
|
1cbb59d0fa | ||
|
|
874a26de0f | ||
|
|
0544213730 | ||
|
|
e05c30c637 | ||
|
|
7f6225bb11 | ||
|
|
977104eac5 | ||
|
|
dffa6ae34e | ||
|
|
6dd3f1a4ae | ||
|
|
b8500c33ca | ||
|
|
e2899ff619 | ||
|
|
b25ddf8329 | ||
|
|
20c6b146eb | ||
|
|
6e105af256 | ||
|
|
d5a8cd5294 | ||
|
|
e77d9e933c | ||
|
|
90bdbfeb84 | ||
|
|
c644f43a02 | ||
|
|
ceb3393992 | ||
|
|
383a2cce71 | ||
|
|
6b70edfa39 | ||
|
|
881c96864b | ||
|
|
f524c48d19 | ||
|
|
c26ab22809 | ||
|
|
0a3adf6552 | ||
|
|
35de68b589 | ||
|
|
6a3fcef165 | ||
|
|
fe11b2601d | ||
|
|
39efe308af |
+16
-4
@@ -2,9 +2,11 @@
|
||||
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
|
||||
|
||||
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
|
||||
# Linux/macOS example for enabling NVIDIA override:
|
||||
# Linux example for Intel/AMD VA-API or Intel Quick Sync:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.vaapi.yml
|
||||
# Linux example for NVIDIA NVENC:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
# Windows example:
|
||||
# Windows NVIDIA example:
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
|
||||
|
||||
# Required for docker compose: host path to your media library.
|
||||
@@ -27,6 +29,15 @@ POSTGRES_USER=silo
|
||||
POSTGRES_PASSWORD=silo
|
||||
POSTGRES_DB=silo
|
||||
POSTGRES_SHM_SIZE=8gb
|
||||
# POSTGRES_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Optional Meilisearch service. Generate a key, uncomment it, then start the
|
||||
# search profile with: docker compose --profile search up -d
|
||||
# MEILI_MASTER_KEY=replace-with-output-of-openssl-rand-hex-32
|
||||
# MEILISEARCH_IMAGE=getmeili/meilisearch:latest
|
||||
# MEILISEARCH_HOST=127.0.0.1
|
||||
# MEILISEARCH_PORT=7700
|
||||
|
||||
# PostgreSQL auto-tuning is enabled by default in docker-compose.yml. Silo
|
||||
# applies pgtune-style OLTP recommendations through ALTER SYSTEM at startup.
|
||||
@@ -40,7 +51,8 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# POSTGRES_TUNE_DB_SIZE=auto
|
||||
# POSTGRES_TUNE_CONNECTIONS=100
|
||||
|
||||
# Optional host port overrides for docker-compose.yml.
|
||||
# Optional host port overrides for docker-compose.yml. The container listeners
|
||||
# remain fixed, so these values only change the host side of each port mapping.
|
||||
# PORT=8090
|
||||
# Used only after Jellyfin-compatible app support is enabled by an admin.
|
||||
# JF_PORT=8096
|
||||
@@ -77,7 +89,7 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# Optional Redis override when running from source or pointing workers at an external Redis.
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Optional standalone worker identity settings for docker-compose.remote-transcode.yml.
|
||||
# Optional standalone worker identity settings for custom worker deployments.
|
||||
# NODE_NAME=transcode-01
|
||||
# NODE_URL=http://transcode-01.example.com:8082
|
||||
|
||||
|
||||
@@ -99,6 +99,14 @@ jobs:
|
||||
- name: Verify settings bindings are current
|
||||
run: make verify-settings-bindings
|
||||
|
||||
# The server defines the playback protocol and the clients prove
|
||||
# conformance against these fixtures. A contract change that does not
|
||||
# regenerate leaves every client testing against a stale body, so the
|
||||
# drift surfaces as a playback bug on three platforms rather than as a
|
||||
# failure here.
|
||||
- name: Verify playback protocol fixtures are current
|
||||
run: make verify-playback-fixtures
|
||||
|
||||
# Runs the settings-contract gate among everything else: the embedded
|
||||
# manifest must parse, satisfy its own schema, hold every structural
|
||||
# invariant, and agree with the live settings registry on keys and
|
||||
|
||||
@@ -82,3 +82,4 @@ docker-compose.local.yml
|
||||
.playwright-cli/
|
||||
output/
|
||||
/settingsgen
|
||||
/playbackfixtures
|
||||
|
||||
+16
-7
@@ -6,9 +6,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge requ
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go** 1.24+
|
||||
- **Bun** 1.0+
|
||||
- **PostgreSQL** 18+
|
||||
- **Go** 1.26.4+
|
||||
- **Node.js** 22+ with **pnpm** 10.32.1
|
||||
- **PostgreSQL** 18 with pgvector
|
||||
- **Redis**
|
||||
- **FFmpeg** (for transcoding support)
|
||||
|
||||
## Local Development
|
||||
@@ -16,6 +17,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge requ
|
||||
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) for local services and the source-build workflow below.
|
||||
|
||||
```sh
|
||||
# Create the local bootstrap configuration
|
||||
cp .env.example .env
|
||||
printf '\nSECRET_KEY=%s\nDATABASE_URL=%s\nREDIS_URL=%s\n' \
|
||||
"$(openssl rand -base64 48)" \
|
||||
'postgres://silo:silo@localhost:5432/silo?sslmode=disable' \
|
||||
'redis://localhost:6379' >> .env
|
||||
|
||||
# Start local PostgreSQL and Redis
|
||||
docker compose up -d postgres redis
|
||||
|
||||
@@ -26,7 +34,8 @@ make dev-frontend
|
||||
make dev-backend
|
||||
```
|
||||
|
||||
The main compose file expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first (`cp .env.example .env`).
|
||||
The template supplies a non-empty `MEDIA_ROOT` because Compose validates the whole file even when
|
||||
you start only PostgreSQL and Redis. Change it before testing libraries against real media.
|
||||
|
||||
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
|
||||
|
||||
@@ -79,7 +88,7 @@ database URL should be read from a non-default env file.
|
||||
go test ./...
|
||||
|
||||
# Frontend tests
|
||||
cd web && bun test
|
||||
cd web && pnpm test
|
||||
```
|
||||
|
||||
## Linting
|
||||
@@ -89,8 +98,8 @@ cd web && bun test
|
||||
golangci-lint run
|
||||
|
||||
# Frontend
|
||||
cd web && bun run lint
|
||||
cd web && bun run format:check
|
||||
cd web && pnpm run lint
|
||||
cd web && pnpm run format:check
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint test test-go test-web embed-stub clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up migrate-down-to settings-bindings verify-settings-bindings verify-settings-bindings-web verify-settings-bindings-all
|
||||
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint test test-go test-web embed-stub clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up migrate-down-to settings-bindings verify-settings-bindings verify-settings-bindings-web verify-settings-bindings-all playback-fixtures verify-playback-fixtures
|
||||
|
||||
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
|
||||
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
|
||||
@@ -62,7 +62,6 @@ WEBTEST_KNOWN_FAILURES := \
|
||||
--exclude src/pages/Catalog.test.tsx \
|
||||
--exclude src/pages/ItemDetail/SeasonContent.test.tsx \
|
||||
--exclude src/pages/LibraryRecommended.test.tsx \
|
||||
--exclude src/pages/audiobooks/player/useAudiobookPlayback.test.ts \
|
||||
--exclude src/pages/setup-wizard/steps/ServerStorageStep.test.tsx \
|
||||
--exclude src/player/hooks/useASSSubtitles.test.tsx
|
||||
|
||||
@@ -145,6 +144,36 @@ verify-settings-bindings-web:
|
||||
|
||||
verify-settings-bindings-all: verify-settings-bindings verify-settings-bindings-web
|
||||
|
||||
# Regenerate the protocol-v3 golden contract fixtures from the live types and planner.
|
||||
#
|
||||
# The server owns the playback contract and the clients prove conformance
|
||||
# against these bodies, so they are only trustworthy while the code that emits
|
||||
# them is the code that serves traffic. Editing one by hand instead of running
|
||||
# this would let the contract and the implementation drift apart in silence.
|
||||
PLAYBACK_FIXTURE_DIR := internal/playback/testdata/protocol_v3
|
||||
PLAYBACK_SCHEMA_FIXTURE_DIR := docs/design/schemas/playback-v3/v3/fixtures/valid
|
||||
PLAYBACK_WIRE_FIXTURES := start_request.json replan_request.json decision_response.json capability_response.json error_response.json route_event.json
|
||||
|
||||
playback-fixtures:
|
||||
go run ./cmd/playbackfixtures -out $(PLAYBACK_FIXTURE_DIR)
|
||||
@set -e; for fixture in $(PLAYBACK_WIRE_FIXTURES); do \
|
||||
cp "$(PLAYBACK_FIXTURE_DIR)/$$fixture" "$(PLAYBACK_SCHEMA_FIXTURE_DIR)/$$fixture"; \
|
||||
done
|
||||
|
||||
# Fail when the committed fixtures disagree with the contract types. A change
|
||||
# that does not regenerate leaves every client testing against a body the server
|
||||
# no longer produces, which is exactly the drift the fixtures exist to catch.
|
||||
verify-playback-fixtures:
|
||||
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
|
||||
go run ./cmd/playbackfixtures -out "$$CHECK_DIR" && \
|
||||
diff -ur $(PLAYBACK_FIXTURE_DIR) "$$CHECK_DIR" \
|
||||
|| { echo "::error::$(PLAYBACK_FIXTURE_DIR) is stale; run make playback-fixtures"; exit 1; }; \
|
||||
for fixture in $(PLAYBACK_WIRE_FIXTURES); do \
|
||||
cmp -s "$$CHECK_DIR/$$fixture" "$(PLAYBACK_SCHEMA_FIXTURE_DIR)/$$fixture" \
|
||||
|| { echo "::error::$(PLAYBACK_SCHEMA_FIXTURE_DIR)/$$fixture is stale; run make playback-fixtures"; exit 1; }; \
|
||||
done
|
||||
@echo "playback fixtures are current"
|
||||
|
||||
# Check committed content for local machine path leaks.
|
||||
verify-local-paths:
|
||||
scripts/check-local-path-leaks.sh
|
||||
|
||||
@@ -15,14 +15,22 @@ Join the community on [Discord](https://discord.gg/4RxuUQAEnW). If Silo is usefu
|
||||
|
||||
## Deploy with Docker (recommended)
|
||||
|
||||
The easiest way to run Silo is with Docker Compose. The default stack assumes you do not already have PostgreSQL and Redis available, so it bundles PostgreSQL, Redis, FFmpeg, and the application for a one-command start.
|
||||
The easiest way to run Silo is with Docker Compose 2.24 or newer. The default stack assumes you do
|
||||
not already have PostgreSQL and Redis available, so it bundles PostgreSQL, Redis, FFmpeg, and the
|
||||
application for a one-command start.
|
||||
|
||||
1. **Create a `.env` file**
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
printf '\nPOSTGRES_PASSWORD=%s\nSECRET_KEY=%s\n' \
|
||||
"$(openssl rand -hex 24)" "$(openssl rand -base64 48)" >> .env
|
||||
```
|
||||
|
||||
This replaces the development database password from `.env.example` and creates the key Silo
|
||||
uses to encrypt stored credentials. Back up `.env` separately from PostgreSQL; losing
|
||||
`SECRET_KEY` makes those credentials unrecoverable.
|
||||
|
||||
2. **Set your media path**
|
||||
|
||||
Edit `.env` and set:
|
||||
@@ -43,6 +51,21 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
|
||||
|
||||
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
|
||||
|
||||
### Optional Intel/AMD VA-API or Intel Quick Sync
|
||||
|
||||
The default stack is CPU-only so it starts on hosts without `/dev/dri`. On a Linux host with
|
||||
`/dev/dri`, enable the device overlay:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.vaapi.yml up -d
|
||||
```
|
||||
|
||||
To make that the default for this installation, set:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.vaapi.yml
|
||||
```
|
||||
|
||||
### Optional NVIDIA/NVENC
|
||||
|
||||
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
|
||||
@@ -78,32 +101,39 @@ By default, data is stored under `/opt/silo`:
|
||||
|
||||
- `/opt/silo/postgres`
|
||||
- `/opt/silo/redis`
|
||||
- `/opt/silo/plugins`
|
||||
- `/opt/silo/compat`
|
||||
- `/opt/silo/transcode`
|
||||
- `/opt/silo/catalog-seeds`
|
||||
|
||||
The optional `search` profile also stores its index under `/opt/silo/meilisearch`.
|
||||
|
||||
Media is mounted into the container at `/mnt/media` from the host path you set in `MEDIA_ROOT`.
|
||||
|
||||
### Optional Profiles
|
||||
### Optional Search Profile
|
||||
|
||||
The main compose file is integrated-first. These profiles exist for operators testing distributed mode or mirroring a split deployment shape. Most single-host installs should stay on the default integrated service, because it already includes proxying and transcoding.
|
||||
PostgreSQL full-text search works without any optional services. Meilisearch is available when you
|
||||
want its search provider:
|
||||
|
||||
| Profile | Command | Description |
|
||||
|---|---|---|
|
||||
| default | `docker compose up -d` | Integrated server plus bundled PostgreSQL and Redis |
|
||||
| `proxy` | `docker compose --profile proxy up -d` | Start a standalone proxy service for distributed-mode testing |
|
||||
| `transcode` | `docker compose --profile transcode up -d` | Start a standalone transcode service for distributed-mode testing |
|
||||
| `search` | `docker compose --profile search up -d` | Add the optional Meilisearch service |
|
||||
|
||||
You can enable both optional examples together:
|
||||
Before starting the `search` profile, set `MEILI_MASTER_KEY` in `.env` to the output of
|
||||
`openssl rand -hex 32`. After Silo starts, choose Meilisearch under **Admin > Settings > Search**,
|
||||
set the URL to `http://meilisearch:7700`, enter the same key as the API key, test the connection,
|
||||
and save. Restart Silo, then rebuild the catalog search index from the same page. Silo continues
|
||||
to use PostgreSQL full-text search until you select Meilisearch.
|
||||
|
||||
```sh
|
||||
docker compose --profile proxy --profile transcode up -d
|
||||
```
|
||||
### Distributed Examples
|
||||
|
||||
If you are splitting workers across multiple hosts, use the separate remote worker example instead of trying to stretch the main compose file across machines.
|
||||
The main Compose file includes commented proxy and transcode service examples. Most single-host
|
||||
installs should leave them commented because the integrated service already includes proxying and
|
||||
transcoding.
|
||||
|
||||
### Advanced Remote Node Example
|
||||
|
||||
For a dedicated remote transcode worker, use [docker-compose.remote-transcode.yml](docker-compose.remote-transcode.yml). That file is intended for a separate worker host that connects back to an existing Silo deployment using shared PostgreSQL and Redis.
|
||||
Multi-host operators can use those examples as a starting point for a dedicated worker Compose
|
||||
file connected to the deployment's shared PostgreSQL and Redis services.
|
||||
|
||||
### Deployment Notes
|
||||
|
||||
@@ -116,7 +146,10 @@ helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/co
|
||||
|
||||
## Configuration
|
||||
|
||||
Silo requires only a `DATABASE_URL` when running from source or against external infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs for you. All other settings — libraries, metadata providers, transcoding, users — are managed through the admin UI after first launch.
|
||||
Silo requires `DATABASE_URL` and `SECRET_KEY` when running from source or against external
|
||||
infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs
|
||||
for you. All other settings — libraries, metadata providers, transcoding, users — are managed
|
||||
through the admin UI after first launch.
|
||||
|
||||
### Server Modes
|
||||
|
||||
@@ -183,24 +216,26 @@ move fully to a custom `postgresql.conf`.
|
||||
|
||||
If you prefer running Silo without Docker:
|
||||
|
||||
1. **Install prerequisites**: Go 1.24+, Bun 1.0+, PostgreSQL 18+, and FFmpeg.
|
||||
1. **Install prerequisites**: Go 1.26.4+, Node.js 22+, pnpm 10.32.1, PostgreSQL 18 with pgvector, Redis, and FFmpeg.
|
||||
|
||||
2. **Start PostgreSQL and Redis** (skip if you already have them running)
|
||||
2. **Configure the source process**
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
printf '\nSECRET_KEY=%s\nDATABASE_URL=%s\nREDIS_URL=%s\n' \
|
||||
"$(openssl rand -base64 48)" \
|
||||
'postgres://silo:silo@localhost:5432/silo?sslmode=disable' \
|
||||
'redis://localhost:6379' >> .env
|
||||
```
|
||||
|
||||
Change the URLs when you use existing services instead of the bundled development defaults.
|
||||
|
||||
3. **Start PostgreSQL and Redis** (skip if you already have them running)
|
||||
|
||||
```sh
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
|
||||
|
||||
3. **Configure the database connection**
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
|
||||
|
||||
4. **Build and run**
|
||||
|
||||
```sh
|
||||
|
||||
@@ -0,0 +1,858 @@
|
||||
// Command playbackfixtures writes the protocol-v3 golden contract fixtures
|
||||
// from the live Go types and planner.
|
||||
//
|
||||
// The direction of authority is inverted from where it started: the server
|
||||
// defines the playback protocol, and clients prove conformance against these
|
||||
// files. Android and Apple CI vendor them and compare their own decoders and
|
||||
// opaque-token echo behavior against the values here, so a fixture is
|
||||
// only trustworthy if it was produced by the same code that serves real
|
||||
// traffic — hand-editing one would let the contract and the implementation
|
||||
// drift apart silently.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/playbackfixtures -out internal/playback/testdata/protocol_v3
|
||||
//
|
||||
// `make playback-fixtures` runs it; `make verify-playback-fixtures`
|
||||
// regenerates into a temporary directory and diffs, so a contract change
|
||||
// cannot merge without refreshing what every client tests against.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
// goldenSessionID is a fixed UUID: fixtures must be byte-stable across runs,
|
||||
// so nothing in this generator may read the clock or a random source.
|
||||
const (
|
||||
goldenSessionID = "11111111-1111-4111-8111-111111111111"
|
||||
goldenAttemptID = "attempt-golden-0001"
|
||||
goldenExpiresAt = "2030-01-01T00:00:00Z"
|
||||
goldenMediaFileID = 42
|
||||
// Codec and container tokens the fixtures are built from. Named so the
|
||||
// capability lists, the plan recipe, and the source descriptor cannot drift
|
||||
// into describing different media by a one-character typo.
|
||||
codecH264 = "h264"
|
||||
codecHEVC = "hevc"
|
||||
codecAAC = "aac"
|
||||
codecTrueHD = "truehd"
|
||||
containerMP4 = "mp4"
|
||||
containerMKV = "mkv"
|
||||
containerHLS = "hls"
|
||||
profileHigh = "high"
|
||||
resolutionHD = "720p"
|
||||
resolutionFHD = "1080p"
|
||||
qualityOriginal = "original"
|
||||
audioLayoutStereo = "stereo"
|
||||
audioLayout71 = "7.1"
|
||||
filmFrameRate = "24000/1001"
|
||||
languageEnglish = "eng"
|
||||
videoRangeSDR = "SDR"
|
||||
categoryMidSeek = "mid_seek_replan"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "", "directory to write fixtures into (required)")
|
||||
flag.Parse()
|
||||
if *out == "" {
|
||||
fail("usage: playbackfixtures -out <dir>")
|
||||
}
|
||||
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||
fail("create %s: %v", *out, err)
|
||||
}
|
||||
|
||||
write(*out, "start_request.json", goldenStartRequest())
|
||||
write(*out, "replan_request.json", goldenReplanRequest())
|
||||
write(*out, "decision_response.json", goldenDecisionResponse())
|
||||
write(*out, "capability_response.json", goldenCapabilityResponse())
|
||||
write(*out, "error_response.json", goldenErrorResponse())
|
||||
write(*out, "route_event.json", goldenRouteEvent())
|
||||
write(*out, "subtitle_inventory.json", goldenSubtitleInventory())
|
||||
write(*out, "attempt_keys.json", goldenAttemptKeys())
|
||||
write(*out, "conformance_matrix.json", goldenConformanceMatrix())
|
||||
}
|
||||
|
||||
func goldenErrorResponse() playback.ErrorResponseV3 {
|
||||
return playback.LegacyUpgradeErrorV3()
|
||||
}
|
||||
|
||||
func write(dir, name string, value any) {
|
||||
body, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
fail("marshal %s: %v", name, err)
|
||||
}
|
||||
body = append(body, '\n')
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil { //nolint:gosec // generated fixture
|
||||
fail("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// goldenCapabilities is the exact-evidence Android-shaped capability block the
|
||||
// request fixtures share. Both request bodies carry the same capability
|
||||
// contract, so they are built from one function rather than two drifting
|
||||
// literals.
|
||||
func goldenCapabilities() playback.ClientCodecCapabilitiesV3 {
|
||||
return playback.ClientCodecCapabilitiesV3{
|
||||
VideoEvidence: playback.EvidenceExactV3,
|
||||
AudioEvidence: playback.EvidenceExactV3,
|
||||
CodecsVideo: []string{codecH264},
|
||||
CodecsVideoHardware: []string{codecH264},
|
||||
CodecsAudio: []string{codecAAC},
|
||||
Containers: []string{containerMP4},
|
||||
MaxResolution: resolutionFHD,
|
||||
VideoDecode: []playback.VideoDecodeCapabilityV3{{
|
||||
Codec: codecH264,
|
||||
Profiles: []string{profileHigh},
|
||||
Levels: []int{41},
|
||||
BitDepths: []int{8},
|
||||
MaxWidth: 1920,
|
||||
MaxHeight: 1080,
|
||||
MaxFrameRate: 60,
|
||||
MaxBitrateKbps: 20_000,
|
||||
Hardware: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func goldenPlaybackContext() playback.ClientPlaybackContextV3 {
|
||||
return playback.ClientPlaybackContextV3{
|
||||
ProtocolVersion: playback.ProtocolV3,
|
||||
FormFactor: "tv",
|
||||
AppVersion: "3.0-test",
|
||||
Device: playback.DeviceContextV3{
|
||||
Platform: "android",
|
||||
OSVersion: "15",
|
||||
Manufacturer: "NVIDIA",
|
||||
Model: "SHIELD Android TV",
|
||||
// Everything platform-specific travels here as opaque bounded
|
||||
// strings; the contract itself stays neutral.
|
||||
PlatformDetails: map[string]string{"sdk_int": "35", "abis": "arm64-v8a"},
|
||||
},
|
||||
Output: playback.OutputContextV3{OutputContextID: "7"},
|
||||
Deliveries: map[string]playback.DeliveryCapabilityV3{
|
||||
playback.DeliveryClassOriginalHTTPV3: {
|
||||
Enabled: true,
|
||||
SupportedOnDevice: true,
|
||||
Containers: []string{containerMP4},
|
||||
VideoCodecs: []string{codecH264},
|
||||
AudioDecodeCodecs: []string{codecAAC},
|
||||
AudioPassthroughCodecs: []string{},
|
||||
Subtitles: playback.DeliverySubtitleCapabilitiesV3{
|
||||
EmbeddedText: true,
|
||||
SidecarText: true,
|
||||
},
|
||||
Features: []string{},
|
||||
AuthHeaderRefresh: true,
|
||||
ValidatedClaims: []string{},
|
||||
Transformations: []playback.TransformationV3{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func goldenStartRequest() playback.StartRequestV3 {
|
||||
start := 12.5
|
||||
audioIndex := 0
|
||||
return playback.StartRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3,
|
||||
ClientFeatures: []string{playback.FeaturePlaybackPlanV3},
|
||||
FileID: goldenMediaFileID,
|
||||
ProfileID: "profile-1",
|
||||
PlaybackAttemptID: goldenAttemptID,
|
||||
QualityPreference: playback.QualityOriginalV3,
|
||||
SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3,
|
||||
StartPosition: &start,
|
||||
ProgressPersistence: playback.ProgressPersistenceClientV3,
|
||||
AudioTrackID: playback.TrackIDV3(goldenMediaFileID, "audio", 0),
|
||||
AudioTrackIndex: &audioIndex,
|
||||
Capabilities: goldenCapabilities(),
|
||||
ClientPlaybackContext: goldenPlaybackContext(),
|
||||
}
|
||||
}
|
||||
|
||||
func goldenReplanRequest() playback.ReplanRequestV3 {
|
||||
estimate := 3_500
|
||||
cap := 4_000
|
||||
audioIndex := 0
|
||||
decision := goldenDecisionResponse()
|
||||
if decision.PlaybackPlan == nil {
|
||||
fail("golden decision has no plan")
|
||||
}
|
||||
plan := decision.PlaybackPlan
|
||||
return playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3,
|
||||
ClientFeatures: []string{playback.FeaturePlaybackPlanV3},
|
||||
Operation: playback.ReplanOperationFailureRecoveryV3,
|
||||
PlaybackAttemptID: goldenAttemptID,
|
||||
ReplanRequestID: "replan-golden-0001",
|
||||
FailedPlanID: plan.PlanID,
|
||||
PlanAttemptID: "plan-attempt-golden-0001",
|
||||
// Attempt keys are server-owned opaque tokens. A client echoes the
|
||||
// values it was handed; it never computes one.
|
||||
PlanAttemptKey: plan.PlanAttemptKey,
|
||||
AttemptedPlanKeys: []string{plan.PlanAttemptKey},
|
||||
AttemptCount: 1,
|
||||
QualityPreference: "auto",
|
||||
PositionSeconds: 42.5,
|
||||
Metered: true,
|
||||
BandwidthEstimateKbps: &estimate,
|
||||
BandwidthCapKbps: &cap,
|
||||
SelectedTracks: playback.SelectedTracksV3{
|
||||
Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(goldenMediaFileID, "audio", 0), Index: &audioIndex},
|
||||
},
|
||||
Failure: playback.FailureV3{Classification: "network_degraded"},
|
||||
Capabilities: goldenCapabilities(),
|
||||
ClientPlaybackContext: goldenPlaybackContext(),
|
||||
}
|
||||
}
|
||||
|
||||
// goldenMediaFile is the subtitle-bearing source the inventory fixtures
|
||||
// describe. The embedded track list deliberately mixes a text track, a PGS
|
||||
// track, and a DVD bitmap track: the DVD track has no sidecar shape the stream
|
||||
// handler can serve, and pinning its ordinal is the point of the fixture.
|
||||
func goldenMediaFile() *models.MediaFile {
|
||||
return &models.MediaFile{
|
||||
ID: goldenMediaFileID,
|
||||
ExternalSubtitles: []models.ExternalSubtitle{
|
||||
{Path: "/library/movie.en.srt", Language: languageEnglish, Format: "srt", Title: "English"},
|
||||
},
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: languageEnglish, Codec: "ass", Title: "English (Signs)", Forced: true},
|
||||
{Index: 1, Language: "jpn", Codec: "pgs", Title: "Japanese"},
|
||||
{Index: 2, Language: "fre", Codec: "dvd_subtitle", Title: "French"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func goldenSubtitleAdditional() []playback.SubtitleInventoryEntryV3 {
|
||||
return []playback.SubtitleInventoryEntryV3{
|
||||
{Codec: "srt", Language: "spa", Label: "Spanish (downloaded)", Source: playback.SubtitleSourceDownloadedV3},
|
||||
}
|
||||
}
|
||||
|
||||
// subtitleInventoryFixture is a self-describing conformance vector: the inputs
|
||||
// a client would receive for the same file, and the ordinals the server
|
||||
// assigns to them. A client reproduces `inventory` from `source` and compares.
|
||||
type subtitleInventoryFixture struct {
|
||||
Description string `json:"description"`
|
||||
SessionID string `json:"session_id"`
|
||||
MediaFileID int `json:"media_file_id"`
|
||||
Source subtitleInventorySource `json:"source"`
|
||||
Inventory []playback.SubtitleInventoryItemV3 `json:"inventory"`
|
||||
}
|
||||
|
||||
type subtitleInventorySource struct {
|
||||
ExternalSubtitles []models.ExternalSubtitle `json:"external_subtitles"`
|
||||
SubtitleTracks []models.SubtitleTrack `json:"subtitle_tracks"`
|
||||
Downloaded []playback.SubtitleInventoryEntryV3 `json:"downloaded"`
|
||||
}
|
||||
|
||||
func goldenSubtitleInventory() subtitleInventoryFixture {
|
||||
file := goldenMediaFile()
|
||||
additional := goldenSubtitleAdditional()
|
||||
return subtitleInventoryFixture{
|
||||
Description: "Combined subtitle ordinals are dense and gap-free across externals, embedded tracks, " +
|
||||
"then downloaded tracks. A track with no sidecar representation keeps its ordinal and is " +
|
||||
"published as burn_in_only without a URL rather than omitted.",
|
||||
SessionID: goldenSessionID,
|
||||
MediaFileID: goldenMediaFileID,
|
||||
Source: subtitleInventorySource{
|
||||
ExternalSubtitles: file.ExternalSubtitles,
|
||||
SubtitleTracks: file.SubtitleTracks,
|
||||
Downloaded: additional,
|
||||
},
|
||||
Inventory: playback.SubtitleInventoryV3(goldenSessionID, file, additional),
|
||||
}
|
||||
}
|
||||
|
||||
func goldenDecisionResponse() playback.DecisionResponseV3 {
|
||||
file := conformanceFallbackFile()
|
||||
subtitleSource := goldenMediaFile()
|
||||
file.ExternalSubtitles = subtitleSource.ExternalSubtitles
|
||||
file.SubtitleTracks = subtitleSource.SubtitleTracks
|
||||
now, err := time.Parse(time.RFC3339, "2029-12-31T23:55:00Z")
|
||||
if err != nil {
|
||||
fail("parse golden planner time: %v", err)
|
||||
}
|
||||
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
|
||||
Request: goldenStartRequest(),
|
||||
RequestedFile: file,
|
||||
EffectiveFile: file,
|
||||
AudioTrackIndex: 0,
|
||||
Settings: playback.PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true},
|
||||
Registry: conformanceRegistry(),
|
||||
Now: now,
|
||||
AdditionalSubtitles: goldenSubtitleAdditional(),
|
||||
})
|
||||
if result.Plan == nil {
|
||||
fail("golden planner returned terminal: %#v", result.Terminal)
|
||||
}
|
||||
plan := *result.Plan
|
||||
// These values belong to session/transport setup, which runs after the
|
||||
// planner in the request handler. Keep the planner authoritative for every
|
||||
// decision field and bind only the deterministic fixture runtime values.
|
||||
plan.SessionID = goldenSessionID
|
||||
plan.ExpiresAt = goldenExpiresAt
|
||||
plan.Stream.URL = "/stream/" + goldenSessionID
|
||||
plan.Subtitle.Inventory = playback.ScopeSubtitleInventoryV3(goldenSessionID, file, plan.Subtitle.Inventory)
|
||||
|
||||
return playback.DecisionResponseV3{
|
||||
ProtocolVersion: playback.ProtocolV3,
|
||||
ServerFeatures: playback.ServerFeaturesV3(),
|
||||
Outcome: playback.OutcomePlayableV3,
|
||||
SessionID: goldenSessionID,
|
||||
PlaybackPlan: &plan,
|
||||
}
|
||||
}
|
||||
|
||||
func goldenCapabilityResponse() playback.CapabilityResponseV3 {
|
||||
return playback.CapabilityResponseV3{
|
||||
Enabled: true,
|
||||
ProtocolVersions: []int{playback.ProtocolV3},
|
||||
Features: playback.ServerFeaturesV3(),
|
||||
Deliveries: []playback.DeliveryV3{
|
||||
playback.DeliveryOriginalHTTPV3,
|
||||
playback.DeliveryRemuxProgressiveV3,
|
||||
playback.DeliveryRemuxHLSV3,
|
||||
playback.DeliveryTranscodeHLSV3,
|
||||
},
|
||||
// A real server advertises only what its installed FFmpeg probed; the
|
||||
// fixture pins the full set so a client sees every shape it must parse.
|
||||
Transformations: []playback.TransformationV3{
|
||||
{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{playback.ClaimAudioDecodeV3}},
|
||||
{Name: playback.TransformationServerDV7HDR10V3, Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: playback.DV7ToHDR10ClaimsV3()},
|
||||
{Name: playback.TransformationVideoToH264V3, Executor: playback.ExecutorServerV3, RecipeVersion: playback.TransformationVideoToH264RecipeVersionV3, ValidatedClaims: []string{playback.ClaimH264DecodeV3}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func goldenRouteEvent() playback.RouteEventV3 {
|
||||
decision := goldenDecisionResponse()
|
||||
if decision.PlaybackPlan == nil {
|
||||
fail("golden decision has no plan")
|
||||
}
|
||||
plan := decision.PlaybackPlan
|
||||
return playback.RouteEventV3{
|
||||
ProtocolVersion: playback.ProtocolV3,
|
||||
PlaybackAttemptID: goldenAttemptID,
|
||||
SessionID: goldenSessionID,
|
||||
PlanID: plan.PlanID,
|
||||
PlanAttemptID: "plan-attempt-golden-0001",
|
||||
PlanAttemptKey: plan.PlanAttemptKey,
|
||||
Event: playback.RouteEventFirstFrameV3,
|
||||
OutputContextID: "7",
|
||||
Diagnostics: map[string]string{
|
||||
"decoder_name": "c2.android.avc.decoder",
|
||||
"first_frame_ms": "412",
|
||||
"video_mime": "video/avc",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// attemptKeyInput exists only inside the Go fixture generator. The inputs to
|
||||
// the server's identity function are deliberately absent from the generated
|
||||
// JSON: clients receive a token and prove only that they echo it unchanged.
|
||||
type attemptKeyInput struct {
|
||||
Name string `json:"name"`
|
||||
PlanID string `json:"plan_id"`
|
||||
Delivery playback.DeliveryV3 `json:"delivery"`
|
||||
StreamProtocol playback.StreamProtocolV3 `json:"stream_protocol"`
|
||||
Container string `json:"container"`
|
||||
VideoCodec string `json:"video_codec"`
|
||||
AudioCodec string `json:"audio_codec"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
BitrateKbps int `json:"bitrate_kbps"`
|
||||
DynamicRange string `json:"dynamic_range"`
|
||||
SubtitleMode playback.SubtitleModeV3 `json:"subtitle_mode"`
|
||||
Transformations []playback.TransformationV3 `json:"transformations"`
|
||||
AppliedQuirks []playback.AppliedQuirkV3 `json:"applied_quirks,omitempty"`
|
||||
RuntimeCorrections []string `json:"runtime_corrections,omitempty"`
|
||||
OutputContextID string `json:"output_context_id"`
|
||||
LocalMutations []string `json:"local_mutations"`
|
||||
}
|
||||
|
||||
func (f attemptKeyInput) plan() playback.PlanV3 {
|
||||
width, height, bitrate := f.Width, f.Height, f.BitrateKbps
|
||||
return playback.PlanV3{
|
||||
PlanID: f.PlanID,
|
||||
Delivery: f.Delivery,
|
||||
Stream: playback.StreamV3{Protocol: f.StreamProtocol, Container: f.Container},
|
||||
EffectiveRecipe: playback.EffectiveRecipeV3{
|
||||
VideoCodec: f.VideoCodec,
|
||||
AudioCodec: f.AudioCodec,
|
||||
Width: &width,
|
||||
Height: &height,
|
||||
BitrateKbps: &bitrate,
|
||||
DynamicRange: f.DynamicRange,
|
||||
},
|
||||
Subtitle: playback.SubtitleDecisionV3{Mode: f.SubtitleMode},
|
||||
Transformations: f.Transformations,
|
||||
AppliedQuirks: f.AppliedQuirks,
|
||||
RuntimeCorrections: f.RuntimeCorrections,
|
||||
}
|
||||
}
|
||||
|
||||
type opaqueAttemptKeyFixture struct {
|
||||
Name string `json:"name"`
|
||||
ServerPlanAttemptKey string `json:"server_plan_attempt_key"`
|
||||
ReplanEcho string `json:"replan_echo"`
|
||||
AttemptedPlanKeys []string `json:"attempted_plan_keys"`
|
||||
ExpectedServerAction string `json:"expected_server_action"`
|
||||
}
|
||||
|
||||
func goldenAttemptKeys() []opaqueAttemptKeyFixture {
|
||||
inputs := []attemptKeyInput{
|
||||
{
|
||||
Name: "hls_burn_in_sorted_transformations_and_pcm_mutations",
|
||||
PlanID: "plan:fixture",
|
||||
Delivery: playback.DeliveryRemuxHLSV3,
|
||||
StreamProtocol: playback.StreamHLSV3,
|
||||
Container: containerHLS,
|
||||
VideoCodec: codecHEVC,
|
||||
AudioCodec: codecAAC,
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
BitrateKbps: 20_000,
|
||||
DynamicRange: playback.DynamicRangeHDR10V3,
|
||||
SubtitleMode: playback.SubtitleBurnInV3,
|
||||
// Deliberately out of order, and deliberately naming a
|
||||
// transformation the registry no longer defines: the key preimage
|
||||
// sorts its inputs and never consults the registry, and pinning a
|
||||
// retired name proves both properties stay true.
|
||||
Transformations: []playback.TransformationV3{
|
||||
{Name: "hdr_to_sdr_tonemap", Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{}},
|
||||
{Name: playback.TransformationAudioToAACV3, Executor: playback.ExecutorServerV3, RecipeVersion: "1", ValidatedClaims: []string{}},
|
||||
},
|
||||
OutputContextID: "7",
|
||||
LocalMutations: []string{"transport_reopen", "pcm:truehd:8"},
|
||||
},
|
||||
{
|
||||
Name: "direct_client_dv81_executor_and_version",
|
||||
PlanID: "plan:dv81-fixture",
|
||||
Delivery: playback.DeliveryOriginalHTTPV3,
|
||||
StreamProtocol: playback.StreamHTTPProgressiveV3,
|
||||
Container: containerMKV,
|
||||
VideoCodec: codecHEVC,
|
||||
AudioCodec: codecTrueHD,
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
BitrateKbps: 65_000,
|
||||
DynamicRange: playback.DynamicRangeDolbyVisionV3,
|
||||
SubtitleMode: playback.SubtitleOffV3,
|
||||
Transformations: []playback.TransformationV3{
|
||||
{Name: playback.ClientDV7ToDV81V3, Executor: playback.ExecutorClientV3, RecipeVersion: playback.ClientDVTransformVersionV3, ValidatedClaims: []string{}},
|
||||
},
|
||||
OutputContextID: "9",
|
||||
LocalMutations: []string{},
|
||||
},
|
||||
{
|
||||
Name: "direct_device_quirk_and_runtime_correction_identity",
|
||||
PlanID: "plan:quirk",
|
||||
Delivery: playback.DeliveryOriginalHTTPV3,
|
||||
StreamProtocol: playback.StreamHTTPProgressiveV3,
|
||||
Container: containerMKV,
|
||||
VideoCodec: codecHEVC,
|
||||
AudioCodec: "eac3",
|
||||
Width: 3840,
|
||||
Height: 2160,
|
||||
BitrateKbps: 60_000,
|
||||
DynamicRange: playback.DynamicRangeDolbyVisionV3,
|
||||
SubtitleMode: playback.SubtitleOffV3,
|
||||
Transformations: []playback.TransformationV3{},
|
||||
// The private server implementation includes quirk and runtime
|
||||
// correction identity; clients see only its opaque result.
|
||||
AppliedQuirks: []playback.AppliedQuirkV3{{
|
||||
ID: playback.QuirkFireTVDV8HDR10PlusV3,
|
||||
RegistryRevision: playback.DeviceQuirkRegistryRevisionV3,
|
||||
Action: "client_runtime_correction",
|
||||
}},
|
||||
RuntimeCorrections: []string{playback.ClientDV8HDR10PlusSanitizerV3},
|
||||
OutputContextID: "9",
|
||||
LocalMutations: []string{},
|
||||
},
|
||||
}
|
||||
fixtures := make([]opaqueAttemptKeyFixture, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
key := playback.PlanAttemptKeyV3(input.plan(), input.OutputContextID, input.LocalMutations)
|
||||
fixtures = append(fixtures, opaqueAttemptKeyFixture{
|
||||
Name: input.Name,
|
||||
ServerPlanAttemptKey: key,
|
||||
ReplanEcho: key,
|
||||
AttemptedPlanKeys: []string{key},
|
||||
ExpectedServerAction: "reject_already_attempted_plan",
|
||||
})
|
||||
}
|
||||
return fixtures
|
||||
}
|
||||
|
||||
func goldenConformanceMatrix() playback.ConformanceMatrixV3 {
|
||||
videoFile := conformanceVideoFile()
|
||||
base := conformanceStartRequest()
|
||||
registry := conformanceRegistry()
|
||||
settings := playback.PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}
|
||||
|
||||
planner := make([]playback.PlannerScenarioV3, 0, 18)
|
||||
for _, tier := range []playback.CapabilityEvidenceV3{
|
||||
playback.EvidenceExactV3,
|
||||
playback.EvidencePlatformAttestedV3,
|
||||
playback.EvidenceDeclaredV3,
|
||||
} {
|
||||
request := base
|
||||
request.PlaybackAttemptID = "attempt-evidence-" + string(tier)
|
||||
request.Capabilities.VideoEvidence = tier
|
||||
if tier == playback.EvidenceDeclaredV3 {
|
||||
request.Capabilities.VideoDecode = nil
|
||||
}
|
||||
planner = append(planner, makePlannerScenario(
|
||||
"evidence_"+string(tier), "evidence_tier_gating", request, videoFile, nil, settings, registry,
|
||||
))
|
||||
}
|
||||
|
||||
fallbackRequest := conformanceStartRequest()
|
||||
fallbackRequest.Capabilities.VideoEvidence = playback.EvidenceDeclaredV3
|
||||
fallbackRequest.Capabilities.VideoDecode = nil
|
||||
fallbackRequest.Capabilities.CodecsVideo = []string{codecH264}
|
||||
fallbackRequest.Capabilities.CodecsVideoHardware = []string{codecH264}
|
||||
fallbackRequest.Capabilities.Containers = []string{containerMP4}
|
||||
fallbackFile := conformanceFallbackFile()
|
||||
var attempted []string
|
||||
for _, name := range []string{qualityOriginal, "progressive", containerHLS} {
|
||||
request := fallbackRequest
|
||||
request.PlaybackAttemptID = "attempt-delivery-chain"
|
||||
scenario := makePlannerScenario("delivery_"+name, "deliveries_negotiation", request, fallbackFile, attempted, settings, registry)
|
||||
planner = append(planner, scenario)
|
||||
attempted = append(attempted, scenario.Expected.PlanAttemptKey)
|
||||
}
|
||||
transcodeRequest := fallbackRequest
|
||||
transcodeRequest.PlaybackAttemptID = "attempt-delivery-transcode"
|
||||
transcodeRequest.QualityPreference = resolutionHD
|
||||
planner = append(planner, makePlannerScenario("delivery_transcode", "deliveries_negotiation", transcodeRequest, fallbackFile, nil, settings, registry))
|
||||
|
||||
audioFile := &models.MediaFile{ID: 77, BaseType: "audiobook", FilePath: "/media/audiobook.m4b", Container: containerMP4, CodecAudio: codecAAC, Bitrate: 128, AudioChannels: 2, Duration: 39_600, AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}}}
|
||||
audioRequest := conformanceStartRequest()
|
||||
audioRequest.FileID = audioFile.ID
|
||||
audioRequest.PlaybackAttemptID = "attempt-audio-only"
|
||||
audioRequest.Capabilities.Containers = []string{containerMP4}
|
||||
planner = append(planner, makePlannerScenario("audio_only_original", "audio_only_planning", audioRequest, audioFile, nil, settings, registry))
|
||||
|
||||
hdr10Request := conformanceHDRRequest()
|
||||
hdr10Request.PlaybackAttemptID = "attempt-hdr10-direct"
|
||||
planner = append(planner, makePlannerScenario("hdr10_exact_direct", "hdr_dv_matrix", hdr10Request, conformanceHDRFile(), nil, settings, registry))
|
||||
|
||||
dv8File := conformanceHDRFile()
|
||||
dv8File.VideoTracks[0].DVProfile = 8
|
||||
dv8File.VideoTracks[0].DVBLCompatID = 1
|
||||
dv8File.VideoTracks[0].VideoRange = "DolbyVision"
|
||||
dv8File.VideoTracks[0].VideoRangeType = "DOVIWithHDR10"
|
||||
dv8Request := conformanceHDRRequest()
|
||||
dv8Request.PlaybackAttemptID = "attempt-dv8-direct"
|
||||
dv8Request.Capabilities.HDRDetails.DolbyVisionProfiles = []int{8}
|
||||
dv8Request.ClientPlaybackContext.Output.HDRDetails.DolbyVisionProfiles = []int{8}
|
||||
planner = append(planner, makePlannerScenario("dolby_vision_8_exact_direct", "hdr_dv_matrix", dv8Request, dv8File, nil, settings, registry))
|
||||
|
||||
dv7File := conformanceHDRFile()
|
||||
dv7File.VideoTracks[0].DVProfile = 7
|
||||
dv7File.VideoTracks[0].DVBLCompatID = 6
|
||||
dv7File.VideoTracks[0].VideoRange = "DolbyVision"
|
||||
dv7File.VideoTracks[0].VideoRangeType = "DOVIWithEL"
|
||||
dv7Request := conformanceHDRRequest()
|
||||
dv7Request.PlaybackAttemptID = "attempt-dv7-hdr10"
|
||||
dv7Registry := playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{{Name: playback.TransformationServerDV7HDR10V3, RecipeVersion: "1", Available: true}})
|
||||
planner = append(planner, makePlannerScenario("dolby_vision_7_hdr10_fallback", "hdr_dv_matrix", dv7Request, dv7File, nil, settings, dv7Registry))
|
||||
|
||||
audioAdaptFile := conformanceHDRFile()
|
||||
audioAdaptFile.CodecAudio = codecTrueHD
|
||||
audioAdaptFile.AudioChannels = 8
|
||||
audioAdaptFile.AudioTracks[0] = models.AudioTrack{Codec: codecTrueHD, Channels: 8, Layout: audioLayout71}
|
||||
audioAdaptRequest := conformanceHDRRequest()
|
||||
audioAdaptRequest.PlaybackAttemptID = "attempt-truehd-aac"
|
||||
planner = append(planner, makePlannerScenario("truehd_audio_conversion", "audio_matrix", audioAdaptRequest, audioAdaptFile, nil, settings, registry))
|
||||
|
||||
audioPassthroughFile := conformanceHDRFile()
|
||||
audioPassthroughFile.CodecAudio = codecTrueHD
|
||||
audioPassthroughFile.AudioChannels = 8
|
||||
audioPassthroughFile.AudioTracks[0] = models.AudioTrack{Codec: codecTrueHD, Channels: 8, Layout: audioLayout71}
|
||||
audioPassthroughRequest := conformanceHDRRequest()
|
||||
audioPassthroughRequest.PlaybackAttemptID = "attempt-truehd-passthrough"
|
||||
audioPassthroughRequest.ClientFeatures = append(audioPassthroughRequest.ClientFeatures, playback.FeatureLayoutPassthrough)
|
||||
audioPassthroughRequest.Capabilities.CodecsAudio = []string{codecTrueHD}
|
||||
audioPassthroughRequest.Capabilities.AudioPassthrough = &playback.AudioPassthroughV3{
|
||||
PassthroughCodecs: []string{codecTrueHD}, MaxChannels: 8,
|
||||
Entries: []playback.AudioPassthroughEntryV3{{Codec: codecTrueHD, ChannelCounts: []int{8}, Layouts: []string{audioLayout71}}},
|
||||
}
|
||||
originalDelivery := audioPassthroughRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3]
|
||||
originalDelivery.AudioPassthroughCodecs = []string{codecTrueHD}
|
||||
audioPassthroughRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = originalDelivery
|
||||
planner = append(planner, makePlannerScenario("truehd_exact_layout_passthrough", "audio_matrix", audioPassthroughRequest, audioPassthroughFile, nil, settings, registry))
|
||||
|
||||
pgsFile := conformanceHDRFile()
|
||||
pgsFile.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Language: "jpn", Codec: "hdmv_pgs_subtitle", Title: "Japanese"}}
|
||||
pgsRequest := conformanceHDRRequest()
|
||||
pgsRequest.PlaybackAttemptID = "attempt-pgs-sidecar"
|
||||
pgsIndex := 0
|
||||
pgsRequest.SubtitleTrackIndex = &pgsIndex
|
||||
pgsRequest.SubtitleTrackID = playback.TrackIDV3(pgsFile.ID, "subtitle", pgsIndex)
|
||||
for _, deliveryClass := range []string{playback.DeliveryClassOriginalHTTPV3, playback.DeliveryClassProgressiveV3, playback.DeliveryClassHLSV3} {
|
||||
delivery := pgsRequest.ClientPlaybackContext.Deliveries[deliveryClass]
|
||||
delivery.Subtitles.EmbeddedBitmap = true
|
||||
pgsRequest.ClientPlaybackContext.Deliveries[deliveryClass] = delivery
|
||||
}
|
||||
planner = append(planner, makePlannerScenario("embedded_pgs_sidecar", "subtitle_matrix", pgsRequest, pgsFile, nil, settings, registry))
|
||||
|
||||
assFile := conformanceHDRFile()
|
||||
assFile.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Language: languageEnglish, Codec: "ass", Title: "English Signs"}}
|
||||
assRequest := conformanceHDRRequest()
|
||||
assRequest.PlaybackAttemptID = "attempt-ass-authored"
|
||||
assRequest.SubtitleFidelityPreference = playback.SubtitleFidelityPreserveV3
|
||||
assIndex := 0
|
||||
assRequest.SubtitleTrackIndex = &assIndex
|
||||
assRequest.SubtitleTrackID = playback.TrackIDV3(assFile.ID, "subtitle", assIndex)
|
||||
for _, deliveryClass := range []string{playback.DeliveryClassOriginalHTTPV3, playback.DeliveryClassProgressiveV3, playback.DeliveryClassHLSV3} {
|
||||
delivery := assRequest.ClientPlaybackContext.Deliveries[deliveryClass]
|
||||
delivery.Subtitles.EmbeddedText = true
|
||||
delivery.Subtitles.ASSStyling = true
|
||||
delivery.Subtitles.FontAttachments = true
|
||||
assRequest.ClientPlaybackContext.Deliveries[deliveryClass] = delivery
|
||||
}
|
||||
planner = append(planner, makePlannerScenario("embedded_ass_authored_render", "subtitle_matrix", assRequest, assFile, nil, settings, registry))
|
||||
|
||||
dvdFile := conformanceVideoFile()
|
||||
dvdFile.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Language: languageEnglish, Codec: "dvd_subtitle", Title: "English"}}
|
||||
dvdRequest := conformanceStartRequest()
|
||||
dvdRequest.PlaybackAttemptID = "attempt-dvd-burn-in"
|
||||
dvdIndex := 0
|
||||
dvdRequest.SubtitleTrackIndex = &dvdIndex
|
||||
dvdRequest.SubtitleTrackID = playback.TrackIDV3(dvdFile.ID, "subtitle", dvdIndex)
|
||||
planner = append(planner, makePlannerScenario("embedded_dvd_burn_in", "subtitle_matrix", dvdRequest, dvdFile, nil, settings, registry))
|
||||
|
||||
qualityRequest := fallbackRequest
|
||||
qualityRequest.PlaybackAttemptID = "attempt-available-qualities"
|
||||
qualityRequest.QualityPreference = qualityOriginal
|
||||
planner = append(planner, makePlannerScenario("available_qualities", "available_qualities", qualityRequest, fallbackFile, nil, settings, registry))
|
||||
|
||||
decision := goldenDecisionResponse()
|
||||
plan := decision.PlaybackPlan
|
||||
if plan == nil {
|
||||
fail("golden decision has no plan")
|
||||
}
|
||||
trackIndex := 1
|
||||
trackChange := goldenReplanRequest()
|
||||
trackChange.Operation = playback.ReplanOperationTrackChangeV3
|
||||
trackChange.ReplanRequestID = "replan-track-change-0001"
|
||||
trackChange.Failure = playback.FailureV3{}
|
||||
trackChange.SelectedTracks.Audio = &playback.TrackIdentityV3{ID: "", Index: &trackIndex}
|
||||
qualityChange := goldenReplanRequest()
|
||||
qualityChange.Operation = playback.ReplanOperationQualityChangeV3
|
||||
qualityChange.ReplanRequestID = "replan-quality-change-0001"
|
||||
qualityChange.Failure = playback.FailureV3{}
|
||||
qualityChange.QualityPreference = resolutionHD
|
||||
seekReanchor := goldenReplanRequest()
|
||||
seekReanchor.Operation = playback.ReplanOperationSeekReanchorV3
|
||||
seekReanchor.ReplanRequestID = "replan-seek-reanchor-0001"
|
||||
seekReanchor.Failure = playback.FailureV3{}
|
||||
seekReanchor.PositionSeconds = 321.25
|
||||
|
||||
trackChange.PositionSeconds = 321.25
|
||||
qualityChange.PositionSeconds = 321.25
|
||||
trackDuplicate := trackChange
|
||||
trackDuplicate.ReplanRequestID = "replan-track-duplicate-0001"
|
||||
qualityDuplicate := qualityChange
|
||||
qualityDuplicate.ReplanRequestID = "replan-quality-duplicate-0001"
|
||||
trackConcurrent := trackChange
|
||||
trackConcurrent.ReplanRequestID = "replan-track-concurrent-0001"
|
||||
qualityConcurrent := qualityChange
|
||||
qualityConcurrent.ReplanRequestID = "replan-quality-concurrent-0001"
|
||||
trackMidSeek := trackChange
|
||||
trackMidSeek.ReplanRequestID = "replan-track-mid-seek-0001"
|
||||
qualityMidSeek := qualityChange
|
||||
qualityMidSeek.ReplanRequestID = "replan-quality-mid-seek-0001"
|
||||
replans := []playback.ReplanScenarioV3{
|
||||
{Name: "track_change", Category: "track_change_replan", Request: trackChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PreserveUnmodifiedTracks: true}},
|
||||
{Name: "quality_change", Category: "quality_change_replan", Request: qualityChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, SelectedQuality: resolutionHD}},
|
||||
{Name: "track_change_idempotent_duplicate", Category: "idempotent_replan", Request: trackDuplicate, Expected: playback.ReplanExpectationV3{SameRequestAndBodyStatus: http.StatusOK, ResponseReplayedVerbatim: true, ChangedBodyStatus: http.StatusConflict, ChangedBodyError: "idempotency_key_reused"}},
|
||||
{Name: "quality_change_idempotent_duplicate", Category: "idempotent_replan", Request: qualityDuplicate, Expected: playback.ReplanExpectationV3{SameRequestAndBodyStatus: http.StatusOK, ResponseReplayedVerbatim: true, ChangedBodyStatus: http.StatusConflict, ChangedBodyError: "idempotency_key_reused"}},
|
||||
{Name: "track_change_concurrent_duplicate", Category: "concurrent_replan", Request: trackConcurrent, Expected: playback.ReplanExpectationV3{WhileFirstLeaseActiveStatus: http.StatusConflict, ConcurrentError: "replan_in_progress", AfterCompletionStatus: http.StatusOK, ResponseReplayedVerbatim: true}},
|
||||
{Name: "quality_change_concurrent_duplicate", Category: "concurrent_replan", Request: qualityConcurrent, Expected: playback.ReplanExpectationV3{WhileFirstLeaseActiveStatus: http.StatusConflict, ConcurrentError: "replan_in_progress", AfterCompletionStatus: http.StatusOK, ResponseReplayedVerbatim: true}},
|
||||
{Name: "track_change_mid_seek", Category: categoryMidSeek, Request: trackMidSeek, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PositionSeconds: trackMidSeek.PositionSeconds, PositionPreserved: true}},
|
||||
{Name: "quality_change_mid_seek", Category: categoryMidSeek, Request: qualityMidSeek, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PositionSeconds: qualityMidSeek.PositionSeconds, PositionPreserved: true}},
|
||||
{Name: "mid_seek_reanchor", Category: categoryMidSeek, Request: seekReanchor, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PositionSeconds: seekReanchor.PositionSeconds, PositionPreserved: true}},
|
||||
}
|
||||
|
||||
outputA := playback.PlanAttemptKeyV3(*plan, "output-a", nil)
|
||||
outputB := playback.PlanAttemptKeyV3(*plan, "output-b", nil)
|
||||
if outputA == outputB {
|
||||
fail("output context must change the opaque plan attempt key")
|
||||
}
|
||||
recovery := goldenReplanRequest()
|
||||
recovery.ReplanRequestID = "replan-failure-matrix-0001"
|
||||
recovery.PositionSeconds = 321.25
|
||||
subtitleIndex := 2
|
||||
recovery.SelectedTracks.Subtitle = &playback.TrackIdentityV3{
|
||||
ID: playback.TrackIDV3(goldenMediaFileID, "subtitle", subtitleIndex),
|
||||
Index: &subtitleIndex,
|
||||
}
|
||||
restartStart := goldenStartRequest()
|
||||
restartStart.PlaybackAttemptID = "attempt-restart-terminal"
|
||||
restartTerminal := playback.NewTerminalResponseV3(
|
||||
"transcode_start_failed",
|
||||
"The playback transport did not become ready in time.",
|
||||
true,
|
||||
)
|
||||
capacityStart := goldenStartRequest()
|
||||
capacityStart.PlaybackAttemptID = "attempt-capacity-unavailable"
|
||||
capacityAvailable := false
|
||||
zeroCapacityDelta := 0
|
||||
limitEvent := goldenRouteEvent()
|
||||
limitEvent.PlaybackAttemptID = "attempt-route-limit"
|
||||
limitEvent.Diagnostics = make(map[string]string, 33)
|
||||
for index := range 33 {
|
||||
limitEvent.Diagnostics[fmt.Sprintf("diagnostic_%02d", index)] = "value"
|
||||
}
|
||||
draftProtocolVersion := playback.ProtocolV3
|
||||
protocol := []playback.ProtocolScenarioV3{
|
||||
{Name: "legacy_start_requires_upgrade", Category: "legacy_426", Input: playback.ProtocolScenarioInputV3{LegacyStartBody: &playback.LegacyStartBodyV3{FileID: goldenMediaFileID}}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusUpgradeRequired, Error: "client_upgrade_required"}},
|
||||
{Name: "draft_v3_start_requires_upgrade", Category: "draft_v3_426", Input: playback.ProtocolScenarioInputV3{LegacyStartBody: &playback.LegacyStartBodyV3{ProtocolVersion: &draftProtocolVersion, FileID: goldenMediaFileID, ClientCapabilities: &playback.DraftClientCapabilitiesV3{CodecsVideo: []string{codecH264}}}}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusUpgradeRequired, Error: "client_upgrade_required"}},
|
||||
{Name: "output_context_change_invalidates_attempt", Category: "output_context_invalidation", Input: playback.ProtocolScenarioInputV3{PlanID: plan.PlanID, FirstOutputContextID: "output-a", SecondOutputContextID: "output-b", FirstPlanAttemptKey: outputA, SecondPlanAttemptKey: outputB}, Expected: playback.ProtocolExpectationV3{PlanIDUnchanged: true, PlanAttemptKeyChanged: true}},
|
||||
{Name: "opaque_attempt_key_loop", Category: "attempt_key_echo_and_loop", Input: playback.ProtocolScenarioInputV3{ServerPlanAttemptKey: plan.PlanAttemptKey, ReplanEcho: plan.PlanAttemptKey, AttemptedPlanKeys: []string{plan.PlanAttemptKey}}, Expected: playback.ProtocolExpectationV3{Action: "reject_already_attempted_plan"}},
|
||||
{Name: "failure_recovery_preserves_intent", Category: "recovery_matrix", Input: playback.ProtocolScenarioInputV3{ReplanRequest: &recovery}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusOK, SelectionPreserved: true, PositionPreserved: true, Action: "preserve_selected_tracks_and_position"}},
|
||||
{Name: "restart_replays_terminal_attempt", Category: "restart_matrix", Input: playback.ProtocolScenarioInputV3{StartRequest: &restartStart, PersistedDecision: &restartTerminal, Restarted: true}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusCreated, Outcome: playback.OutcomeAdaptationUnavailableV3, TerminalReason: "transcode_start_failed", ResponseReplayedVerbatim: true, CapacityDelta: &zeroCapacityDelta}},
|
||||
{Name: "capacity_unavailable_cleans_up", Category: "capacity_matrix", Input: playback.ProtocolScenarioInputV3{StartRequest: &capacityStart, CapacityAvailable: &capacityAvailable}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusCreated, Outcome: playback.OutcomeAdaptationUnavailableV3, TerminalReason: "capacity_unavailable", CapacityDelta: &zeroCapacityDelta, CleanupComplete: true}},
|
||||
{Name: "route_event_diagnostic_limit", Category: "route_event_limits", Input: playback.ProtocolScenarioInputV3{RouteEvent: &limitEvent}, Expected: playback.ProtocolExpectationV3{HTTPStatus: http.StatusBadRequest, Error: "bad_request", Action: "reject_without_persisting"}},
|
||||
}
|
||||
|
||||
return playback.ConformanceMatrixV3{SchemaVersion: 1, Planner: planner, Replans: replans, Protocol: protocol}
|
||||
}
|
||||
|
||||
func makePlannerScenario(name, category string, request playback.StartRequestV3, file *models.MediaFile, attempted []string, settings playback.PlannerSettingsV3, registry *playback.TransformationRegistryV3) playback.PlannerScenarioV3 {
|
||||
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
|
||||
Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
|
||||
Settings: settings, Registry: registry, AttemptedKeys: attempted,
|
||||
})
|
||||
expected := playback.PlannerExpectationV3{Outcome: playback.OutcomeAdaptationUnavailableV3}
|
||||
if result.Plan != nil {
|
||||
selectedTracks := result.Plan.SelectedTracks
|
||||
subtitle := result.Plan.Subtitle
|
||||
claims := result.Plan.Claims
|
||||
expected = playback.PlannerExpectationV3{
|
||||
Outcome: playback.OutcomePlayableV3,
|
||||
Delivery: result.Plan.Delivery,
|
||||
DecisionReason: result.Plan.DecisionReason,
|
||||
PlanID: result.Plan.PlanID,
|
||||
PlanAttemptKey: result.Plan.PlanAttemptKey,
|
||||
SelectedTracks: &selectedTracks,
|
||||
Subtitle: &subtitle,
|
||||
Claims: &claims,
|
||||
Transformations: append([]playback.TransformationV3(nil), result.Plan.Transformations...),
|
||||
AvailableQualities: result.Plan.AvailableQualities,
|
||||
}
|
||||
} else if result.Terminal != nil {
|
||||
expected.TerminalReason = result.Terminal.Reason
|
||||
}
|
||||
return playback.PlannerScenarioV3{
|
||||
Name: name, Category: category, Request: request,
|
||||
Source: playback.SourceDescriptorFromFileV3(file, 0),
|
||||
AttemptedKeys: append([]string(nil), attempted...), Expected: expected,
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceStartRequest() playback.StartRequestV3 {
|
||||
request := goldenStartRequest()
|
||||
request.QualityPreference = qualityOriginal
|
||||
request.Capabilities = playback.ClientCodecCapabilitiesV3{
|
||||
VideoEvidence: playback.EvidenceExactV3, AudioEvidence: playback.EvidenceExactV3,
|
||||
CodecsVideo: []string{codecHEVC}, CodecsVideoHardware: []string{codecHEVC}, CodecsAudio: []string{codecAAC}, Containers: []string{containerMKV}, MaxResolution: resolutionFHD,
|
||||
VideoDecode: []playback.VideoDecodeCapabilityV3{{Codec: codecHEVC, Profiles: []string{"main 10"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}},
|
||||
}
|
||||
request.ClientPlaybackContext = playback.ClientPlaybackContextV3{
|
||||
ProtocolVersion: playback.ProtocolV3, FormFactor: "tv", AppVersion: "3.0-test",
|
||||
Device: playback.DeviceContextV3{Platform: "fixture"}, Output: playback.OutputContextV3{OutputContextID: "output-a"},
|
||||
Deliveries: map[string]playback.DeliveryCapabilityV3{
|
||||
playback.DeliveryClassOriginalHTTPV3: conformanceDelivery([]string{containerMKV, containerMP4}),
|
||||
playback.DeliveryClassProgressiveV3: conformanceDelivery([]string{containerMP4}),
|
||||
playback.DeliveryClassHLSV3: conformanceDelivery([]string{containerHLS}),
|
||||
},
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func conformanceDelivery(containers []string) playback.DeliveryCapabilityV3 {
|
||||
return playback.DeliveryCapabilityV3{
|
||||
Enabled: true,
|
||||
SupportedOnDevice: true,
|
||||
Containers: append([]string(nil), containers...),
|
||||
VideoCodecs: []string{codecHEVC, codecH264},
|
||||
AudioDecodeCodecs: []string{codecAAC},
|
||||
AudioPassthroughCodecs: []string{},
|
||||
Features: []string{},
|
||||
ValidatedClaims: []string{},
|
||||
Transformations: []playback.TransformationV3{},
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceVideoFile() *models.MediaFile {
|
||||
return &models.MediaFile{ID: goldenMediaFileID, Container: containerMKV, CodecVideo: codecHEVC, CodecAudio: codecAAC, Resolution: resolutionFHD, Bitrate: 8_000, AudioChannels: 2, Duration: 7_200, VideoTracks: []models.VideoTrack{{Codec: codecHEVC, Profile: "Main", Level: 41, Width: 1920, Height: 1080, FrameRate: filmFrameRate, Bitrate: 8_000, BitDepth: 8, VideoRange: videoRangeSDR, VideoRangeType: videoRangeSDR}}, AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}}}
|
||||
}
|
||||
|
||||
func conformanceHDRFile() *models.MediaFile {
|
||||
return &models.MediaFile{
|
||||
ID: goldenMediaFileID,
|
||||
Container: containerMKV,
|
||||
CodecVideo: codecHEVC,
|
||||
CodecAudio: codecAAC,
|
||||
Resolution: "2160p",
|
||||
Bitrate: 60_000,
|
||||
AudioChannels: 2,
|
||||
Duration: 7_200,
|
||||
VideoTracks: []models.VideoTrack{{
|
||||
Codec: codecHEVC, Profile: "Main 10", Level: 153,
|
||||
Width: 3840, Height: 2160, FrameRate: filmFrameRate, Bitrate: 60_000, BitDepth: 10,
|
||||
VideoRange: "HDR", VideoRangeType: "HDR10", ColorRange: "tv",
|
||||
}},
|
||||
AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}},
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceHDRRequest() playback.StartRequestV3 {
|
||||
request := conformanceStartRequest()
|
||||
request.Capabilities.CodecsVideo = []string{codecHEVC}
|
||||
request.Capabilities.CodecsVideoHardware = []string{codecHEVC}
|
||||
request.Capabilities.CodecsAudio = []string{codecAAC}
|
||||
request.Capabilities.Containers = []string{containerMKV}
|
||||
request.Capabilities.MaxResolution = "2160p"
|
||||
request.Capabilities.HDR = true
|
||||
request.Capabilities.HDRDetails = &playback.HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{}}
|
||||
request.Capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
|
||||
Codec: codecHEVC, Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10},
|
||||
MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true,
|
||||
}}
|
||||
request.ClientPlaybackContext.Output.HDRDetails = &playback.HDRCapabilitiesV3{HDR10: true, DolbyVisionProfiles: []int{}}
|
||||
return request
|
||||
}
|
||||
|
||||
func conformanceFallbackFile() *models.MediaFile {
|
||||
return &models.MediaFile{ID: goldenMediaFileID, FilePath: "/media/movie.mp4", Container: containerMP4, CodecVideo: codecH264, CodecAudio: codecAAC, Resolution: resolutionFHD, Bitrate: 8_000, AudioChannels: 2, Duration: 7_200, VideoTracks: []models.VideoTrack{{Codec: codecH264, Profile: profileHigh, Level: 41, Width: 1920, Height: 1080, FrameRate: filmFrameRate, Bitrate: 8_000, BitDepth: 8, VideoRange: videoRangeSDR, VideoRangeType: videoRangeSDR}}, AudioTracks: []models.AudioTrack{{Codec: codecAAC, Channels: 2, Layout: audioLayoutStereo}}}
|
||||
}
|
||||
|
||||
func conformanceRegistry() *playback.TransformationRegistryV3 {
|
||||
return playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
|
||||
{Name: playback.TransformationAudioToAACV3, RecipeVersion: "1", Available: true},
|
||||
{Name: playback.TransformationVideoToH264V3, RecipeVersion: "1", Available: true},
|
||||
{Name: playback.TransformationServerDV7HDR10V3, RecipeVersion: "1", Available: true},
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"manifest_revision": 5,
|
||||
"manifest_revision": 6,
|
||||
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
|
||||
"cases": [
|
||||
{
|
||||
@@ -667,6 +667,30 @@
|
||||
"source": "profile"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "subtitle_appearance_uses_shared_default",
|
||||
"description": "With no stored profile or device value, every client receives the contract's shared Box 75% subtitle appearance.",
|
||||
"keys": ["playback.subtitle_appearance"],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"stored": [],
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"value": {
|
||||
"fontSize": "large",
|
||||
"fontFamily": "sans-serif",
|
||||
"fontColor": "#ffffff",
|
||||
"backgroundColor": "#000000",
|
||||
"backgroundStyle": "box",
|
||||
"backgroundOpacity": 75,
|
||||
"textOutline": false,
|
||||
"textOutlineColor": "#000000",
|
||||
"position": "bottom"
|
||||
},
|
||||
"source": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version": 1,
|
||||
"revision": 5,
|
||||
"revision": 6,
|
||||
"option_sets": {
|
||||
"playback_audio_languages": {
|
||||
"type": "language_tag",
|
||||
@@ -233,7 +233,7 @@
|
||||
"fontFamily": "sans-serif",
|
||||
"fontColor": "#ffffff",
|
||||
"backgroundColor": "#000000",
|
||||
"backgroundStyle": "shadow",
|
||||
"backgroundStyle": "box",
|
||||
"backgroundOpacity": 75,
|
||||
"textOutline": false,
|
||||
"textOutlineColor": "#000000",
|
||||
@@ -243,7 +243,7 @@
|
||||
"label": "Subtitle appearance",
|
||||
"description": "How subtitles are drawn during playback.",
|
||||
"recommended_control": "panel",
|
||||
"notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. The default below is the web client's; Apple defaults to a box background and Android to no background with an outline, so migration must first write each platform's own default into a row for users who never opened the panel, or their subtitles silently change appearance at cutover."
|
||||
"notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative."
|
||||
},
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
|
||||
@@ -123,7 +123,6 @@ services:
|
||||
- silo-dev-plugins:/var/lib/silo/plugins
|
||||
- silo-dev-compat:/var/lib/silo/compat
|
||||
- silo-dev-transcode:/tmp/silo-transcode
|
||||
- silo-dev-audiobook-covers:/var/lib/silo/audiobook-covers
|
||||
- ${MEDIA_ROOT:-/tmp/silo-dev-media}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -169,7 +168,6 @@ services:
|
||||
|
||||
volumes:
|
||||
silo-dev-air-tmp:
|
||||
silo-dev-audiobook-covers:
|
||||
silo-dev-compat:
|
||||
silo-dev-go-build:
|
||||
silo-dev-go-mod:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
silo:
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
# silo-transcode:
|
||||
# devices:
|
||||
# - /dev/dri:/dev/dri
|
||||
+6
-7
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-silo}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/postgres:/var/lib/postgresql
|
||||
command: ["postgres", "-c", "listen_addresses=*"]
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/redis:/data
|
||||
healthcheck:
|
||||
@@ -56,6 +56,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: integrated
|
||||
# Keep the container listeners fixed. PORT and JF_PORT in .env are
|
||||
# host-side published-port overrides.
|
||||
PORT: "8080"
|
||||
JF_PORT: "8096"
|
||||
# Master key for at-rest credential encryption. Required — the server
|
||||
# refuses to start without it. Generate with: openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
|
||||
@@ -71,12 +75,10 @@ services:
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/compat:/var/lib/silo/compat
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
|
||||
- /proc/meminfo:/host/proc/meminfo:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -86,9 +88,6 @@ services:
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
# silo-proxy:
|
||||
|
||||
@@ -31,6 +31,20 @@ entire current entity as `200 OK`, preventing bytes from different revisions
|
||||
from being combined. `If-None-Match` uses the same validator for ordinary
|
||||
conditional requests.
|
||||
|
||||
For a bounded chunk read, a client can instead send `Range` and `If-Match`.
|
||||
When the validator matches, the server returns the requested `206` interval.
|
||||
When it does not match, the server returns `412 Precondition Failed` without a
|
||||
body. This contract avoids materializing a full entity when a bounded read's
|
||||
validator is stale. It does not change `If-Range`: an `If-Range` mismatch still
|
||||
ignores the range and returns the full current entity as `200 OK`, as required
|
||||
by the HTTP range contract.
|
||||
|
||||
Direct-stream completion logs record whether `If-Match` and `If-Range` were
|
||||
present, a bounded conditional-result value, and short SHA-256 fingerprints for
|
||||
the emitted and requested validators. The raw validators and media path are not
|
||||
logged. The fingerprints are only correlation aids and are not protocol
|
||||
validators.
|
||||
|
||||
Playback sessions already treat each transport request independently.
|
||||
Sequential ranged requests refresh transport activity, and cleanup never
|
||||
expires a session while one of those transport requests is active. A late
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
# Playback protocol v3
|
||||
|
||||
The wire contract between a Silo client and a Silo server for deciding *how* a
|
||||
piece of media will play, and for recovering when that decision turns out to be
|
||||
wrong on the device in front of the user.
|
||||
|
||||
> **Status: normative.** This document, the JSON Schemas under
|
||||
> [`docs/design/schemas/playback-v3/`](../design/schemas/playback-v3/), and the
|
||||
> golden fixtures under `internal/playback/testdata/protocol_v3/` are the
|
||||
> contract. Where this document and a client implementation disagree, the client
|
||||
> is wrong. Where this document and the server implementation disagree, that is a
|
||||
> bug in one of them — file it.
|
||||
|
||||
This is written to be sufficient on its own. A third-party client should be able
|
||||
to implement playback against a Silo server from this document plus the schemas,
|
||||
without reading any client repository and without reading the server source.
|
||||
|
||||
Paths are repository-relative; assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design invariants
|
||||
|
||||
Five properties hold everywhere in the protocol. Most of the surprising details
|
||||
below follow from one of them.
|
||||
|
||||
**The server owns the decision.** A client reports what it can do; the server
|
||||
decides what will be sent and how. Clients do not pick a bitrate ladder rung, do
|
||||
not choose a container, do not decide whether to remux, and do not post a
|
||||
transcode recipe. The `playback_plan` in a response is the whole instruction.
|
||||
|
||||
**Plan identity is deterministic.** `plan_id` is a pure function of the request
|
||||
identity and the plan's own shape (§9). The same decision made twice produces
|
||||
the same `plan_id`. This is what makes replans idempotent rather than merely
|
||||
retried.
|
||||
|
||||
**Attempt keys are server-owned and opaque.** `plan_attempt_key` identifies "this
|
||||
plan, on this output, with these local mutations" for loop prevention. It is a
|
||||
hash the server computes. A client stores it, echoes it back, and never parses,
|
||||
compares by substring, or recomputes it. Its algorithm and preimage are server
|
||||
implementation details; §9 specifies only the stability clients may rely on.
|
||||
|
||||
**Claims are validated, not assumed.** When the server says a route preserves
|
||||
Atmos, or that Dolby Vision metadata was removed, that claim was checked against
|
||||
evidence the client supplied, at the strictness its evidence tier allows (§3).
|
||||
The server never claims something it did not verify.
|
||||
|
||||
**Route events are diagnostics, not control.** A client reports what happened
|
||||
(`first_frame`, `plan_failed`, `terminal`) so the server can learn; the report
|
||||
never changes the session. Playback recovery goes through replan (§6), which is a
|
||||
request with a response, not a fire-and-forget event.
|
||||
|
||||
Two consequences worth stating early, because they surprise implementers:
|
||||
|
||||
- `POST /playback/start` returns **201 for every outcome**, including a terminal
|
||||
refusal to play. A terminal is a decision, not a transport failure. 4xx means
|
||||
the request was malformed; it never means "this media cannot play."
|
||||
- `protocol_version: 3` is carried on every body independently, and each is
|
||||
validated separately: the start request, the `client_playback_context` nested
|
||||
inside it, the replan request, the route event, the decision response, and the
|
||||
plan within it. A start request whose envelope says 3 but whose
|
||||
`client_playback_context` says otherwise is rejected. There is no negotiation
|
||||
and no fallback — a server that does not speak v3 is not a server this
|
||||
contract describes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints
|
||||
|
||||
All paths are relative to `/api/v1`. Every endpoint requires an authenticated
|
||||
user. The mutation endpoints additionally require a profile, supplied as the
|
||||
`X-Profile-Id` header.
|
||||
|
||||
Every non-2xx response body is the standard error envelope:
|
||||
|
||||
```json
|
||||
{"error": "<machine_code>", "message": "<human sentence>"}
|
||||
```
|
||||
|
||||
The `error` code is the stable part; the `message` is for logs and is not
|
||||
contract.
|
||||
|
||||
### 2.1 `GET /playback/capability`
|
||||
|
||||
Feature detection. Auth required; no profile needed.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `200` | Capability document |
|
||||
| `401` `unauthorized` | No authenticated user |
|
||||
|
||||
v3 is the server's only playback protocol, so `enabled` is constant `true` and
|
||||
the document is always the full one:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"protocol_versions": [3],
|
||||
"features": ["playback_plan_v3", "neutral_playback_v3_contract_v1", "layout_aware_passthrough", "playback_route_diagnostics",
|
||||
"device_quirks_v1", "seek_reanchor_v1", "direct_stream_resume_v1",
|
||||
"plan_source_duration_v1"],
|
||||
"deliveries": ["original_http", "server_remux_progressive", "server_remux_hls", "server_transcode_hls"],
|
||||
"transformations": [{"name": "audio_to_aac", "executor": "server", "recipe_version": "1", "validated_claims": ["audio_decode"]}]
|
||||
}
|
||||
```
|
||||
|
||||
The eight feature strings above are the full set this server version advertises:
|
||||
|
||||
| Feature | What it promises |
|
||||
| --- | --- |
|
||||
| `playback_plan_v3` | The three plan endpoints exist and behave as specified here |
|
||||
| `neutral_playback_v3_contract_v1` | The server mints opaque `plan_attempt_key` values that clients only echo, and exposes `track_change` / `quality_change` as intent replans distinct from failure recovery |
|
||||
| `layout_aware_passthrough` | Audio passthrough is decided from channel *layouts*, not just channel counts (§3) |
|
||||
| `playback_route_diagnostics` | `POST /playback/route-events` is accepted |
|
||||
| `device_quirks_v1` | Plans may carry `applied_quirks` and `runtime_corrections` (§9) |
|
||||
| `seek_reanchor_v1` | The `seek_reanchor` replan operation is available (§6) |
|
||||
| `direct_stream_resume_v1` | A direct route may resume mid-file rather than restarting |
|
||||
| `plan_source_duration_v1` | `source.duration_seconds` is populated when known, so its absence means *unknown* rather than *unsupported* (§5) |
|
||||
|
||||
That last one is the reason feature detection is a list and not a version
|
||||
number: without it, a client cannot tell a server that never sends the runtime
|
||||
apart from a server that knows this particular file's runtime is genuinely
|
||||
unknown, and both look like an absent field.
|
||||
|
||||
`deliveries` reports the four *server-side* delivery values, not the three
|
||||
delivery classes a client negotiates in. §4 gives the folding.
|
||||
|
||||
`transformations` advertises only what the *installed* FFmpeg was probed for at
|
||||
startup — a server without a `dovi_rpu` bitstream filter does not list
|
||||
`server_dv7_to_hdr10`. A client must not assume a transformation exists because
|
||||
this document names it.
|
||||
|
||||
`enabled` survives from the rollout period and is now constant `true`; the
|
||||
negative shape was deliberately removed before v1 lock because v3 is the only
|
||||
playback protocol. `reason` remains an optional diagnostic for a future
|
||||
non-rollout condition, but it never changes the meaning of `enabled`.
|
||||
|
||||
### 2.2 `POST /playback/start`
|
||||
|
||||
Requests a plan. Auth + `X-Profile-Id` required. Request body cap: **256 KiB**.
|
||||
Body: `StartRequestV3`
|
||||
([`start-request.schema.json`](../design/schemas/playback-v3/v3/start-request.schema.json)).
|
||||
|
||||
| Status | Code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `201` | — | A decision was made. Body is `DecisionResponseV3`: either `outcome: "playable"` with a `playback_plan`, or `outcome: "adaptation_unavailable"` with a `terminal`. |
|
||||
| `400` | `bad_request` | Malformed JSON, failed validation (the `message` is the validator's own text), missing `X-Profile-Id`, or `profile_id` disagreeing with the header |
|
||||
| `401` | `unauthorized` | No authenticated user |
|
||||
| `404` | `not_found` | `file_id` does not exist, is marked missing, or this profile cannot see it |
|
||||
| `409` | `playback_attempt_reused` | This `playback_attempt_id` was already used for a *different* request |
|
||||
| `426` | `client_upgrade_required` | The body does not declare the finalized v3 shape: `protocol_version: 3` plus both capability evidence markers |
|
||||
| `500` | `internal_error` | Session store failure |
|
||||
|
||||
A file the profile is not allowed to see is `404`, not `403` — parental and
|
||||
library restrictions do not confirm that a hidden item exists.
|
||||
|
||||
The `426` is what a pre-v3 or draft-v3 client gets. There is no protocol negotiation and no
|
||||
fallback: the server decodes v3 or it refuses, and the client is expected to
|
||||
render an "update required" state rather than retry. It is deliberately not a
|
||||
`400` — the request may be perfectly well-formed for the protocol it was written
|
||||
against, and the distinction is what lets a client tell "I sent something wrong"
|
||||
apart from "I am too old to talk to this server".
|
||||
|
||||
Note the layering: a request whose *body* is fine but whose *media* cannot be
|
||||
played is not an HTTP error. It is a `201` with `outcome:
|
||||
"adaptation_unavailable"` and a terminal reason from §7.3. HTTP statuses on this
|
||||
endpoint describe the request; the decision lives in the body.
|
||||
|
||||
**Idempotency.** The server stores each `playback_attempt_id` alongside a
|
||||
SHA-256 digest of the exact request body. Replaying a byte-identical body
|
||||
replays the original response verbatim. Reusing the id with a different body is
|
||||
`409 playback_attempt_reused` — the id is a claim about *which* playback attempt
|
||||
this is, so reusing it for different intent is a client bug the server refuses to
|
||||
paper over. If the attempt is known but its session has since expired, the
|
||||
response is a `201` terminal with reason `session_expired` rather than a replay
|
||||
of a plan that no longer exists.
|
||||
|
||||
Playable and terminal decisions are both durable attempts. A terminal start has
|
||||
no `session_id` or plan identity, but its response and ownership are retained
|
||||
under `playback_attempt_id` for the same TTL. This makes terminal retries obey
|
||||
the same replay/conflict rules and gives terminal route events an addressable
|
||||
authorization record.
|
||||
|
||||
A client generates a fresh `playback_attempt_id` per user-initiated playback and
|
||||
reuses it only to retry a request whose response it did not receive.
|
||||
|
||||
**Omission is a request, not a default.** Two start fields mean "you decide" when
|
||||
absent, and the server answers from stored user state rather than from a
|
||||
constant:
|
||||
|
||||
| Field | Omitted | Present |
|
||||
| --- | --- | --- |
|
||||
| `start_position` | The profile's saved resume point for this item, or `0` when there is none, it is already complete, or the file is one part of a multipart item (every part shares the item's resume point, so a part-local seek to it would land somewhere arbitrary). It is required when `progress_persistence` is `client` | Exactly that position. `0` means *start over* |
|
||||
| `audio_track_id` / `audio_track_index` | The profile's preferred audio track, resolved from the series preference, then the profile's audio-language setting, then the library override | Exactly that track |
|
||||
|
||||
`progress_persistence` separates the live session clock from durable resume
|
||||
ownership. Omission (or `server`) means session progress may update the item's
|
||||
resume/history normally. `client` keeps heartbeats, route diagnostics, and live
|
||||
session state intact but suppresses those durable writes because the client
|
||||
persists its own item-global timeline (for example through `/sync/progress`). A
|
||||
client choosing that mode must send `start_position` explicitly, including
|
||||
explicit `0`; the server never substitutes saved resume state for it.
|
||||
|
||||
Both are settled *before* planning, not after. This is not an implementation
|
||||
detail a client can ignore: the plan's timeline is cut at the start position
|
||||
(§5), and the audio track is part of the plan's identity (§9), so a route chosen
|
||||
for position zero and then seeked is a different route than the one the server
|
||||
would have chosen for the resume point. A client that resolves resume state
|
||||
itself and sends the position explicitly gets identical behaviour — that is the
|
||||
supported way to override the server's policy.
|
||||
|
||||
### 2.3 `POST /playback/{session_id}/replan`
|
||||
|
||||
Asks for a different plan for an existing session — after a failure, or because
|
||||
the user changed a track or the quality. Auth + `X-Profile-Id` required. Request
|
||||
body cap: 256 KiB. Body: `ReplanRequestV3`
|
||||
([`replan-request.schema.json`](../design/schemas/playback-v3/v3/replan-request.schema.json)).
|
||||
|
||||
| Status | Code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `200` | — | `DecisionResponseV3`, plan or terminal |
|
||||
| `400` | `bad_request` | Malformed or failed validation |
|
||||
| `401` | `unauthorized` | No authenticated user, or no `X-Profile-Id` |
|
||||
| `403` | `forbidden` | The session belongs to another user or profile |
|
||||
| `404` | `playback_session_not_found` | No such session, or its session has ended |
|
||||
| `409` | `stale_playback_plan` | `failed_plan_id` is not the session's current plan, `playback_attempt_id` is not the session's attempt, or a newer replacement is already active |
|
||||
| `409` | `idempotency_key_reused` | This `replan_request_id` was used for a different replan |
|
||||
| `409` | `replan_in_progress` | A replan for this session holds the lease right now |
|
||||
| `503` | `replan_capacity_exhausted` | Server-wide concurrent replan limit (8) reached; retryable |
|
||||
| `500` | `internal_error` | Store outage |
|
||||
|
||||
Note that a missing profile is `401` here, where start answers `400` — start
|
||||
validates the body first and reports the header as one more field problem, while
|
||||
replan treats identity as a precondition. Neither is retryable, so the
|
||||
difference does not change client behaviour.
|
||||
|
||||
Checks run in this order: auth → body decode and validation → concurrency slot →
|
||||
session lock → attempt lookup → ownership → attempt match → live session → lease.
|
||||
A client that sees `503` therefore knows nothing was read or written for its
|
||||
session, and can retry the identical request unchanged. Note that validation
|
||||
comes before every session lookup: a malformed body against a session that does
|
||||
not exist answers `400`, not `404`.
|
||||
|
||||
**Leases.** A replan takes a 15-second lease on the session. A second request
|
||||
carrying the *same* `replan_request_id` while the lease is in flight gets `409
|
||||
replan_in_progress`; once the original completes, the same id replays its
|
||||
response verbatim. This is what makes a client's retry-on-timeout safe.
|
||||
|
||||
Note the deliberate asymmetry with start: a store outage during replan is `500`,
|
||||
never `404`. Clients tear playback down on session-not-found, so a transient
|
||||
store failure must read as retryable rather than as the session having vanished.
|
||||
|
||||
### 2.4 `POST /playback/route-events`
|
||||
|
||||
Reports what happened on the device. Auth + `X-Profile-Id` required. Request body
|
||||
cap: **32 KiB**. Body: a single `RouteEventV3`, not a batch
|
||||
([`route-event.schema.json`](../design/schemas/playback-v3/v3/route-event.schema.json)).
|
||||
|
||||
| Status | Code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `202` | — | Accepted. **No response body.** |
|
||||
| `400` | `bad_request` | Malformed or failed validation |
|
||||
| `401` | `unauthorized` | No authenticated user or no profile |
|
||||
| `403` | `forbidden` | The session or attempt belongs to another profile, or the referenced session/attempt does not exist |
|
||||
| `429` | `event_rate_limited` | 120 events/attempt/minute or 600/user/minute exceeded |
|
||||
| `500` | `internal_error` | Store outage |
|
||||
|
||||
The checks run in that order: auth, then body decode and validation, then the
|
||||
rate limit, and only then the session-ownership lookup. The
|
||||
limiter sits in front of the ownership lookup deliberately — it exists to bound
|
||||
store reads as much as writes, so it has to precede the read that would establish
|
||||
ownership.
|
||||
|
||||
Two consequences for clients. A `429` means "drop this event," never "retry it";
|
||||
the events are diagnostics and losing one costs nothing. And an unknown session
|
||||
is `403`, not `404` — the handler does not distinguish "not yours" from "not
|
||||
there." A store outage during that lookup is `500`, so a `403` genuinely means
|
||||
the event will never be accepted and should be dropped rather than retried.
|
||||
|
||||
A terminal decision returned by `POST /playback/start` has a durable attempt but
|
||||
no playback session or plan. The client reports it with `event: "terminal"`,
|
||||
the start request's `playback_attempt_id`, and no `session_id`, `plan_id`,
|
||||
`plan_attempt_id`, or `plan_attempt_key`. The server authorizes the event through
|
||||
the persisted attempt ownership and returns `202`.
|
||||
|
||||
Event names are the eleven in §7.4. `diagnostics` is a string→string map, capped
|
||||
at 32 entries, and the server keeps only the keys on its allowlist (§7.5),
|
||||
truncating each value to 256 characters. Unknown keys are dropped silently; a
|
||||
client sending them is not an error, it just achieves nothing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Capability evidence tiers
|
||||
|
||||
The hardest problem in this protocol is that clients lie — not maliciously, but
|
||||
because platform APIs vary in how much they actually know. Android can enumerate
|
||||
`MediaCodecList` and answer "this exact decoder supports H.264 High@4.1 at 8-bit
|
||||
up to 1920×1080@60". A browser can only answer `isTypeSupported("video/mp4;
|
||||
codecs=avc1.640028")` → true. Apple can attest that VideoToolbox handles a codec
|
||||
family but not enumerate levels.
|
||||
|
||||
So a client declares *how it knows*, per media type, and the server applies a
|
||||
different strictness to each tier. `video_evidence` and `audio_evidence` are
|
||||
required and are one of:
|
||||
|
||||
| Tier | Who reports it | What the server does with it |
|
||||
| --- | --- | --- |
|
||||
| `exact` | Android (`MediaCodecList`) | Full strict validation. The server walks `video_decode[]` and requires a hardware entry matching codec, profile, level, bit depth, and every `max_*` bound. Only this tier can earn a validated audio **passthrough** claim. |
|
||||
| `platform_attested` | Apple (VideoToolbox) | Same walk, but profile and level are **skipped** — the platform attests the codec family rather than enumerating modes. All other bounds still apply. |
|
||||
| `declared` | Web (`isTypeSupported`) | Flat list match only: `codecs_video` / `codecs_video_hardware` membership. No `video_decode[]` walk. |
|
||||
|
||||
Four rules follow from the table and are easy to get wrong:
|
||||
|
||||
**A flat claim without backing detail is a refusal, not a pass.** On `exact` and
|
||||
`platform_attested`, if a codec appears in `codecs_video` but no `video_decode[]`
|
||||
entry names that codec with `hardware: true`, the source is *not* eligible for a
|
||||
direct route. The plan is downgraded and carries the decision reason
|
||||
`evidence_insufficient_for_direct` plus the matching degradation warning, which
|
||||
distinguishes "your evidence didn't support this" from "your device said no." A
|
||||
client advertising a strict tier must populate `video_decode[]`; the flat lists
|
||||
alone earn it nothing. On `declared` the flat lists are the whole mechanism, so
|
||||
that tier never produces this signal.
|
||||
|
||||
Note the precise trigger: the signal fires only when *no* entry named the codec.
|
||||
If an entry matched the codec but the source exceeded one of its bounds — a
|
||||
4K file against a `max_height: 1080` decoder — that is a real device limit, and
|
||||
the plan is downgraded with no evidence warning. The two cases mean different
|
||||
things and a client should not conflate them in its telemetry.
|
||||
|
||||
**An omitted bound means "unconstrained", not "unknown".** Within a
|
||||
`video_decode[]` entry, an empty `profiles`, `levels`, or `bit_depths` list and a
|
||||
zero `max_width` / `max_height` / `max_frame_rate` / `max_bitrate_kbps` are each
|
||||
*skipped*, not failed. An entry that names a codec and nothing else therefore
|
||||
claims that decoder handles every variant of it. That is a strong claim, and on
|
||||
`exact` it is the client's job not to make it carelessly: the server will honour
|
||||
it and hand back a direct route. Report what the platform actually enumerated.
|
||||
|
||||
The three list bounds are also not matched the same way, which matters when
|
||||
populating them:
|
||||
|
||||
| Field | Match |
|
||||
| --- | --- |
|
||||
| `profiles` | Case-insensitive string equality against the source profile |
|
||||
| `levels` | **At-least**: any listed level ≥ the source level passes |
|
||||
| `bit_depths` | Exact integer equality |
|
||||
|
||||
So a decoder that tops out at H.264 level 4.1 may report `[41]` and still
|
||||
validate a level-3.0 stream, while a decoder that handles 8- and 10-bit must
|
||||
list both — `[10]` alone rejects an 8-bit source. Levels use the integer form
|
||||
(4.1 → `41`).
|
||||
|
||||
**Every validated video route requires complete routing metadata.** Before any
|
||||
tier logic runs, the server requires video codec, bit depth, width, height,
|
||||
frame rate, and bitrate. Profile and level are decoder bounds instead: an
|
||||
`exact` entry that supplies either bound cannot validate a source whose matching
|
||||
probe value is absent or unknown, while an omitted bound keeps the explicit
|
||||
“unconstrained” meaning above. This permits server adaptation of sources such as
|
||||
VP9 whose probe reports an unknown level without allowing that sentinel to
|
||||
satisfy a concrete client limit. A source missing the routing fields is
|
||||
ineligible for any route and this case is *not* reported as
|
||||
`evidence_insufficient_for_direct` — the client's evidence was never the
|
||||
problem.
|
||||
|
||||
**Passthrough requires `exact` audio evidence.** A validated passthrough claim
|
||||
(bitstreaming E-AC-3/TrueHD to a receiver) additionally requires the
|
||||
`layout_aware_passthrough` feature in `client_features`, the codec listed in
|
||||
`audio_passthrough.passthrough_codecs`, and a matching
|
||||
`audio_passthrough.entries[]` whose `channel_counts` and `layouts` cover the
|
||||
source. Only a client that can enumerate real sink layouts — the Android audio
|
||||
HAL — can supply that. `platform_attested` and `declared` audio evidence still
|
||||
qualify for ordinary decode/copy routes; they simply cannot earn
|
||||
`claims.audio.passthrough = true`.
|
||||
|
||||
**HDR is decided against the output, not the decoder.** `output.hdr_details` (the
|
||||
display or receiver actually attached) takes precedence over
|
||||
`client_capabilities.hdr_details` (what the device could do in principle). A
|
||||
source whose dynamic range is recorded as `hdr_unknown` — legacy rows that only
|
||||
stored a file-level HDR boolean — is treated as HDR10 when the output supports
|
||||
HDR10, and the plan carries the `hdr_range_assumed_hdr10` degradation warning.
|
||||
Refusing to play those outright would be worse than an assumption the client is
|
||||
told about.
|
||||
|
||||
---
|
||||
|
||||
## 4. Deliveries
|
||||
|
||||
A delivery is *how bytes reach the player*. The server works in four values; the
|
||||
client negotiates in three classes.
|
||||
|
||||
| Server `delivery` | Client class | What it is |
|
||||
| --- | --- | --- |
|
||||
| `original_http` | `original_http` | The source file, byte-for-byte, over HTTP with range support |
|
||||
| `server_remux_progressive` | `progressive` | Repackaged into a new container, streamed as one chunked response |
|
||||
| `server_remux_hls` | `hls` | Repackaged into HLS segments; codecs untouched |
|
||||
| `server_transcode_hls` | `hls` | Re-encoded and segmented |
|
||||
|
||||
`client_playback_context.deliveries` is keyed by **class**, because a client's
|
||||
answer to "can you play HLS" does not differ between a remux and a transcode —
|
||||
the same player component handles both. The server folds its four values into
|
||||
three when checking eligibility, and reports the specific one it chose in the
|
||||
plan.
|
||||
|
||||
Each `deliveries` entry describes one class:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `enabled` | The client is *willing* to use this class right now (user setting, network policy) |
|
||||
| `supported_on_device` | The client is *able* to — the platform has a player for it at all |
|
||||
| `failure_reason` | Optional free text explaining a `false` above; diagnostics only |
|
||||
| `containers`, `video_codecs`, `audio_decode_codecs` | Flat lowercase name lists |
|
||||
| `audio_passthrough_codecs` | Bitstream-out candidates; only ever honoured under the `exact` tier (§3) |
|
||||
| `max_channels` | Optional ceiling applied to audio routing |
|
||||
| `hdr_details` | Optional per-class HDR support, overriding the device-level value |
|
||||
| `subtitles` | Six booleans: `embedded_text`, `sidecar_text`, `ass_styling`, `embedded_bitmap`, `sidecar_bitmap`, `font_attachments` |
|
||||
| `features` | Class-scoped feature strings |
|
||||
| `auth_header_refresh` | The client can re-fetch stream auth headers without restarting playback |
|
||||
| `validated_claims` | Claims the client asserts it has verified for this class |
|
||||
| `transformations` | Client-executed transformations offered for this class (§11) |
|
||||
|
||||
Both booleans must be true for the class to be eligible; they are separate
|
||||
because "the user turned HLS off" and "this device has no HLS player" call for
|
||||
different degradation warnings and different diagnostics. A class the client
|
||||
omits entirely is unavailable — the server will not guess.
|
||||
|
||||
`stream.header_refresh` tells the client what to do when the stream URL's auth
|
||||
expires: `none` means the URL is stable for the session, `session` means
|
||||
re-request headers from `header_refresh_url` rather than restarting playback.
|
||||
|
||||
---
|
||||
|
||||
## 5. The timeline model
|
||||
|
||||
The single most common client bug in v2 was assuming the player's zero and the
|
||||
source's zero are the same instant. In v3 they usually are not, and the plan says
|
||||
so explicitly.
|
||||
|
||||
`timeline` carries four numbers that a client must keep distinct:
|
||||
|
||||
- `source_start_seconds` — where in the *media* this plan begins.
|
||||
- `stream_origin_seconds` — the source position that the *transport's* byte-zero
|
||||
corresponds to.
|
||||
- `player_start_seconds` — where the client should seek the player after load.
|
||||
- `timeline_offset_seconds` — what to add to a player position to get a source
|
||||
position.
|
||||
|
||||
Three shapes exist:
|
||||
|
||||
**Direct and transcoded routes** hand the player a complete timeline.
|
||||
`stream_origin` and `timeline_offset` are `0`, `player_start` is the requested
|
||||
position, `can_seek_anywhere` is true when the runtime is known, and
|
||||
`seek_restoration` is `player_position` — the client seeks locally.
|
||||
|
||||
**Copy remux over HLS** is served from FFmpeg's live, still-growing playlist,
|
||||
which starts at the requested position. So `player_start` is `0`,
|
||||
`stream_origin` and `timeline_offset` both equal the seek position,
|
||||
`seek_window_start_seconds` is that position, and **`seek_window_end_seconds` is
|
||||
deliberately absent**. An open end marks the window as incomplete; combined with
|
||||
`can_seek_anywhere: false` it routes every seek back through the server as a
|
||||
reanchor (§6), which is correct because the playlist has no bytes for positions
|
||||
FFmpeg has not reached yet. `seek_restoration` is `source_position`.
|
||||
|
||||
**Progressive remux** is a freshly generated chunked response with no byte-range
|
||||
support, so it behaves the same way: player-zero is stream-zero is the seek
|
||||
position, the window is open-ended, and seeks go through the server.
|
||||
|
||||
### `source.duration_seconds`
|
||||
|
||||
The media's full runtime, and nothing else. Specifically:
|
||||
|
||||
- It is **not** `total − source_start`. It does not shrink because the plan
|
||||
starts mid-file.
|
||||
- It is **not** adjusted by `timeline_offset_seconds`.
|
||||
- It is **omitted** rather than sent as `null` when the server does not know it,
|
||||
because a client coercing `null` to a numeric default would read it as zero.
|
||||
- A client **must not** substitute the playback engine's reported duration for
|
||||
it. On an HLS copy remux the engine reports the length produced so far, not the
|
||||
runtime — using it makes the scrubber grow while the user watches.
|
||||
|
||||
---
|
||||
|
||||
## 6. Replan
|
||||
|
||||
Replan is the only way a plan changes. It covers both "that didn't work" and
|
||||
"the user asked for something else," and the distinction matters to the server.
|
||||
|
||||
`operation` is one of five:
|
||||
|
||||
| Operation | Meaning | Requires |
|
||||
| --- | --- | --- |
|
||||
| `failure_recovery` | The plan failed on the device | `failure.classification` |
|
||||
| `seek_failure_recovery` | A seek failed | `failure.classification` |
|
||||
| `seek_reanchor` | Move a server-anchored timeline to a new position | — |
|
||||
| `track_change` | The user picked a different audio or subtitle track | — |
|
||||
| `quality_change` | The user picked a rung from `available_qualities` | non-empty `quality_preference` |
|
||||
|
||||
The asymmetry is intentional. A seek reanchor is a timeline operation, not a
|
||||
failed recipe — a classification is still accepted from older callers but never
|
||||
selects seek semantics. A track or quality change is not a failure at all, so
|
||||
demanding a classification would force clients to invent one. But
|
||||
`quality_change` *must* name the rung it wants: an empty `quality_preference`
|
||||
normalizes to `auto`, which is a different user intent than the menu selection
|
||||
the operation models, so the server rejects it rather than silently doing
|
||||
something else.
|
||||
|
||||
**User-intent operations behave differently from failure recovery.**
|
||||
`track_change` and `quality_change` replace what were separate v2 endpoints (an
|
||||
audio PATCH and a client-posted transcode start). Nothing failed, so the previous
|
||||
route stays eligible: neither the attempted-key history nor the failed-plan
|
||||
exclusion applies. A client may therefore be handed back a plan it has already
|
||||
tried — that is correct here, and a client must not treat a repeated
|
||||
`plan_attempt_key` as a loop.
|
||||
|
||||
When such an operation actually changes something — the request's tracks or
|
||||
quality differ from what the session currently has — the server also tries to
|
||||
return to the *requested* edition rather than staying on whatever alternate
|
||||
version a previous fallback landed on, since a user switching tracks may well
|
||||
want the original file back. That is a preference, not a guarantee: if the
|
||||
requested edition no longer resolves or fails its preflight, the healthy active
|
||||
alternate is kept. Track identities are remapped only when the edition really
|
||||
changes, because remapping within one file would degrade an exact selection to a
|
||||
best-match lookup and could silently move a listener off a commentary track.
|
||||
|
||||
Omitting `quality_preference` on any replan preserves the session's current
|
||||
preference; sending it replaces that preference. A track change therefore does
|
||||
not silently reset `original` or a pinned rung, and failure recovery does not
|
||||
discard the viewer's requested quality unless the client explicitly asks it to.
|
||||
Clients should still send the current preference when they know it, so their
|
||||
intent remains explicit in diagnostics.
|
||||
|
||||
For failure recovery, `attempted_plan_keys` is the loop guard. The client sends
|
||||
back every `plan_attempt_key` it has already tried for this attempt (up to 16);
|
||||
the server will not hand back a plan whose key is in that list. `attempt_count`
|
||||
(1–8) bounds the whole recovery chain. Together they mean a device that fails
|
||||
every route reaches a terminal instead of cycling forever.
|
||||
|
||||
Failure, seek, and quality replans may omit unchanged track identities. The
|
||||
server overlays only identities present in those requests and preserves the
|
||||
durable selected subtitle otherwise. Only `operation: "track_change"` gives an
|
||||
omitted `selected_tracks.subtitle` the explicit meaning "subtitles off". A
|
||||
fallback to another media version must remap the selected subtitle; if no
|
||||
equivalent exists, it returns terminal reason `subtitle_unavailable_in_version`
|
||||
instead of silently continuing with subtitles off.
|
||||
|
||||
`local_mutations` (up to 8 entries, 64 chars each) reports client-side
|
||||
adjustments — a transport reopen, a PCM decode fallback — that change the
|
||||
effective route without changing the plan. They feed the attempt key, so a plan
|
||||
retried after a local mutation is a *different* attempt and is not blocked by the
|
||||
loop guard.
|
||||
|
||||
A seek-scoped recovery refuses to accept new capability or device evidence: a
|
||||
seek is not an authority boundary for replacing the client's declared abilities
|
||||
mid-session.
|
||||
|
||||
---
|
||||
|
||||
## 7. Registries
|
||||
|
||||
### 7.1 Decision reasons
|
||||
|
||||
Why the server picked this route. Informational; clients may log or display but
|
||||
must not branch on an unrecognized value.
|
||||
|
||||
`validated_original_playback`, `container_normalization`, `audio_adaptation`,
|
||||
`hls_audio_adaptation`, `hls_packaging_required`, `subtitle_burn_in_required`,
|
||||
`client_dv7_to_dv81`, `client_dv7_to_hdr10`, `evidence_insufficient_for_direct`,
|
||||
and the quality reasons `quality_original`, `quality_auto_source`,
|
||||
`quality_fixed_rung`, `quality_device_limit`, `quality_bandwidth_limit`,
|
||||
`quality_metered_limit`, `quality_bandwidth_cap`.
|
||||
|
||||
### 7.2 Degradation warnings
|
||||
|
||||
The plan will play, but something the user might notice was given up.
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `hdr_range_assumed_hdr10` | Source range unknown; treated as HDR10 |
|
||||
| `dolby_vision_removed` | DV metadata stripped |
|
||||
| `dolby_vision_strip_unsupported_by_source` | DV could not be stripped |
|
||||
| `dolby_vision_enhancement_layer_discarded` | FEL/MEL dropped, base layer kept |
|
||||
| `audio_converted` | Audio re-encoded rather than copied |
|
||||
| `subtitle_burn_in` | Subtitles rendered into the video |
|
||||
| `quality_reduction_unavailable` | Requested rung could not be produced |
|
||||
| `quality_preference_normalized` | Unknown `quality_preference` normalized to `auto` |
|
||||
| `bandwidth_cap_applied` | `bandwidth_cap_kbps` limited the selection |
|
||||
| `evidence_insufficient_for_direct` | Evidence tier blocked a direct route |
|
||||
|
||||
### 7.3 Terminal reasons
|
||||
|
||||
Playback will not proceed. `terminal.retryable` says whether trying again could
|
||||
help. Delivered inside a `201` (start) or `200` (replan), never a 4xx.
|
||||
|
||||
*Planner:* `adaptation_exhausted`, `adaptation_unavailable`,
|
||||
`client_hls_unsupported`, `conversion_tool_unavailable`,
|
||||
`hdr_transcode_unsupported`, `no_alternate_version`,
|
||||
`source_metadata_incomplete`, `source_unavailable`,
|
||||
`audio_conversion_unsupported`, `video_conversion_unsupported`,
|
||||
`dv_conversion_unsupported`, `transcoding_disabled`,
|
||||
`subtitle_conversion_unsupported`.
|
||||
|
||||
*Subtitle policy:* `subtitle_burn_in_source_unsupported`,
|
||||
`subtitle_codec_unsupported`, `subtitle_track_invalid`,
|
||||
`subtitle_track_unavailable`, `subtitle_unavailable_in_version`.
|
||||
|
||||
*Transport and session:* `internal_error`, `session_expired`,
|
||||
`subtitle_artifact_unavailable`, `capacity_unavailable`,
|
||||
`audio_transcoding_disabled`,
|
||||
`transcode_start_failed`, `transcode_node_unavailable`,
|
||||
`transcode_node_capability_unavailable`, `track_unavailable`,
|
||||
`invalid_seek_position`, `invalid_replan`, `seek_reanchor_route_changed`,
|
||||
`seek_reanchor_recipe_unavailable`,
|
||||
`seek_reanchor_intent_mismatch`, `seek_failure_recovery_intent_mismatch`,
|
||||
`policy_denied`.
|
||||
|
||||
### 7.4 Route event names
|
||||
|
||||
`plan_selected`, `plan_invalidated`, `plan_failed`, `first_frame`, `terminal`,
|
||||
`stopped`, `runtime_correction_applied`, `runtime_correction_succeeded`,
|
||||
`runtime_correction_failed`, `seek_reanchor_requested`, `seek_reanchored`.
|
||||
|
||||
### 7.5 Diagnostics allowlist
|
||||
|
||||
Route-event `diagnostics` keys the server retains. Everything else is dropped;
|
||||
every value is truncated to 256 characters.
|
||||
|
||||
`decoder_name`, `decoder_init_ms`, `first_frame_ms`, `device_model`,
|
||||
`requested_quality`, `effective_quality`, `pcm_recovery`, `retry_outcome`,
|
||||
`replan_request_id`, `video_mime`, `video_codecs`, `video_width`, `video_height`,
|
||||
`color_transfer`, `color_range`, `error_code`, `error_code_name`, `error_cause`,
|
||||
`transformation_name`, `transformation_version`, `transformation_stage`,
|
||||
`input_dv_profile`, `output_dv_profile`, `rpu_converted_count`,
|
||||
`rpu_failed_count`, `el_nal_dropped_count`, `sample_count`,
|
||||
`transform_buffer_peak_bytes`, `requested_media_file_id`,
|
||||
`effective_media_file_id`, `audio_output_mode`, `audio_mime`, `audio_channels`,
|
||||
`audio_decoder_name`, `correction_id`, `correction_stage`, `network_transport`,
|
||||
`network_metered`, `network_validated`, `bandwidth_estimate_kbps`,
|
||||
`link_downstream_kbps`, `target_source_position_seconds`, `reason`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Track identity and the subtitle ordinal space
|
||||
|
||||
Every track is addressed as `file:{media_file_id}:{kind}:{ordinal}` — for
|
||||
example `file:42:audio:0`. When a client sends both an id and an index they must
|
||||
agree; the server rejects a disagreeing pair rather than picking one.
|
||||
|
||||
Subtitles occupy a single **combined ordinal space** spanning three sources, in
|
||||
three dense consecutive ranges:
|
||||
|
||||
1. **External** sidecar files, in catalog order — ordinals `0 … E-1`
|
||||
2. **Embedded** container streams, in container stream order — `E … E+M-1`
|
||||
3. **Downloaded** subtitles, in `created_at` order — `E+M … E+M+D-1`
|
||||
|
||||
The space is dense and gap-free, and this is load-bearing. A track that has no
|
||||
sidecar representation the stream handler can serve — a DVD or DVB bitmap stream
|
||||
— **keeps its ordinal** and is published with `delivery: "burn_in_only"` and no
|
||||
`url`. Omitting it would leave a hole, and any client deriving the
|
||||
downloaded-track base by counting published URLs would then undercount and
|
||||
address the wrong track. (That was a real bug; this rule is the fix.)
|
||||
|
||||
`playback_plan.subtitle.inventory` is the authoritative list. A client selects a
|
||||
track by echoing an entry's `track_id` or `combined_index`. It must never derive
|
||||
an ordinal by counting tracks, summing array lengths, or taking `max(index)+1`.
|
||||
|
||||
Each entry carries `source` (`external` | `embedded` | `downloaded`), `delivery`
|
||||
(`sidecar` | `burn_in_only`), the `forced` / `default` / `hearing_impaired`
|
||||
flags, a `url` when deliverable, and a `font_bundle_url` for embedded ASS tracks
|
||||
with attachments. `default` reflects the source container's own default flag, so
|
||||
only embedded and external tracks can carry it — a downloaded subtitle is never
|
||||
`default`. `url` is present only on `sidecar` tracks, and only once a session
|
||||
exists to scope it to — but it does not depend on the current selection: a start
|
||||
or replan that resolves to `subtitle.mode: "off"` still publishes every sidecar
|
||||
entry with its fetchable `url`, so a client can build its full subtitle menu
|
||||
without first asking for a plan it does not want.
|
||||
|
||||
`subtitle.mode` is `off`, `render` (client draws the sidecar), `convert` (server
|
||||
transcodes it to a client-renderable format first — always to WebVTT, served as
|
||||
`text/vtt` at a `.vtt` URL), or `burn_in` (rendered into the video, which forces
|
||||
a transcode).
|
||||
|
||||
The sidecar URL suffix is part of the representation contract, not decoration.
|
||||
An embedded `hdmv_pgs_subtitle`/PGS sidecar is lossless binary PGS at a `.sup`
|
||||
URL with `application/octet-stream`; cached full-track responses support `HEAD`
|
||||
and byte ranges. Text conversion is always WebVTT at `.vtt`, while lossless
|
||||
ASS/SSA uses `.ass`. A suffix that does not match the selected track or a valid
|
||||
conversion is rejected with `415` rather than returning bytes of a different
|
||||
type under the requested extension.
|
||||
|
||||
---
|
||||
|
||||
## 9. Plan identity
|
||||
|
||||
The server mints both identifiers. **Clients treat both as opaque, case-sensitive
|
||||
tokens and never implement either identity algorithm.** Their wire prefixes and
|
||||
lengths are validation syntax, not a derivation recipe.
|
||||
|
||||
`plan_id` identifies the server's playback decision. It is stable when the same
|
||||
attempt produces the same source, delivery, recipe, tracks, subtitle mode,
|
||||
transformations, applied quirks, and recipe revision. A change to any of those
|
||||
inputs produces a different identity.
|
||||
|
||||
`plan_attempt_key` is the replan loop guard for a plan as attempted on one output
|
||||
route with a set of client-reported local mutations. The server canonicalizes
|
||||
order-insensitive inputs internally. The client:
|
||||
|
||||
1. stores the exact key from `playback_plan.plan_attempt_key`;
|
||||
2. echoes it unchanged as `plan_attempt_key` when reporting that plan;
|
||||
3. adds the unchanged token to `attempted_plan_keys` after the plan fails; and
|
||||
4. never case-folds, parses, truncates, hashes, or synthesizes a replacement.
|
||||
|
||||
`internal/playback/testdata/protocol_v3/attempt_keys.json` contains opaque
|
||||
cross-message vectors: a server-emitted token, the exact replan echo, and the
|
||||
loop-rejection result. The generator computes the server token internally but
|
||||
does not publish its preimage.
|
||||
|
||||
---
|
||||
|
||||
## 10. Quality
|
||||
|
||||
`playback_plan.available_qualities` is the menu. The client renders it and, on
|
||||
selection, sends a `quality_change` replan with the entry's `label`. It does not
|
||||
compute rungs.
|
||||
|
||||
The source rung is always present, labelled `original`, with
|
||||
`preserves_source: true`. Transcode rungs are added only below the source's own
|
||||
height, and only when HLS is available to the client, transcoding is enabled,
|
||||
4K transcoding is permitted for a 4K source, and the source is not HDR. Ladder
|
||||
bitrates:
|
||||
|
||||
| Rung | kbps |
|
||||
| --- | --- |
|
||||
| 2160p | 20000 |
|
||||
| 1080p | 6000 |
|
||||
| 720p | 2000 |
|
||||
| 480p | 1500 |
|
||||
|
||||
Registry availability is deliberately *not* consulted when building the menu: a
|
||||
capability check there could trigger lazy node fetches that a source-preserving
|
||||
start must never pay for. A rung whose toolchain turns out to be missing degrades
|
||||
to a retryable terminal at replan time instead.
|
||||
|
||||
Audio-only sources publish a single `original` rung — quality rungs are a video
|
||||
concept.
|
||||
|
||||
`quality_preference` accepts `auto`, `original` (aliases `source`, `max`), and
|
||||
`2160p` / `1080p` / `720p` / `480p` with the obvious aliases (`4k`, `uhd`, `fhd`,
|
||||
`hd`, `sd`). An unrecognized value normalizes to `auto` and the response carries
|
||||
the `quality_preference_normalized` warning rather than an error.
|
||||
|
||||
---
|
||||
|
||||
## 11. Transformations
|
||||
|
||||
A transformation is a named, versioned media operation with claims attached.
|
||||
|
||||
| Name | Executor | Recipe version | Promises | Claims |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `audio_to_aac` | `server` | `1` | — | `audio_decode` |
|
||||
| `video_to_h264` | `server` | `2` | `sdr` output | `h264_decode` |
|
||||
| `server_dv7_to_hdr10` | `server` | `1` | `hdr10` output | `dolby_vision_metadata_removed`, `hdr10_base_layer_preserved`, `enhancement_layer_discarded` |
|
||||
|
||||
They are advertised only if the installed FFmpeg actually has the required
|
||||
capability, probed once at startup:
|
||||
|
||||
| Transformation | Probe |
|
||||
| --- | --- |
|
||||
| `server_dv7_to_hdr10` | `ffmpeg -bsfs` contains `dovi_rpu` |
|
||||
| `audio_to_aac` | `ffmpeg -encoders` contains an `aac` encoder |
|
||||
| `video_to_h264` | `ffmpeg -encoders` contains any of `libx264`, `h264_qsv`, `h264_vaapi`, `h264_nvenc`, `h264_videotoolbox` |
|
||||
|
||||
`GET /playback/capability` reports the *local* probe only. A deployment with
|
||||
pooled transcode nodes may still plan an HLS route using a transformation those
|
||||
nodes advertise but the local FFmpeg lacks, so the capability list is a floor,
|
||||
not a ceiling — one more reason a client must not precompute routes from it.
|
||||
|
||||
An unavailable transformation is not silently skipped at plan time: it produces
|
||||
its own terminal reason (`dv_conversion_unsupported`,
|
||||
`audio_conversion_unsupported`, `video_conversion_unsupported`) so the client
|
||||
learns which conversion was missing rather than seeing a generic refusal.
|
||||
|
||||
A client may advertise its *own* transformations in a delivery's
|
||||
`transformations[]` with `executor: "client"` — Dolby Vision profile 7 → 8.1
|
||||
conversion, for instance. The server accepts a client executor only when that
|
||||
delivery is both `enabled` and `supported_on_device` **and** the request's
|
||||
top-level `client_features` includes `client_video_transformations_v1`.
|
||||
Duplicate `executor:name:recipe_version` triples are rejected. Client
|
||||
transformations participate in plan identity exactly like server ones, so a
|
||||
client that changes its transform version invalidates its prior attempt keys —
|
||||
which is the intent.
|
||||
|
||||
---
|
||||
|
||||
## 12. Conformance
|
||||
|
||||
Three artifacts, in decreasing order of authority:
|
||||
|
||||
1. **`internal/playback/testdata/protocol_v3/`** — golden fixtures generated by
|
||||
`cmd/playbackfixtures` from the live server types. `make playback-fixtures`
|
||||
regenerates them; `make verify-playback-fixtures` fails CI if they are stale.
|
||||
Android and Apple CI vendor these and compare against them as **opaque
|
||||
expected output**. The direction of authority is inverted from where this
|
||||
protocol started: the server defines the contract and clients prove
|
||||
conformance, not the reverse. `conformance_matrix.json` covers the release
|
||||
train's evidence tiers, delivery fallback chain, replan operations and
|
||||
idempotency, quality ladder, audio-only route, HDR/Dolby Vision decisions,
|
||||
audio adaptation and exact-layout passthrough, text/bitmap subtitle policy,
|
||||
failure recovery, restart replay, capacity cleanup, output change, route
|
||||
event limits, opaque loop guard, and legacy-upgrade response in one
|
||||
generated cross-client corpus.
|
||||
2. **`docs/design/schemas/playback-v3/`** — JSON Schemas for every wire body,
|
||||
with valid and invalid fixtures. Every bound mirrors a server validator, so a
|
||||
body these schemas reject is a body the server rejects.
|
||||
`internal/playback/contract` enforces that the schemas, the Go types, and the
|
||||
golden fixtures agree.
|
||||
3. **This document** — the reasoning behind the above, and the normative source
|
||||
for anything the schemas cannot express (idempotency semantics, the timeline
|
||||
model, evidence-tier strictness, ordinal density).
|
||||
|
||||
A client that decodes the golden fixtures, echoes attempt keys byte-for-byte,
|
||||
and round-trips a replan without computing an identity is conforming.
|
||||
@@ -515,7 +515,7 @@ ASCII blocks render anywhere.
|
||||
|
||||
```text
|
||||
┌─────────────────────────── CENTRAL (mode=server) ───────────────────────────┐
|
||||
Client │ RequireAuth /playback/start /playback/transcode/start (mint token) │
|
||||
Client │ RequireAuth /playback/start /playback/{sid}/replan (mint token) │
|
||||
(web / android / │ native serve: /playback/transcode/{sid}/master.m3u8?st= /stream/{sid}?st= │
|
||||
apple / jellyfin) │ Admin Stop/Terminate → write session-deny marker (event, not a timer) │
|
||||
└───────────┬──────────────────────────┬───────────────────────┬──────────────┘
|
||||
@@ -564,12 +564,13 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Central server (integrated)
|
||||
participant PG as Postgres (access)
|
||||
Note over C,S: transcode start
|
||||
C->>S: POST /playback/transcode/start (RequireAuth)
|
||||
Note over C,S: start — the plan decides this is a transcode
|
||||
C->>S: POST /playback/start (RequireAuth, protocol_version 3)
|
||||
S->>PG: resolve file + access
|
||||
S->>S: PlanPlaybackV3 → delivery = server_transcode_hls
|
||||
S->>S: StartTranscode (ffmpeg) + RegisterTranscodeSession
|
||||
S->>S: card = NewRecipeCard(opts); token = Sign(card.ToClaims(), 24h)
|
||||
S-->>C: 202 manifestURL = /playback/transcode/{sid}/master.m3u8?st=TOKEN
|
||||
S-->>C: 201 plan.url = /playback/transcode/{sid}/master.m3u8?st=TOKEN
|
||||
Note over C,S: hot path — NO Postgres, NO lease (live session in memory)
|
||||
C->>S: GET .../master.m3u8?st=TOKEN
|
||||
S->>S: LoadOrReconstructSession → live hit → BuildPlaybackManifest (segments carry ?st)
|
||||
@@ -599,9 +600,9 @@ sequenceDiagram
|
||||
participant N as Proxy node
|
||||
participant T as Transcode node
|
||||
participant R as Redis
|
||||
C->>S: POST /playback/transcode/start (RequireAuth)
|
||||
C->>S: POST /playback/start (RequireAuth, protocol_version 3)
|
||||
S->>S: card(full recipe, tnode=T); token = Sign(card.ToClaims())
|
||||
S-->>C: manifestURL = {N}/stream/transcode/TOKEN/master.m3u8
|
||||
S-->>C: plan.url = {N}/stream/transcode/TOKEN/master.m3u8
|
||||
loop manifest + segments
|
||||
C->>N: GET /stream/transcode/TOKEN/...
|
||||
N->>N: verifyToken(TOKEN)
|
||||
|
||||
@@ -33,6 +33,11 @@ justification and falls back to the Deprecation/Sunset flow like anything else.
|
||||
|---|---|---|
|
||||
| String `GET`/`PUT`/`DELETE /api/v1/settings…`, the unknown-key extension bag, preference fields on profile/library/series DTOs | Cross-platform settings contract, [design](../superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md) | Replaced wholesale by the typed settings contract. Deferring past lock would mean carrying the Deprecation/Sunset surface *and* the untyped key bag — which lets any client invent a production setting the server stores unvalidated — through the deprecation window, which is the exact surface the contract exists to close. |
|
||||
| The ten string-registry admin user-settings routes: `GET /api/v1/admin/users/{id}/settings`, `GET /api/v1/admin/users/{id}/settings/{key}`, `PUT /api/v1/admin/users/{id}/settings/{key}`, `DELETE /api/v1/admin/users/{id}/settings/{key}`, `GET /api/v1/admin/users/{id}/device-settings`, `GET /api/v1/admin/users/{id}/device-settings/{key}`, `DELETE /api/v1/admin/users/{id}/device-settings/{key}`, `PUT /api/v1/admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}`, `DELETE /api/v1/admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}`, `DELETE /api/v1/admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings` | Cross-platform settings contract, [design](../superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md) | The admin projection of the removal above: these routes read and wrote the string registry the contract replaces. Their canonical successors are `GET /api/v1/admin/users/{id}/settings/values` (every stored value across all scopes) and `PUT`/`DELETE /api/v1/admin/users/{id}/settings/values/{key}` at an explicit scope, sharing the session routes' validation. Keeping the string routes past lock would preserve an admin-only write path into the untyped bag after the user-facing one closed. |
|
||||
| The legacy (pre-v3) request and response bodies of `POST /api/v1/playback/start`. The route stays; a body that does not declare `protocol_version: 3` now gets `426 client_upgrade_required` instead of a legacy plan | Playback protocol v3, [spec](playback-protocol-v3.md), [plan](../superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md) | v3 is a platform-neutral contract that moves route selection, quality laddering and track choice server-side. The legacy body carried the opposite model — the client posted a decision it had already made. Running both means every planner change has to be made twice, in two shapes that disagree about who decides, and the legacy shape is the one the client-specific bugs live in. A deprecation window would extend that duplication across the whole window for a protocol no shipping client will still speak by lock. |
|
||||
| `POST /api/v1/playback/transcode/start` | Playback protocol v3, [spec](playback-protocol-v3.md) | Client-posted transcode recipes. Superseded by the `quality_change` replan operation: the client sends a label from `playback_plan.available_qualities` and the server picks the encode. Keeping the endpoint keeps a second, unvalidated way to start a transcode that bypasses plan identity entirely. |
|
||||
| `PATCH /api/v1/playback/{session_id}/audio` | Playback protocol v3, [spec](playback-protocol-v3.md) | Superseded by the `track_change` replan operation, which changes the audio track *and* returns the resulting plan. The PATCH mutated the session without re-planning, so a track change that invalidated the route left the client playing a plan the server no longer agreed with. |
|
||||
| `409 protocol_disabled` on `POST /api/v1/playback/route-events`, and the `"enabled": false` shape of `GET /api/v1/playback/capability` | Playback protocol v3, [spec](playback-protocol-v3.md) | Both described a server with v3 switched off. With v3 the only playback protocol that state cannot exist — "disabled" would mean "no playback at all". The `enabled` field itself is kept and is constant `true`, so clients that feature-detect against it keep working; only the negative shape and the status code go. |
|
||||
| The draft-v3 platform-specific wire vocabulary: `ClientPlaybackContextV3.features`, `.platform`, and `.engines`; `PlanV3.engine`; `output_route_generation` in start, replan, output-context, and route-event bodies; Android device/build fields (`brand`, `device`, `product`, `soc_*`, `build_*`, `security_patch`, `sdk_int`, `abis`); and the `media3_only` / `detailed_decode_capabilities` feature tokens | Platform-neutral playback protocol v3, [spec](playback-protocol-v3.md), [plan](../superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md) | These names exposed one client's implementation as the cross-platform contract. Before v1 lock they are replaced by neutral delivery classes, evidence tiers, `device.platform` / `device.os_version` / bounded `platform_details`, opaque `output_context_id`, and top-level feature advertisement. Carrying both drafts through lock would force every client to translate Media3-specific aliases indefinitely and leave two conflicting sources of capability truth. |
|
||||
|
||||
Feature-detection precedent: clients discover which metadata providers (including the
|
||||
built-in NFO provider, #216) apply to a library type via
|
||||
|
||||
@@ -361,7 +361,7 @@ Borrows the *behavior* of the requests gate (status endpoint + per-call
|
||||
guard that rereads settings) but stores keys in the general `server_settings`
|
||||
store — requests uses its own dedicated table, which diagnostics doesn't
|
||||
need. Key naming follows existing namespaces (`opslog.*`,
|
||||
`audiobooks.enabled`, `playback.protocol_v3_enabled`):
|
||||
`audiobooks.enabled`, `playback.*`):
|
||||
|
||||
- `diagnostics.uploads_enabled` (bool, default `false`)
|
||||
- `diagnostics.max_bundle_bytes` (default 10 MiB compressed)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
The canonical playback protocol v3 wire contract lives in `v3/`.
|
||||
Clients vendor the schema files and fixtures from this directory; each schema is self-contained and refers only to its own `$defs`, so a client can vendor one body at a time.
|
||||
The wire fixtures under `v3/fixtures/valid/` are copies of the matching server-generated golden bodies in `internal/playback/testdata/protocol_v3/`; treat them as opaque expected output rather than hand-edited examples, and regenerate them from the server rather than editing them in place. The golden directory also contains non-wire cross-message fixtures such as `attempt_keys.json`, `subtitle_inventory.json`, and `conformance_matrix.json`; clients vendor those directly from the generator output.
|
||||
`error-response.schema.json` describes the common non-2xx envelope; its golden fixture is the exact `426 client_upgrade_required` response to a legacy start body.
|
||||
Every bound in these schemas mirrors a server validator, so a body this directory rejects is a body the server rejects.
|
||||
Identity fields the server computes — `plan_id`, `plan_attempt_key` — are opaque to clients: store them, echo them back on replan and route events, and never parse or recompute them.
|
||||
Within v3, changes are additive only: new optional fields, and new enum values after coordinated client support.
|
||||
Breaking changes require a new versioned contract directory and explicit server support.
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/playback-v3/v3/capability-response.schema.json",
|
||||
"title": "Silo Playback Protocol v3 Capability Response",
|
||||
"description": "Response body of GET /api/v1/playback/capability, mirroring CapabilityResponseV3 in internal/playback/protocol_v3.go. A client reads this before its first start request so it plans against what the server actually advertises instead of a compiled-in assumption. Protocol v3 is the server's only playback protocol, so enabled is always true; there is no disabled response shape.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled",
|
||||
"protocol_versions",
|
||||
"features",
|
||||
"deliveries",
|
||||
"transformations"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
},
|
||||
"protocol_versions": {
|
||||
"type": "array",
|
||||
"description": "Protocol versions this server speaks. A client picks the highest it supports rather than assuming a single value, which is what keeps a future v4 from breaking v3 clients.",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"contains": {
|
||||
"const": 3
|
||||
},
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"description": "Server feature flags. The list grows additively within v3, so an unrecognized entry is expected and must be ignored, not treated as an error.",
|
||||
"minItems": 8,
|
||||
"uniqueItems": true,
|
||||
"allOf": [
|
||||
{ "contains": { "const": "playback_plan_v3" } },
|
||||
{ "contains": { "const": "neutral_playback_v3_contract_v1" } },
|
||||
{ "contains": { "const": "layout_aware_passthrough" } },
|
||||
{ "contains": { "const": "playback_route_diagnostics" } },
|
||||
{ "contains": { "const": "device_quirks_v1" } },
|
||||
{ "contains": { "const": "seek_reanchor_v1" } },
|
||||
{ "contains": { "const": "direct_stream_resume_v1" } },
|
||||
{ "contains": { "const": "plan_source_duration_v1" } }
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"deliveries": {
|
||||
"type": "array",
|
||||
"description": "Serving routes the planner may return. The list contains the server's currently executable delivery classes and grows additively.",
|
||||
"minItems": 4,
|
||||
"uniqueItems": true,
|
||||
"allOf": [
|
||||
{ "contains": { "const": "original_http" } },
|
||||
{ "contains": { "const": "server_remux_progressive" } },
|
||||
{ "contains": { "const": "server_remux_hls" } },
|
||||
{ "contains": { "const": "server_transcode_hls" } }
|
||||
],
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"original_http",
|
||||
"server_remux_progressive",
|
||||
"server_remux_hls",
|
||||
"server_transcode_hls"
|
||||
]
|
||||
}
|
||||
},
|
||||
"transformations": {
|
||||
"type": "array",
|
||||
"description": "Server-executed transformations available to the planner. Client-executed transformations are declared by the client in its start request, not listed here.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/transformation"
|
||||
}
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reserved optional diagnostic for a future non-rollout condition. It does not disable protocol v3; enabled remains true."
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"transformation": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"executor",
|
||||
"recipe_version",
|
||||
"validated_claims"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"executor": {
|
||||
"type": "string",
|
||||
"description": "Capability responses advertise transformations installed on the server. Client-executed transformations are declared in requests instead.",
|
||||
"const": "server"
|
||||
},
|
||||
"recipe_version": {
|
||||
"type": "string",
|
||||
"description": "Identity of the recipe, not a semantic version to compare. A client matches it exactly or declines the transformation.",
|
||||
"minLength": 1,
|
||||
"maxLength": 32
|
||||
},
|
||||
"validated_claims": {
|
||||
"type": "array",
|
||||
"description": "Neutral statements about the bytes the transformation produces, so a client reasons about the output without knowing the server's toolchain.",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/playback-v3/v3/decision-response.schema.json",
|
||||
"title": "Silo Playback Protocol v3 Decision Response",
|
||||
"description": "Response body of POST /api/v1/playback/start and POST /api/v1/playback/{session_id}/replan. Shapes mirror DecisionResponseV3 and PlanV3 in internal/playback/protocol_v3.go. A playable outcome carries playback_plan; adaptation_unavailable carries terminal. Enum lists are the values the server emits today and grow additively within v3, so a client must tolerate an unrecognized member of a described-but-open list rather than treating the plan as unusable.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"server_features",
|
||||
"outcome"
|
||||
],
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"server_features": {
|
||||
"type": "array",
|
||||
"description": "Additive feature tokens supported by this server. Unknown entries must be ignored.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playable",
|
||||
"adaptation_unavailable"
|
||||
]
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"playback_plan": {
|
||||
"$ref": "#/$defs/plan"
|
||||
},
|
||||
"terminal": {
|
||||
"$ref": "#/$defs/terminal"
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"outcome": {
|
||||
"const": "playable"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"session_id",
|
||||
"playback_plan"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"terminal"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"outcome": {
|
||||
"const": "adaptation_unavailable"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"terminal"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"not": {
|
||||
"required": [
|
||||
"session_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"required": [
|
||||
"playback_plan"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"terminal": {
|
||||
"type": "object",
|
||||
"description": "Why no route exists. `retryable` distinguishes a condition that may clear (a missing toolchain, incomplete metadata) from one that will not for this client and source.",
|
||||
"required": [
|
||||
"reason",
|
||||
"message",
|
||||
"retryable"
|
||||
],
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"retryable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"track_identity": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Combined-ordinal track identity `file:{file_id}:{audio|subtitle}:{ordinal}`."
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"transformation": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"executor",
|
||||
"recipe_version",
|
||||
"validated_claims"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"executor": {
|
||||
"type": "string",
|
||||
"description": "Who runs it: `server` before the bytes leave the server, `client` as the client's own responsibility.",
|
||||
"enum": [
|
||||
"client",
|
||||
"server"
|
||||
]
|
||||
},
|
||||
"recipe_version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"validated_claims": {
|
||||
"type": "array",
|
||||
"description": "Neutral statements about the produced bytes, not client-framework decoder names.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"plan": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"plan_id",
|
||||
"plan_attempt_key",
|
||||
"delivery",
|
||||
"stream",
|
||||
"timeline",
|
||||
"selected_tracks",
|
||||
"effective_recipe",
|
||||
"claims",
|
||||
"subtitle",
|
||||
"transformations",
|
||||
"applied_quirks",
|
||||
"runtime_corrections",
|
||||
"available_qualities",
|
||||
"degradation_warnings",
|
||||
"decision_reason",
|
||||
"requested_media_file_id",
|
||||
"effective_media_file_id",
|
||||
"source",
|
||||
"subtitle_fidelity_policy"
|
||||
],
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "string",
|
||||
"description": "Deterministic server-computed plan identity, `plan:` followed by 32 hex characters. Opaque to clients: echo it, never parse or recompute it.",
|
||||
"pattern": "^plan:[0-9a-f]{32}$"
|
||||
},
|
||||
"plan_attempt_key": {
|
||||
"type": "string",
|
||||
"description": "Server-computed opaque loop-prevention token, always `v3:` prefixed. Clients store the keys of attempted plans and echo them in attempted_plan_keys on replan; they never compute keys themselves.",
|
||||
"pattern": "^v3:",
|
||||
"minLength": 8
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"delivery": {
|
||||
"type": "string",
|
||||
"description": "The plan's serving route. Clients negotiate the coarser delivery classes (original_http, progressive, hls); these are the finer-grained plan outcomes those classes fold onto.",
|
||||
"enum": [
|
||||
"original_http",
|
||||
"server_remux_progressive",
|
||||
"server_remux_hls",
|
||||
"server_transcode_hls"
|
||||
]
|
||||
},
|
||||
"stream": {
|
||||
"$ref": "#/$defs/stream"
|
||||
},
|
||||
"timeline": {
|
||||
"$ref": "#/$defs/timeline"
|
||||
},
|
||||
"selected_tracks": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {
|
||||
"$ref": "#/$defs/track_identity"
|
||||
},
|
||||
"subtitle": {
|
||||
"$ref": "#/$defs/track_identity"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"effective_recipe": {
|
||||
"$ref": "#/$defs/effective_recipe"
|
||||
},
|
||||
"claims": {
|
||||
"$ref": "#/$defs/claims"
|
||||
},
|
||||
"subtitle": {
|
||||
"$ref": "#/$defs/subtitle_decision"
|
||||
},
|
||||
"transformations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/transformation"
|
||||
}
|
||||
},
|
||||
"applied_quirks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/applied_quirk"
|
||||
}
|
||||
},
|
||||
"runtime_corrections": {
|
||||
"type": "array",
|
||||
"description": "Client-executed corrections this plan depends on. A client that cannot run a listed correction must not select the plan.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"available_qualities": {
|
||||
"type": "array",
|
||||
"description": "Server ladder rungs valid for this source and client, so a client renders a quality menu without owning a bitrate table. The entry with preserves_source true pins the source rung.",
|
||||
"minItems": 1,
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": ["label", "preserves_source"],
|
||||
"properties": {
|
||||
"label": { "const": "original" },
|
||||
"preserves_source": { "const": true }
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/$defs/available_quality"
|
||||
}
|
||||
},
|
||||
"degradation_warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/degradation_warning"
|
||||
}
|
||||
},
|
||||
"decision_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"requested_media_file_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"effective_media_file_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Differs from requested_media_file_id when the server routed to an alternate edition of the same title. Track identities in this plan belong to the effective file."
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/$defs/source_descriptor"
|
||||
},
|
||||
"subtitle_fidelity_policy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"require_authored_fidelity",
|
||||
"allow_simplified_rendering"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"stream": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url",
|
||||
"protocol",
|
||||
"headers",
|
||||
"header_refresh"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"http_progressive",
|
||||
"hls"
|
||||
]
|
||||
},
|
||||
"container": {
|
||||
"type": "string"
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "string",
|
||||
"description": "What the transport will actually serve. A declared-tier client probes this before attaching a source buffer, so an audio-only remux advertises audio/mp4 rather than video/mp4."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"header_refresh": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"session"
|
||||
]
|
||||
},
|
||||
"header_refresh_url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"timeline": {
|
||||
"type": "object",
|
||||
"description": "Anchoring for the delivered stream. source_start_seconds is the position in the source, player_start_seconds where the player should start within the delivered timeline; the two differ when the stream is re-anchored at a seek point.",
|
||||
"required": [
|
||||
"source_start_seconds",
|
||||
"stream_origin_seconds",
|
||||
"player_start_seconds",
|
||||
"timeline_offset_seconds",
|
||||
"can_seek_anywhere",
|
||||
"seek_restoration"
|
||||
],
|
||||
"properties": {
|
||||
"source_start_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"stream_origin_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"player_start_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"timeline_offset_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"seek_window_start_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"seek_window_end_seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"can_seek_anywhere": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"seek_restoration": {
|
||||
"type": "string",
|
||||
"description": "Which clock a resume position is expressed in: `player_position` for a full-timeline stream, `source_position` for one re-anchored at a seek point.",
|
||||
"enum": [
|
||||
"player_position",
|
||||
"source_position"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"effective_recipe": {
|
||||
"type": "object",
|
||||
"description": "What the delivered bytes actually are, after any adaptation. Fields the server has no value for are omitted rather than sent as zero.",
|
||||
"properties": {
|
||||
"video_codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"audio_codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"width": {
|
||||
"type": "integer"
|
||||
},
|
||||
"height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"frame_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"bitrate_kbps": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dynamic_range": {
|
||||
"type": "string"
|
||||
},
|
||||
"audio_channels": {
|
||||
"type": "integer"
|
||||
},
|
||||
"audio_layout": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"claims": {
|
||||
"type": "object",
|
||||
"description": "What the server promises about the delivered bytes. A client renders its HDR/passthrough/subtitle affordances from these rather than re-deriving them from the source descriptor.",
|
||||
"required": [
|
||||
"video",
|
||||
"audio",
|
||||
"subtitles"
|
||||
],
|
||||
"properties": {
|
||||
"video": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"hdr10",
|
||||
"hdr10_plus",
|
||||
"hlg",
|
||||
"dolby_vision"
|
||||
],
|
||||
"properties": {
|
||||
"hdr10": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hdr10_plus": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hlg": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dolby_vision": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dolby_vision_reason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"audio": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"passthrough",
|
||||
"atmos_preserved"
|
||||
],
|
||||
"properties": {
|
||||
"codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"passthrough": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"atmos_preserved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dts_variant": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"subtitles": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ass_styling_preserved",
|
||||
"bitmap_overlay",
|
||||
"bitmap_sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"ass_styling_preserved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"bitmap_overlay": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"bitmap_sidecar": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"subtitle_decision": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"mode",
|
||||
"inventory"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off",
|
||||
"render",
|
||||
"convert",
|
||||
"burn_in"
|
||||
]
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"description": "The prepared sidecar for the selected track. timing_origin_seconds is the source position the artifact's cue timestamps are relative to.",
|
||||
"required": [
|
||||
"url",
|
||||
"mime_type",
|
||||
"format",
|
||||
"timing_origin_seconds"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"timing_origin_seconds": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"inventory": {
|
||||
"type": "array",
|
||||
"description": "Authoritative, dense, gap-free combined-ordinal track list for the effective source: external tracks first, then embedded, then downloaded. A client selects by echoing an entry's track_id or combined_index and never derives an ordinal by counting, summing track arrays, or taking max(index)+1.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/subtitle_inventory_item"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"subtitle_inventory_item": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"track_id",
|
||||
"combined_index",
|
||||
"source",
|
||||
"forced",
|
||||
"default",
|
||||
"hearing_impaired",
|
||||
"delivery"
|
||||
],
|
||||
"properties": {
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"combined_index": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"external",
|
||||
"embedded",
|
||||
"downloaded"
|
||||
]
|
||||
},
|
||||
"codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"forced": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hearing_impaired": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"delivery": {
|
||||
"type": "string",
|
||||
"description": "`sidecar` tracks are fetchable from their published url. A `burn_in_only` track has no client-fetchable representation and carries no url, but still occupies its ordinal and stays selectable — omitting it would leave a hole that makes every later ordinal wrong.",
|
||||
"enum": [
|
||||
"sidecar",
|
||||
"burn_in_only"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_bundle_url": {
|
||||
"type": "string",
|
||||
"description": "Attachment bundle needed to render an embedded ASS/SSA track with its authored typesetting."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"applied_quirk": {
|
||||
"type": "object",
|
||||
"description": "A device-specific decision the planner made on top of the generic route. registry_revision identifies the quirk registry the decision came from.",
|
||||
"required": [
|
||||
"id",
|
||||
"registry_revision",
|
||||
"action"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"registry_revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"action": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"available_quality": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"preserves_source"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"bitrate_kbps": {
|
||||
"type": "integer"
|
||||
},
|
||||
"preserves_source": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"degradation_warning": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"source_descriptor": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"media_file_id",
|
||||
"hdr10_plus",
|
||||
"dv_enhancement_layer"
|
||||
],
|
||||
"properties": {
|
||||
"media_file_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"duration_seconds": {
|
||||
"type": "number",
|
||||
"description": "Full runtime of this source, independent of where the delivery's timeline is anchored. Absent means the server does not know the runtime; it is omitted rather than null so clients that coerce null to a numeric default do not read it as zero. Do not substitute the playback engine's reported duration — on an HLS copy remux the engine reports the length produced so far."
|
||||
},
|
||||
"container": {
|
||||
"type": "string"
|
||||
},
|
||||
"video_codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"video_profile": {
|
||||
"type": "string"
|
||||
},
|
||||
"video_level": {
|
||||
"type": "integer"
|
||||
},
|
||||
"bit_depth": {
|
||||
"type": "integer"
|
||||
},
|
||||
"color_range": {
|
||||
"type": "string"
|
||||
},
|
||||
"width": {
|
||||
"type": "integer"
|
||||
},
|
||||
"height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"frame_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"bitrate_kbps": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dynamic_range": {
|
||||
"type": "string",
|
||||
"description": "Values the server emits today: sdr, hdr10, hdr10_plus, hlg, dolby_vision, and hdr_unknown for legacy rows that recorded only a file-level HDR flag."
|
||||
},
|
||||
"hdr10_plus": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dolby_vision_profile": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dv_bl_compat_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"dv_enhancement_layer": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"mel",
|
||||
"fel",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"audio_codec": {
|
||||
"type": "string"
|
||||
},
|
||||
"audio_channels": {
|
||||
"type": "integer"
|
||||
},
|
||||
"audio_layout": {
|
||||
"type": "string"
|
||||
},
|
||||
"video_copy_unsafe": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/playback-v3/v3/error-response.schema.json",
|
||||
"title": "Silo Playback Protocol v3 Error Response",
|
||||
"description": "Common non-2xx response body for playback-v3 endpoints. Clients branch on error and may display message. The valid fixture pins the 426 response returned to a legacy start body.",
|
||||
"type": "object",
|
||||
"required": ["error", "message"],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"operation": "failure_recovery",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"replan_request_id": "replan-golden-0001",
|
||||
"failed_plan_id": "plan:golden-0001",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "v3:0000000000000001",
|
||||
"attempted_plan_keys": [
|
||||
"v3:0000000000000001"
|
||||
],
|
||||
"attempt_count": 9,
|
||||
"quality_preference": "auto",
|
||||
"position_seconds": 42.5,
|
||||
"metered": true,
|
||||
"bandwidth_estimate_kbps": 3500,
|
||||
"bandwidth_cap_kbps": 4000,
|
||||
"selected_tracks": {
|
||||
"audio": {
|
||||
"id": "file:42:audio:0",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"failure": {
|
||||
"classification": "network_degraded"
|
||||
},
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"bandwidth_estimate_kbps": 99
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"feature_00",
|
||||
"feature_01",
|
||||
"feature_02",
|
||||
"feature_03",
|
||||
"feature_04",
|
||||
"feature_05",
|
||||
"feature_06",
|
||||
"feature_07",
|
||||
"feature_08",
|
||||
"feature_09",
|
||||
"feature_10",
|
||||
"feature_11",
|
||||
"feature_12",
|
||||
"feature_13",
|
||||
"feature_14",
|
||||
"feature_15",
|
||||
"feature_16",
|
||||
"feature_17",
|
||||
"feature_18",
|
||||
"feature_19",
|
||||
"feature_20",
|
||||
"feature_21",
|
||||
"feature_22",
|
||||
"feature_23",
|
||||
"feature_24",
|
||||
"feature_25",
|
||||
"feature_26",
|
||||
"feature_27",
|
||||
"feature_28",
|
||||
"feature_29",
|
||||
"feature_30",
|
||||
"feature_31",
|
||||
"feature_32",
|
||||
"feature_33",
|
||||
"feature_34",
|
||||
"feature_35",
|
||||
"feature_36",
|
||||
"feature_37",
|
||||
"feature_38",
|
||||
"feature_39",
|
||||
"feature_40",
|
||||
"feature_41",
|
||||
"feature_42",
|
||||
"feature_43",
|
||||
"feature_44",
|
||||
"feature_45",
|
||||
"feature_46",
|
||||
"feature_47",
|
||||
"feature_48",
|
||||
"feature_49",
|
||||
"feature_50",
|
||||
"feature_51",
|
||||
"feature_52",
|
||||
"feature_53",
|
||||
"feature_54",
|
||||
"feature_55",
|
||||
"feature_56",
|
||||
"feature_57",
|
||||
"feature_58",
|
||||
"feature_59",
|
||||
"feature_60",
|
||||
"feature_61",
|
||||
"feature_62",
|
||||
"feature_63",
|
||||
"feature_64"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"session_id": "11111111-1111-4111-8111-111111111111",
|
||||
"plan_id": "plan:golden-0001",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "v3:0000000000000001",
|
||||
"event": "first_frame",
|
||||
"output_context_id": "7",
|
||||
"diagnostics": {
|
||||
"key_00": "0",
|
||||
"key_01": "1",
|
||||
"key_02": "2",
|
||||
"key_03": "3",
|
||||
"key_04": "4",
|
||||
"key_05": "5",
|
||||
"key_06": "6",
|
||||
"key_07": "7",
|
||||
"key_08": "8",
|
||||
"key_09": "9",
|
||||
"key_10": "10",
|
||||
"key_11": "11",
|
||||
"key_12": "12",
|
||||
"key_13": "13",
|
||||
"key_14": "14",
|
||||
"key_15": "15",
|
||||
"key_16": "16",
|
||||
"key_17": "17",
|
||||
"key_18": "18",
|
||||
"key_19": "19",
|
||||
"key_20": "20",
|
||||
"key_21": "21",
|
||||
"key_22": "22",
|
||||
"key_23": "23",
|
||||
"key_24": "24",
|
||||
"key_25": "25",
|
||||
"key_26": "26",
|
||||
"key_27": "27",
|
||||
"key_28": "28",
|
||||
"key_29": "29",
|
||||
"key_30": "30",
|
||||
"key_31": "31",
|
||||
"key_32": "32"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"protocol_versions": [],
|
||||
"features": [],
|
||||
"deliveries": [],
|
||||
"transformations": [],
|
||||
"reason": "disabled"
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"operation": "failure_recovery",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"replan_request_id": "replan-golden-0001",
|
||||
"failed_plan_id": "plan:golden-0001",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "v3:0000000000000001",
|
||||
"attempted_plan_keys": [
|
||||
"v3:0000000000000001"
|
||||
],
|
||||
"attempt_count": 1,
|
||||
"quality_preference": "auto",
|
||||
"position_seconds": 42.5,
|
||||
"metered": true,
|
||||
"bandwidth_estimate_kbps": 3500,
|
||||
"bandwidth_cap_kbps": 4000,
|
||||
"selected_tracks": {
|
||||
"audio": {
|
||||
"id": "file:42:audio:0",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"error": "client_upgrade_required"
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"operation": "failure_recovery",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"replan_request_id": "replan-golden-0001",
|
||||
"failed_plan_id": "plan:golden-0001",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "0000000000000001",
|
||||
"attempted_plan_keys": [
|
||||
"v3:0000000000000001"
|
||||
],
|
||||
"attempt_count": 1,
|
||||
"quality_preference": "auto",
|
||||
"position_seconds": 42.5,
|
||||
"metered": true,
|
||||
"bandwidth_estimate_kbps": 3500,
|
||||
"bandwidth_cap_kbps": 4000,
|
||||
"selected_tracks": {
|
||||
"audio": {
|
||||
"id": "file:42:audio:0",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"failure": {
|
||||
"classification": "network_degraded"
|
||||
},
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"server_features": [],
|
||||
"outcome": "playable"
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"detail_00": "0",
|
||||
"detail_01": "1",
|
||||
"detail_02": "2",
|
||||
"detail_03": "3",
|
||||
"detail_04": "4",
|
||||
"detail_05": "5",
|
||||
"detail_06": "6",
|
||||
"detail_07": "7",
|
||||
"detail_08": "8",
|
||||
"detail_09": "9",
|
||||
"detail_10": "10",
|
||||
"detail_11": "11",
|
||||
"detail_12": "12",
|
||||
"detail_13": "13",
|
||||
"detail_14": "14",
|
||||
"detail_15": "15",
|
||||
"detail_16": "16"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "-leading-dash-attempt",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"operation": "quality_change",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"replan_request_id": "replan-golden-0001",
|
||||
"failed_plan_id": "plan:golden-0001",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "v3:0000000000000001",
|
||||
"attempted_plan_keys": [
|
||||
"v3:0000000000000001"
|
||||
],
|
||||
"attempt_count": 1,
|
||||
"quality_preference": "",
|
||||
"position_seconds": 42.5,
|
||||
"metered": true,
|
||||
"bandwidth_estimate_kbps": 3500,
|
||||
"bandwidth_cap_kbps": 4000,
|
||||
"selected_tracks": {
|
||||
"audio": {
|
||||
"id": "file:42:audio:0",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 31536001,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"session_id": "11111111-1111-4111-8111-111111111111",
|
||||
"plan_id": "plan:golden-0001",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "v3:0000000000000001",
|
||||
"event": "buffering",
|
||||
"output_context_id": "7",
|
||||
"diagnostics": {
|
||||
"decoder_name": "c2.android.avc.decoder",
|
||||
"first_frame_ms": "412",
|
||||
"video_mime": "video/avc"
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "best_effort",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"protocol_version": 2,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"protocol_versions": [
|
||||
3
|
||||
],
|
||||
"features": [
|
||||
"playback_plan_v3",
|
||||
"neutral_playback_v3_contract_v1",
|
||||
"layout_aware_passthrough",
|
||||
"playback_route_diagnostics",
|
||||
"device_quirks_v1",
|
||||
"seek_reanchor_v1",
|
||||
"direct_stream_resume_v1",
|
||||
"plan_source_duration_v1"
|
||||
],
|
||||
"deliveries": [
|
||||
"original_http",
|
||||
"server_remux_progressive",
|
||||
"server_remux_hls",
|
||||
"server_transcode_hls"
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"name": "audio_to_aac",
|
||||
"executor": "server",
|
||||
"recipe_version": "1",
|
||||
"validated_claims": [
|
||||
"audio_decode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "server_dv7_to_hdr10",
|
||||
"executor": "server",
|
||||
"recipe_version": "1",
|
||||
"validated_claims": [
|
||||
"dolby_vision_metadata_removed",
|
||||
"hdr10_base_layer_preserved",
|
||||
"enhancement_layer_discarded"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "video_to_h264",
|
||||
"executor": "server",
|
||||
"recipe_version": "2",
|
||||
"validated_claims": [
|
||||
"h264_decode"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"server_features": [
|
||||
"playback_plan_v3",
|
||||
"neutral_playback_v3_contract_v1",
|
||||
"layout_aware_passthrough",
|
||||
"playback_route_diagnostics",
|
||||
"device_quirks_v1",
|
||||
"seek_reanchor_v1",
|
||||
"direct_stream_resume_v1",
|
||||
"plan_source_duration_v1"
|
||||
],
|
||||
"outcome": "playable",
|
||||
"session_id": "11111111-1111-4111-8111-111111111111",
|
||||
"playback_plan": {
|
||||
"protocol_version": 3,
|
||||
"plan_id": "plan:478677870860e5e5108c18bff749b34b",
|
||||
"plan_attempt_key": "v3:f0144c47fa349e3e",
|
||||
"session_id": "11111111-1111-4111-8111-111111111111",
|
||||
"expires_at": "2030-01-01T00:00:00Z",
|
||||
"delivery": "original_http",
|
||||
"stream": {
|
||||
"url": "/stream/11111111-1111-4111-8111-111111111111",
|
||||
"protocol": "http_progressive",
|
||||
"container": "mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"headers": {},
|
||||
"header_refresh": "none"
|
||||
},
|
||||
"timeline": {
|
||||
"source_start_seconds": 12.5,
|
||||
"stream_origin_seconds": 0,
|
||||
"player_start_seconds": 12.5,
|
||||
"timeline_offset_seconds": 0,
|
||||
"can_seek_anywhere": true,
|
||||
"seek_restoration": "player_position"
|
||||
},
|
||||
"selected_tracks": {
|
||||
"audio": {
|
||||
"id": "file:42:audio:0",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"effective_recipe": {
|
||||
"video_codec": "h264",
|
||||
"audio_codec": "aac",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frame_rate": 23.976023976023978,
|
||||
"bitrate_kbps": 8000,
|
||||
"dynamic_range": "sdr",
|
||||
"audio_channels": 2,
|
||||
"audio_layout": "stereo"
|
||||
},
|
||||
"claims": {
|
||||
"video": {
|
||||
"hdr10": false,
|
||||
"hdr10_plus": false,
|
||||
"hlg": false,
|
||||
"dolby_vision": false
|
||||
},
|
||||
"audio": {
|
||||
"codec": "aac",
|
||||
"passthrough": false,
|
||||
"atmos_preserved": false,
|
||||
"reason": "client_decode_supported"
|
||||
},
|
||||
"subtitles": {
|
||||
"ass_styling_preserved": false,
|
||||
"bitmap_overlay": false,
|
||||
"bitmap_sidecar": false
|
||||
}
|
||||
},
|
||||
"subtitle": {
|
||||
"mode": "off",
|
||||
"inventory": [
|
||||
{
|
||||
"track_id": "file:42:subtitle:0",
|
||||
"combined_index": 0,
|
||||
"source": "external",
|
||||
"codec": "srt",
|
||||
"language": "eng",
|
||||
"label": "English",
|
||||
"forced": false,
|
||||
"default": false,
|
||||
"hearing_impaired": false,
|
||||
"delivery": "sidecar",
|
||||
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/0.vtt?file_id=42"
|
||||
},
|
||||
{
|
||||
"track_id": "file:42:subtitle:1",
|
||||
"combined_index": 1,
|
||||
"source": "embedded",
|
||||
"codec": "ass",
|
||||
"language": "eng",
|
||||
"label": "English (Signs)",
|
||||
"forced": true,
|
||||
"default": false,
|
||||
"hearing_impaired": false,
|
||||
"delivery": "sidecar",
|
||||
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1.ass?file_id=42",
|
||||
"font_bundle_url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1/fonts?file_id=42"
|
||||
},
|
||||
{
|
||||
"track_id": "file:42:subtitle:2",
|
||||
"combined_index": 2,
|
||||
"source": "embedded",
|
||||
"codec": "pgs",
|
||||
"language": "jpn",
|
||||
"label": "Japanese",
|
||||
"forced": false,
|
||||
"default": false,
|
||||
"hearing_impaired": false,
|
||||
"delivery": "sidecar",
|
||||
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/2.sup?file_id=42"
|
||||
},
|
||||
{
|
||||
"track_id": "file:42:subtitle:3",
|
||||
"combined_index": 3,
|
||||
"source": "embedded",
|
||||
"codec": "dvd_subtitle",
|
||||
"language": "fre",
|
||||
"label": "French",
|
||||
"forced": false,
|
||||
"default": false,
|
||||
"hearing_impaired": false,
|
||||
"delivery": "burn_in_only"
|
||||
},
|
||||
{
|
||||
"track_id": "file:42:subtitle:4",
|
||||
"combined_index": 4,
|
||||
"source": "downloaded",
|
||||
"codec": "srt",
|
||||
"language": "spa",
|
||||
"label": "Spanish (downloaded)",
|
||||
"forced": false,
|
||||
"default": false,
|
||||
"hearing_impaired": false,
|
||||
"delivery": "sidecar",
|
||||
"url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/4.vtt?file_id=42"
|
||||
}
|
||||
]
|
||||
},
|
||||
"transformations": [],
|
||||
"applied_quirks": [],
|
||||
"runtime_corrections": [],
|
||||
"available_qualities": [
|
||||
{
|
||||
"label": "original",
|
||||
"height": 1080,
|
||||
"bitrate_kbps": 8000,
|
||||
"preserves_source": true
|
||||
}
|
||||
],
|
||||
"degradation_warnings": [],
|
||||
"decision_reason": "validated_original_playback",
|
||||
"requested_media_file_id": 42,
|
||||
"effective_media_file_id": 42,
|
||||
"source": {
|
||||
"media_file_id": 42,
|
||||
"duration_seconds": 7200,
|
||||
"container": "mp4",
|
||||
"video_codec": "h264",
|
||||
"video_profile": "high",
|
||||
"video_level": 41,
|
||||
"bit_depth": 8,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frame_rate": 23.976023976023978,
|
||||
"bitrate_kbps": 8000,
|
||||
"dynamic_range": "sdr",
|
||||
"hdr10_plus": false,
|
||||
"dv_enhancement_layer": "none",
|
||||
"audio_codec": "aac",
|
||||
"audio_channels": 2,
|
||||
"audio_layout": "stereo"
|
||||
},
|
||||
"subtitle_fidelity_policy": "allow_simplified_rendering"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"error": "client_upgrade_required",
|
||||
"message": "This server requires playback protocol v3. Update the app to continue."
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"operation": "failure_recovery",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"replan_request_id": "replan-golden-0001",
|
||||
"failed_plan_id": "plan:478677870860e5e5108c18bff749b34b",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "v3:f0144c47fa349e3e",
|
||||
"attempted_plan_keys": [
|
||||
"v3:f0144c47fa349e3e"
|
||||
],
|
||||
"attempt_count": 1,
|
||||
"quality_preference": "auto",
|
||||
"position_seconds": 42.5,
|
||||
"metered": true,
|
||||
"bandwidth_estimate_kbps": 3500,
|
||||
"bandwidth_cap_kbps": 4000,
|
||||
"selected_tracks": {
|
||||
"audio": {
|
||||
"id": "file:42:audio:0",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"failure": {
|
||||
"classification": "network_degraded"
|
||||
},
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"session_id": "11111111-1111-4111-8111-111111111111",
|
||||
"plan_id": "plan:478677870860e5e5108c18bff749b34b",
|
||||
"plan_attempt_id": "plan-attempt-golden-0001",
|
||||
"plan_attempt_key": "v3:f0144c47fa349e3e",
|
||||
"event": "first_frame",
|
||||
"output_context_id": "7",
|
||||
"diagnostics": {
|
||||
"decoder_name": "c2.android.avc.decoder",
|
||||
"first_frame_ms": "412",
|
||||
"video_mime": "video/avc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"protocol_version": 3,
|
||||
"client_features": [
|
||||
"playback_plan_v3"
|
||||
],
|
||||
"file_id": 42,
|
||||
"profile_id": "profile-1",
|
||||
"playback_attempt_id": "attempt-golden-0001",
|
||||
"quality_preference": "original",
|
||||
"subtitle_fidelity_preference": "compatible",
|
||||
"start_position": 12.5,
|
||||
"progress_persistence": "client",
|
||||
"audio_track_id": "file:42:audio:0",
|
||||
"audio_track_index": 0,
|
||||
"metered": false,
|
||||
"client_capabilities": {
|
||||
"video_evidence": "exact",
|
||||
"audio_evidence": "exact",
|
||||
"codecs_video": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_video_hardware": [
|
||||
"h264"
|
||||
],
|
||||
"codecs_audio": [
|
||||
"aac"
|
||||
],
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"max_resolution": "1080p",
|
||||
"hdr": false,
|
||||
"video_decode": [
|
||||
{
|
||||
"codec": "h264",
|
||||
"profiles": [
|
||||
"high"
|
||||
],
|
||||
"levels": [
|
||||
41
|
||||
],
|
||||
"bit_depths": [
|
||||
8
|
||||
],
|
||||
"max_width": 1920,
|
||||
"max_height": 1080,
|
||||
"max_frame_rate": 60,
|
||||
"max_bitrate_kbps": 20000,
|
||||
"hardware": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"client_playback_context": {
|
||||
"protocol_version": 3,
|
||||
"form_factor": "tv",
|
||||
"app_version": "3.0-test",
|
||||
"device": {
|
||||
"platform": "android",
|
||||
"os_version": "15",
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"platform_details": {
|
||||
"abis": "arm64-v8a",
|
||||
"sdk_int": "35"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"output_context_id": "7"
|
||||
},
|
||||
"deliveries": {
|
||||
"original_http": {
|
||||
"enabled": true,
|
||||
"supported_on_device": true,
|
||||
"containers": [
|
||||
"mp4"
|
||||
],
|
||||
"video_codecs": [
|
||||
"h264"
|
||||
],
|
||||
"audio_decode_codecs": [
|
||||
"aac"
|
||||
],
|
||||
"audio_passthrough_codecs": [],
|
||||
"subtitles": {
|
||||
"embedded_text": true,
|
||||
"sidecar_text": true,
|
||||
"ass_styling": false,
|
||||
"embedded_bitmap": false,
|
||||
"sidecar_bitmap": false,
|
||||
"font_attachments": false
|
||||
},
|
||||
"features": [],
|
||||
"auth_header_refresh": true,
|
||||
"validated_claims": [],
|
||||
"transformations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/playback-v3/v3/replan-request.schema.json",
|
||||
"title": "Silo Playback Protocol v3 Replan Request",
|
||||
"description": "Body of POST /api/v1/playback/{session_id}/replan. Bounds mirror ReplanRequestV3.Validate and validateCapabilitiesV3 in internal/playback/protocol_v3.go. STRING LENGTHS: every `maxLength` here is a character count, while the server enforces the same limit as a UTF-8 byte length (Go `len(string)`), so a value near a cap that contains multi-byte characters can satisfy this schema yet be rejected at ingest. JSON Schema cannot express byte length, so the divergence is documented rather than encoded.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"playback_attempt_id",
|
||||
"replan_request_id",
|
||||
"failed_plan_id",
|
||||
"plan_attempt_id",
|
||||
"plan_attempt_key",
|
||||
"attempt_count",
|
||||
"client_capabilities",
|
||||
"client_playback_context"
|
||||
],
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"client_features": {
|
||||
"$ref": "#/$defs/feature_list"
|
||||
},
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": "Omitted means failure_recovery, which keeps callers that predate the field on the ordinary recovery path. Seek operations are opt-in because both pin the current media version and user intent.",
|
||||
"enum": [
|
||||
"failure_recovery",
|
||||
"seek_reanchor",
|
||||
"seek_failure_recovery",
|
||||
"track_change",
|
||||
"quality_change"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"playback_attempt_id": {
|
||||
"$ref": "#/$defs/bounded_identifier"
|
||||
},
|
||||
"replan_request_id": {
|
||||
"$ref": "#/$defs/bounded_identifier"
|
||||
},
|
||||
"failed_plan_id": {
|
||||
"$ref": "#/$defs/opaque_plan_token"
|
||||
},
|
||||
"plan_attempt_id": {
|
||||
"$ref": "#/$defs/opaque_plan_token"
|
||||
},
|
||||
"plan_attempt_key": {
|
||||
"$ref": "#/$defs/plan_attempt_key"
|
||||
},
|
||||
"attempted_plan_keys": {
|
||||
"type": "array",
|
||||
"description": "Keys of the plans this attempt has already tried, echoed back verbatim so the server can refuse to hand out a plan that already failed on this output route.",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"$ref": "#/$defs/plan_attempt_key"
|
||||
}
|
||||
},
|
||||
"local_mutations": {
|
||||
"type": "array",
|
||||
"description": "Client-applied local plan mutations, so the server can fold them into the attempt key it computes for the failed plan. Clients never hash anything themselves.",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"attempt_count": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 8
|
||||
},
|
||||
"quality_preference": {
|
||||
"type": "string",
|
||||
"description": "Free-form and deliberately unbounded, normalized the same way as on a start request. A quality_change operation must name a non-blank rung: an empty preference would silently mean \"auto\", which is a different user intent than the menu selection that operation models."
|
||||
},
|
||||
"position_seconds": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 31536000
|
||||
},
|
||||
"metered": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"bandwidth_estimate_kbps": {
|
||||
"$ref": "#/$defs/bandwidth_kbps"
|
||||
},
|
||||
"bandwidth_cap_kbps": {
|
||||
"$ref": "#/$defs/bandwidth_kbps"
|
||||
},
|
||||
"selected_tracks": {
|
||||
"$ref": "#/$defs/selected_tracks"
|
||||
},
|
||||
"failure": {
|
||||
"$ref": "#/$defs/failure"
|
||||
},
|
||||
"client_capabilities": {
|
||||
"$ref": "#/$defs/client_capabilities"
|
||||
},
|
||||
"client_playback_context": {
|
||||
"$ref": "#/$defs/client_playback_context"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"bounded_identifier": {
|
||||
"type": "string",
|
||||
"description": "Opaque client-minted identifier. The server matches ^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$, so the value is 8 to 128 characters and starts alphanumeric.",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"opaque_plan_token": {
|
||||
"type": "string",
|
||||
"description": "Server-minted plan identity echoed back verbatim. Clients store and return these tokens and never parse or recompute them.",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"plan_attempt_key": {
|
||||
"type": "string",
|
||||
"description": "Server-computed opaque loop-prevention token. Always carries the `v3:` prefix; the server compares keys byte-for-byte, so case and whitespace are significant.",
|
||||
"pattern": "^v3:",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"track_identity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"index": {
|
||||
"$ref": "#/$defs/track_index"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"selected_tracks": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio": {
|
||||
"$ref": "#/$defs/track_identity"
|
||||
},
|
||||
"subtitle": {
|
||||
"$ref": "#/$defs/track_identity"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"failure": {
|
||||
"type": "object",
|
||||
"description": "Required for failure_recovery and seek_failure_recovery: both need a non-empty classification. A track_change or quality_change carries no failure.",
|
||||
"properties": {
|
||||
"classification": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"decoder_name": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"track_index": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000
|
||||
},
|
||||
"bandwidth_kbps": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 1000000
|
||||
},
|
||||
"feature_list": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"capability_value_list": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"delivery_value_list": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"capability_evidence": {
|
||||
"type": "string",
|
||||
"description": "How the capability facts were produced. `exact` is a real platform probe with per-codec profiles/levels/bounds, `platform_attested` a decoder attestation without profile enumeration, `declared` a boolean support statement. Planner strictness follows the tier.",
|
||||
"enum": [
|
||||
"exact",
|
||||
"platform_attested",
|
||||
"declared"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"hdr_capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hdr10": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hdr10_plus": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hlg": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dolby_vision_profiles": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"audio_passthrough": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"passthrough_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"spatializer_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_channels": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 64
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codec": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"channel_counts": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"video_decode_capability": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"codec"
|
||||
],
|
||||
"properties": {
|
||||
"codec": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"decoder_name": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"levels": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"bit_depths": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"max_width": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_height": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_frame_rate": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_bitrate_kbps": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"hardware": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"client_capabilities": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"video_evidence",
|
||||
"audio_evidence"
|
||||
],
|
||||
"properties": {
|
||||
"video_evidence": {
|
||||
"$ref": "#/$defs/capability_evidence"
|
||||
},
|
||||
"audio_evidence": {
|
||||
"$ref": "#/$defs/capability_evidence"
|
||||
},
|
||||
"codecs_video": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"codecs_video_hardware": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"codecs_audio": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"containers": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"max_resolution": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"hdr": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hdr_details": {
|
||||
"$ref": "#/$defs/hdr_capabilities"
|
||||
},
|
||||
"audio_passthrough": {
|
||||
"$ref": "#/$defs/audio_passthrough"
|
||||
},
|
||||
"video_decode": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/video_decode_capability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"transformation_capability": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"executor",
|
||||
"recipe_version"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"executor": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"server"
|
||||
]
|
||||
},
|
||||
"recipe_version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 32
|
||||
},
|
||||
"validated_claims": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"delivery_capability": {
|
||||
"type": "object",
|
||||
"description": "One negotiated delivery class the client can execute. A `client` executor transformation is only accepted on a delivery that is enabled and supported_on_device, and only when client_features advertises client_video_transformations_v1; (executor, name, recipe_version) must be unique within the delivery.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supported_on_device": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"failure_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"containers": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"video_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"audio_decode_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"audio_passthrough_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"max_channels": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hdr_details": {
|
||||
"$ref": "#/$defs/hdr_capabilities"
|
||||
},
|
||||
"subtitles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"embedded_text": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidecar_text": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ass_styling": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"embedded_bitmap": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidecar_bitmap": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"font_attachments": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"features": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"auth_header_refresh": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"validated_claims": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"transformations": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"$ref": "#/$defs/transformation_capability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"client_playback_context": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"protocol_version"
|
||||
],
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"form_factor": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"app_version": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"device": {
|
||||
"type": "object",
|
||||
"description": "Manufacturer and model stay first-class because the device-quirk registry matches on them. Everything platform-specific travels in platform_details as opaque bounded strings.",
|
||||
"properties": {
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"os_version": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"platform_details": {
|
||||
"type": "object",
|
||||
"maxProperties": 16,
|
||||
"propertyNames": {
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hdr_details": {
|
||||
"$ref": "#/$defs/hdr_capabilities"
|
||||
},
|
||||
"audio_passthrough": {
|
||||
"$ref": "#/$defs/audio_passthrough"
|
||||
},
|
||||
"current_sink": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"sink_type": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"output_context_id": {
|
||||
"type": "string",
|
||||
"description": "Opaque token identifying the current output route. The server only ever compares it for equality, so any stable platform-native identity works and omitting it is valid.",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"deliveries": {
|
||||
"type": "object",
|
||||
"description": "Keyed by delivery class: original_http, progressive, hls. A class the client omits is a class the planner will not route to.",
|
||||
"maxProperties": 16,
|
||||
"propertyNames": {
|
||||
"maxLength": 64
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/delivery_capability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$comment": "failure_recovery (the default when operation is omitted) and seek_failure_recovery require a non-empty failure classification; mirrors ReplanRequestV3.Validate in internal/playback/protocol_v3.go.",
|
||||
"if": {
|
||||
"anyOf": [
|
||||
{
|
||||
"not": {
|
||||
"required": [
|
||||
"operation"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"operation": {
|
||||
"enum": [
|
||||
"failure_recovery",
|
||||
"seek_failure_recovery"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"failure"
|
||||
],
|
||||
"properties": {
|
||||
"failure": {
|
||||
"required": [
|
||||
"classification"
|
||||
],
|
||||
"properties": {
|
||||
"classification": {
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$comment": "quality_change must name the wanted rung; an empty preference would mean auto, a different user intent than the menu selection.",
|
||||
"if": {
|
||||
"properties": {
|
||||
"operation": {
|
||||
"const": "quality_change"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"operation"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"quality_preference"
|
||||
],
|
||||
"properties": {
|
||||
"quality_preference": {
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/playback-v3/v3/route-event.schema.json",
|
||||
"title": "Silo Playback Protocol v3 Route Event",
|
||||
"description": "Request body of POST /api/v1/playback/route-event: one event object, not a batch. Bounds mirror validRouteEventV3 in internal/api/handlers/playback_v3.go. The endpoint is telemetry, so the server rejects a malformed event rather than half-recording it; every bound here is a rejection, and the two behaviours that are not rejections — unknown diagnostics keys being dropped, long diagnostics values being truncated — are described rather than constrained.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"playback_attempt_id",
|
||||
"event"
|
||||
],
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"playback_attempt_id": {
|
||||
"type": "string",
|
||||
"description": "The attempt this event belongs to, correlating every plan the client tried for one user-visible playback start.",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "string",
|
||||
"description": "Echoed from the plan being reported on. Opaque to clients.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"plan_attempt_id": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"plan_attempt_key": {
|
||||
"type": "string",
|
||||
"description": "Echoed from the plan being reported on. Opaque to clients: never computed on the client side.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"event": {
|
||||
"type": "string",
|
||||
"description": "The complete v3 telemetry contract. New names are added additively within v3; the server rejects a name it does not know, so a client must not invent one.",
|
||||
"enum": [
|
||||
"plan_selected",
|
||||
"plan_invalidated",
|
||||
"plan_failed",
|
||||
"first_frame",
|
||||
"terminal",
|
||||
"stopped",
|
||||
"runtime_correction_applied",
|
||||
"runtime_correction_succeeded",
|
||||
"runtime_correction_failed",
|
||||
"seek_reanchor_requested",
|
||||
"seek_reanchored"
|
||||
]
|
||||
},
|
||||
"failure_classification": {
|
||||
"type": "string",
|
||||
"description": "Neutral failure class, not a client-framework error string.",
|
||||
"maxLength": 64
|
||||
},
|
||||
"fallback_reason": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"applied_quirk_ids": {
|
||||
"type": "array",
|
||||
"description": "Quirks the client actually applied, so a registry entry can be measured against real outcomes rather than assumed to work.",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"quirk_registry_revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"output_context_id": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"diagnostics": {
|
||||
"type": "object",
|
||||
"description": "Free-form key/value detail. The server keeps only keys on its allowlist and silently drops the rest, and trims each surviving value to 256 characters, so an unknown key or a long value is not a rejection — but more than 32 entries is.",
|
||||
"maxProperties": 32,
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/playback-v3/v3/start-request.schema.json",
|
||||
"title": "Silo Playback Protocol v3 Start Request",
|
||||
"description": "Body of POST /api/v1/playback/start. Bounds mirror StartRequestV3.NormalizeAndValidate and validateCapabilitiesV3 in internal/playback/protocol_v3.go. STRING LENGTHS: every `maxLength` here is a character count, while the server enforces the same limit as a UTF-8 byte length (Go `len(string)`), so a value near a cap that contains multi-byte characters can satisfy this schema yet be rejected at ingest. JSON Schema cannot express byte length, so the divergence is documented rather than encoded.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"protocol_version",
|
||||
"file_id",
|
||||
"profile_id",
|
||||
"playback_attempt_id",
|
||||
"subtitle_fidelity_preference",
|
||||
"client_capabilities",
|
||||
"client_playback_context"
|
||||
],
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"client_features": {
|
||||
"$ref": "#/$defs/feature_list"
|
||||
},
|
||||
"file_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "Must equal the X-Profile-Id header on the request; the server rejects a mismatch.",
|
||||
"minLength": 1
|
||||
},
|
||||
"playback_attempt_id": {
|
||||
"$ref": "#/$defs/bounded_identifier"
|
||||
},
|
||||
"quality_preference": {
|
||||
"type": "string",
|
||||
"description": "Free-form and deliberately unbounded: the server normalizes an unrecognized value to \"auto\" and returns a quality_preference_normalized degradation warning rather than rejecting the request. The values that survive normalization unchanged are auto, original, 2160p, 1080p, 720p and 480p."
|
||||
},
|
||||
"subtitle_fidelity_preference": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"preserve",
|
||||
"compatible"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"start_position": {
|
||||
"type": "number",
|
||||
"description": "Where in the source to begin, in seconds. Omitting it asks the server for the profile's saved resume point (0 when there is none, when the item is already complete, or when the file is one part of a multipart item); sending 0 explicitly means start over. Resolved before planning, because the plan's timeline is cut at the start position.",
|
||||
"minimum": 0,
|
||||
"maximum": 31536000
|
||||
},
|
||||
"progress_persistence": {
|
||||
"type": "string",
|
||||
"description": "Who owns durable item resume/history persistence. Omission defaults to server. client keeps session heartbeats live but suppresses server item progress writes and requires an explicit start_position.",
|
||||
"enum": [
|
||||
"server",
|
||||
"client"
|
||||
],
|
||||
"default": "server"
|
||||
},
|
||||
"audio_track_id": {
|
||||
"description": "Omitting both audio fields asks the server for the profile's preferred track, resolved from the series preference, then the profile audio-language setting, then the library override. Sending either one selects exactly that track.",
|
||||
"$ref": "#/$defs/track_id"
|
||||
},
|
||||
"audio_track_index": {
|
||||
"$ref": "#/$defs/track_index"
|
||||
},
|
||||
"subtitle_track_id": {
|
||||
"$ref": "#/$defs/track_id"
|
||||
},
|
||||
"subtitle_track_index": {
|
||||
"$ref": "#/$defs/track_index"
|
||||
},
|
||||
"metered": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"bandwidth_estimate_kbps": {
|
||||
"$ref": "#/$defs/bandwidth_kbps"
|
||||
},
|
||||
"bandwidth_cap_kbps": {
|
||||
"$ref": "#/$defs/bandwidth_kbps"
|
||||
},
|
||||
"client_capabilities": {
|
||||
"$ref": "#/$defs/client_capabilities"
|
||||
},
|
||||
"client_playback_context": {
|
||||
"$ref": "#/$defs/client_playback_context"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"progress_persistence": {
|
||||
"const": "client"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"progress_persistence"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"start_position"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"bounded_identifier": {
|
||||
"type": "string",
|
||||
"description": "Opaque client-minted identifier. The server matches ^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$, so the value is 8 to 128 characters and starts alphanumeric.",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$",
|
||||
"minLength": 8,
|
||||
"maxLength": 128
|
||||
},
|
||||
"track_id": {
|
||||
"type": "string",
|
||||
"description": "Combined-ordinal track identity `file:{file_id}:{audio|subtitle}:{ordinal}`. When the matching index field is also sent the two must agree; the server rebuilds the identity from file_id and index and compares byte-for-byte.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"track_index": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000
|
||||
},
|
||||
"bandwidth_kbps": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 1000000
|
||||
},
|
||||
"feature_list": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"capability_value_list": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"delivery_value_list": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"capability_evidence": {
|
||||
"type": "string",
|
||||
"description": "How the capability facts were produced. `exact` is a real platform probe with per-codec profiles/levels/bounds, `platform_attested` a decoder attestation without profile enumeration, `declared` a boolean support statement. Planner strictness follows the tier.",
|
||||
"enum": [
|
||||
"exact",
|
||||
"platform_attested",
|
||||
"declared"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"hdr_capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hdr10": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hdr10_plus": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hlg": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dolby_vision_profiles": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"audio_passthrough": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"passthrough_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"spatializer_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_channels": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 64
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"codec": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"channel_counts": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"video_decode_capability": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"codec"
|
||||
],
|
||||
"properties": {
|
||||
"codec": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"decoder_name": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"profiles": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"levels": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"bit_depths": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"max_width": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_height": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_frame_rate": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"max_bitrate_kbps": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"hardware": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"client_capabilities": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"video_evidence",
|
||||
"audio_evidence"
|
||||
],
|
||||
"properties": {
|
||||
"video_evidence": {
|
||||
"$ref": "#/$defs/capability_evidence"
|
||||
},
|
||||
"audio_evidence": {
|
||||
"$ref": "#/$defs/capability_evidence"
|
||||
},
|
||||
"codecs_video": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"codecs_video_hardware": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"codecs_audio": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"containers": {
|
||||
"$ref": "#/$defs/capability_value_list"
|
||||
},
|
||||
"max_resolution": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"hdr": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hdr_details": {
|
||||
"$ref": "#/$defs/hdr_capabilities"
|
||||
},
|
||||
"audio_passthrough": {
|
||||
"$ref": "#/$defs/audio_passthrough"
|
||||
},
|
||||
"video_decode": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"$ref": "#/$defs/video_decode_capability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"transformation_capability": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"executor",
|
||||
"recipe_version"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"executor": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"client",
|
||||
"server"
|
||||
]
|
||||
},
|
||||
"recipe_version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 32
|
||||
},
|
||||
"validated_claims": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"delivery_capability": {
|
||||
"type": "object",
|
||||
"description": "One negotiated delivery class the client can execute. A `client` executor transformation is only accepted on a delivery that is enabled and supported_on_device, and only when client_features advertises client_video_transformations_v1; (executor, name, recipe_version) must be unique within the delivery.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supported_on_device": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"failure_reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"containers": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"video_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"audio_decode_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"audio_passthrough_codecs": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"max_channels": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hdr_details": {
|
||||
"$ref": "#/$defs/hdr_capabilities"
|
||||
},
|
||||
"subtitles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"embedded_text": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidecar_text": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ass_styling": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"embedded_bitmap": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sidecar_bitmap": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"font_attachments": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"features": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"auth_header_refresh": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"validated_claims": {
|
||||
"$ref": "#/$defs/delivery_value_list"
|
||||
},
|
||||
"transformations": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"$ref": "#/$defs/transformation_capability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"client_playback_context": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"protocol_version"
|
||||
],
|
||||
"properties": {
|
||||
"protocol_version": {
|
||||
"type": "integer",
|
||||
"const": 3
|
||||
},
|
||||
"form_factor": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"app_version": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"device": {
|
||||
"type": "object",
|
||||
"description": "Manufacturer and model stay first-class because the device-quirk registry matches on them. Everything platform-specific travels in platform_details as opaque bounded strings.",
|
||||
"properties": {
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"maxLength": 32
|
||||
},
|
||||
"os_version": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"platform_details": {
|
||||
"type": "object",
|
||||
"maxProperties": 16,
|
||||
"propertyNames": {
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hdr_details": {
|
||||
"$ref": "#/$defs/hdr_capabilities"
|
||||
},
|
||||
"audio_passthrough": {
|
||||
"$ref": "#/$defs/audio_passthrough"
|
||||
},
|
||||
"current_sink": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"sink_type": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"output_context_id": {
|
||||
"type": "string",
|
||||
"description": "Opaque token identifying the current output route. The server only ever compares it for equality, so any stable platform-native identity works and omitting it is valid.",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"deliveries": {
|
||||
"type": "object",
|
||||
"description": "Keyed by delivery class: original_http, progressive, hls. A class the client omits is a class the planner will not route to.",
|
||||
"maxProperties": 16,
|
||||
"propertyNames": {
|
||||
"maxLength": 64
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/delivery_capability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
# Playback Protocol v3: Platform-Neutral Contract Finalization
|
||||
|
||||
**Status:** Proposed
|
||||
**Scope:** `silo-server` (protocol owner), with coordinated breaking updates in
|
||||
`silo-android`, `silo-apple`, and the in-repo web frontend (`web/src`)
|
||||
**Supersedes:** the wire-contract sections (§3.3, §5.1–§5.2) of
|
||||
`docs/superpowers/plans/2026-07-12-playback-protocol-v3.md`. The planner,
|
||||
session, replan-idempotency, and transport architecture from that plan are
|
||||
unchanged and remain authoritative.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Finalize the v3 playback protocol **in place** as a platform-neutral contract
|
||||
before it carries production traffic. The protocol keeps its version number,
|
||||
its endpoints, and its architecture (server-owned plans, deterministic plan
|
||||
identity, idempotent replans, validated claims, route events). What changes is
|
||||
the wire contract, which today is a serialization of Android Media3 concepts:
|
||||
|
||||
- the engine vocabulary is Media3 branding (`media3_direct`, `media3_hls`,
|
||||
feature token `media3_only`, claims `media3_h264_decode`);
|
||||
- loop-prevention attempt keys are client-computed FNV hashes whose canonical
|
||||
string is defined by Kotlin enum constant names, forcing the Go server to
|
||||
carry `KotlinName()` methods and Apple to reimplement the hash in Swift;
|
||||
- the capability schema mirrors `android.media.MediaCodecInfo` and
|
||||
`android.os.Build`, which Apple can only fill with hardcoded literals
|
||||
(`profiles: []`, fabricated `output_route_generation`) and the web client
|
||||
cannot fill at all;
|
||||
- the normative spec lives in the Android repository.
|
||||
|
||||
**There is no backwards compatibility requirement.** v3 is dark
|
||||
(`playback.protocol_v3_enabled` defaults to `false`), the only v3 speakers are
|
||||
first-party test builds, and the product is early enough to require clients and
|
||||
servers to update together. All three repos move in one coordinated release;
|
||||
the legacy playback protocol is deleted, not deprecated.
|
||||
|
||||
## 2. Current state (verified)
|
||||
|
||||
| Surface | State |
|
||||
| --- | --- |
|
||||
| Server v3 | Complete behind `playback.protocol_v3_enabled`: capability, start, replan, route-events; planner in `internal/playback/plan_v3.go`; durable attempt store in `internal/playback/planstore/`. |
|
||||
| Android | Speaks v3 natively; owns the normative Kotlin model and generates the attempt-key fixtures Go tests consume. |
|
||||
| Apple | Speaks v3 through a ~1,050-line translation shim (`ProtocolV3/` directory) that aliases AVPlayer under Media3 engine names, hardcodes decode capabilities, and reimplements the Kotlin attempt-key hash. |
|
||||
| Web video player | Legacy protocol only: flat codec lists to `POST /playback/start`, client-chosen FFmpeg recipes via `POST /playback/transcode/start`, audio switch via `PATCH /playback/{id}/audio`. |
|
||||
| Web audiobook player | Legacy `POST /playback/start` with `play_method: "direct"`. |
|
||||
| Transcode nodes | Advertise the shared transformation registry (`internal/playback/transformations_v3.go`), including its `media3_*` validated-claim strings. |
|
||||
| jellycompat | Separate protocol surface for Jellyfin clients; out of scope. |
|
||||
|
||||
## 3. Contract changes
|
||||
|
||||
### 3.1 Server-owned attempt keys
|
||||
|
||||
The server already computes `PlanAttemptKeyV3` to enforce loop prevention.
|
||||
Make that the only implementation:
|
||||
|
||||
- Every `PlanV3` response carries `plan_attempt_key`, an **opaque token**.
|
||||
Clients store the keys of attempted plans and echo them in
|
||||
`attempted_plan_keys` on replan. Clients never hash anything.
|
||||
- A client that applies a local mutation (Android's PCM recovery route) reports
|
||||
it as a bounded string list `local_mutations` on the replan; the server folds
|
||||
it into the key.
|
||||
- Delete `KotlinName()` on `DeliveryV3`, `StreamProtocolV3`, and
|
||||
`SubtitleModeV3` (`internal/playback/protocol_v3.go`). The canonical string
|
||||
in `internal/playback/plan_key_v3.go` switches to lowercase wire tokens. The
|
||||
key remains FNV-1a `v3:<16-hex>` so existing store columns and log tooling
|
||||
keep working, but the algorithm is now a server implementation detail.
|
||||
- Bump `PlanRecipeVersionV3` (currently `v3.2`) so no key or plan ID computed
|
||||
under the old canonicalization can collide with the new scheme.
|
||||
- Golden fixtures in `internal/playback/testdata/protocol_v3/` are regenerated
|
||||
**by Go** and become the cross-repo source of truth (§3.7). The
|
||||
"fixtures must be generated by the checked-in Kotlin implementation" rule
|
||||
from the original plan is retired along with the reason it existed.
|
||||
|
||||
Deterministic `plan_id` (`DeterministicPlanIDV3`) stays: the server still needs
|
||||
recipe-stable identity for idempotency and telemetry. Only the client-visible
|
||||
hashing contract disappears.
|
||||
|
||||
### 3.2 Engines leave the wire; deliveries are the negotiation unit
|
||||
|
||||
Delete `EngineV3` from the wire contract. The delivery→engine mapping was
|
||||
already fixed one-to-one, so `delivery` plus `stream.protocol` carries all the
|
||||
information; each client maps deliveries to its own player internally
|
||||
(Media3, AVPlayer, hls.js).
|
||||
|
||||
- `ClientPlaybackContextV3.Engines` (map keyed `media3_*`) becomes
|
||||
`deliveries`, keyed by the delivery class the client can execute:
|
||||
`original_http`, `progressive`, `hls`. The value keeps the existing
|
||||
per-engine shape (containers, codecs, subtitle capabilities, features,
|
||||
transformations), renamed `DeliveryCapabilityV3`.
|
||||
- `PlanV3.Engine` is removed. `ResolveSubtitlePolicyV3` and
|
||||
`engineAvailableV3` re-key on delivery class; their logic is unchanged.
|
||||
- Feature token `media3_only` is deleted from `ServerFeaturesV3()`.
|
||||
- Validated-claim strings drop the prefix: `media3_h264_decode` →
|
||||
`h264_decode`, `media3_audio_decode` → `audio_decode` (registry specs in
|
||||
`internal/playback/transformations_v3.go`; flows automatically to the
|
||||
transcode-node advertisement, which matches on name/version/executor and is
|
||||
version-locked to the server binary anyway).
|
||||
- Reserved legacy Android enum values (`mpv_direct`, `client_local_loopback`,
|
||||
`external_player`, `client_local_normalization`) lose their protocol
|
||||
reservation; they become Android-internal history.
|
||||
|
||||
### 3.3 Tiered capability evidence
|
||||
|
||||
Replace the implicit "Android fills everything, others fake it" model with an
|
||||
explicit per-area evidence tier, and make planner strictness follow the tier:
|
||||
|
||||
```text
|
||||
video_evidence: "exact" | "platform_attested" | "declared"
|
||||
audio_evidence: "exact" | "platform_attested" | "declared"
|
||||
```
|
||||
|
||||
- **exact** — per-codec profiles/levels/bit-depths/bounds from a real platform
|
||||
probe (Android `MediaCodecList`). Full strict validation, exactly today's
|
||||
`detailedVideoEligibleV3` path.
|
||||
- **platform_attested** — platform-level decoder attestation without
|
||||
profile/level enumeration (Apple/VideoToolbox): codec, hardware flag, max
|
||||
resolution/frame-rate, bit depths, HDR ranges. The planner validates codec,
|
||||
resolution, bit depth, frame rate, and dynamic range, and skips
|
||||
profile/level matching instead of failing conservative. Apple stops
|
||||
advertising `detailed_decode_capabilities` facts it cannot produce.
|
||||
- **declared** — boolean support statements (web `MediaSource.isTypeSupported`).
|
||||
The request is valid (today web cannot construct a legal v3 request at all);
|
||||
the planner grants copy routes on codec+container+range match and otherwise
|
||||
adapts. No strict direct claims.
|
||||
|
||||
The `detailed_decode_capabilities` feature token is subsumed by
|
||||
`video_evidence: "exact"` and removed. Audio passthrough entries stay optional:
|
||||
only `exact` audio evidence earns passthrough claims; `platform_attested`
|
||||
decode claims suffice for copy routes, which unblocks Apple's current
|
||||
never-passthrough outcome honestly (Apple simply cannot attest sink layouts —
|
||||
the tier records that instead of a stubbed-empty struct pretending to).
|
||||
|
||||
Terminal reasons must distinguish "your evidence tier cannot support this
|
||||
route" (`evidence_insufficient_for_direct`) from "your device cannot play
|
||||
this" so `declared`-tier clients get actionable degradation warnings rather
|
||||
than mystery transcodes.
|
||||
|
||||
### 3.4 Neutral device and output context
|
||||
|
||||
- `DeviceContextV3` (an `android.os.Build` dump: `sdk_int`, `abis`,
|
||||
`security_patch`, `build_display`, …) is replaced by:
|
||||
|
||||
```text
|
||||
platform e.g. "android", "ios", "tvos", "macos", "web"
|
||||
os_version bounded string
|
||||
manufacturer kept first-class: the quirk registry matches on it
|
||||
model kept first-class: the quirk registry matches on it
|
||||
form_factor existing field, unchanged
|
||||
platform_details bounded map<string,string> (≤16 entries, ≤128 chars each)
|
||||
```
|
||||
|
||||
Android moves `sdk_int`, `soc_model`, build fields into `platform_details`;
|
||||
the Fire TV quirks in `internal/playback/device_quirks_v3.go` keep working on
|
||||
`manufacturer`/`model` unchanged. The quirk mechanism (namespaced IDs,
|
||||
registry revision, applied-quirk reporting) already generalizes to `apple.*`
|
||||
and `web.*` quirks; nothing to change there.
|
||||
|
||||
- `output_route_generation` (int64, strictly cross-validated in two request
|
||||
locations, modeling Android's audio-route callback counter) becomes
|
||||
`output_context_id`: an optional bounded opaque string. The server only ever
|
||||
uses it to distinguish "same output route" from "route changed" in attempt
|
||||
keys and plan invalidation, which an opaque token does on every platform.
|
||||
Android supplies its route generation stringified; Apple supplies its
|
||||
existing synthetic sink hash (no longer masked into an int64); web omits it.
|
||||
The dual-location consistency validation is deleted.
|
||||
|
||||
- Dual-location feature advertisement (`client_features` at top level *and*
|
||||
`client_playback_context.features`) collapses to the top-level list only.
|
||||
`HasFeatureV3` call sites lose their second lookup.
|
||||
|
||||
### 3.5 Replan gains track and quality operations; legacy endpoints die
|
||||
|
||||
Extend `ReplanOperationV3` beyond failure recovery and seek:
|
||||
|
||||
- `track_change` — new `selected_tracks`, no failure classification required.
|
||||
Replaces `PATCH /playback/{session_id}/audio`.
|
||||
- `quality_change` — new `quality_preference`, no failure classification.
|
||||
Replaces the client-recipe half of `POST /playback/transcode/start`.
|
||||
|
||||
Both run through the existing replan transaction (validation → capacity
|
||||
reservation → staged successor → atomic commit), so they inherit idempotency,
|
||||
loop prevention, and restart safety for free.
|
||||
|
||||
To let clients render a quality menu without owning a bitrate table,
|
||||
`PlanV3` gains `available_qualities`: the server ladder rungs valid for this
|
||||
source and client (label, height, approximate bitrate, plus `original`).
|
||||
The web ladder in `web/src/player/hooks/useTranscodeQuality.ts` is deleted,
|
||||
not reconciled — resolving the original plan's §6.3 "one ladder" requirement
|
||||
in the server's favor.
|
||||
|
||||
### 3.6 Audio-only sources join the planner
|
||||
|
||||
The v3 planner currently terminals on sources without complete video metadata,
|
||||
which is why the audiobook player must stay on the legacy start path. Teach
|
||||
`PlanPlaybackV3` audio-only sources: when the effective file has no video
|
||||
track, skip video/HDR/subtitle-burn gates and plan `original_http` (validated
|
||||
audio decode) or an audio transformation route. This is what allows the legacy
|
||||
start path to be deleted entirely rather than kept alive for audiobooks, and
|
||||
it is the natural extension point for future music support.
|
||||
|
||||
### 3.7 Spec and fixtures move into this repository
|
||||
|
||||
- New normative spec: `docs/architecture/playback-protocol-v3.md` — endpoint
|
||||
contract, request/response schemas, evidence tiers, delivery semantics,
|
||||
timeline model, replan state machine, terminal reason registry, and the
|
||||
frozen track-identity ordering rules.
|
||||
- JSON Schemas for every wire body under `docs/design/schemas/playback-v3/`,
|
||||
following the existing `docs/design/schemas/client-diagnostics` convention.
|
||||
- Golden fixtures (`internal/playback/testdata/protocol_v3/`) are generated by
|
||||
a Go generator and consumed by Android and Apple CI as opaque expected
|
||||
values. The direction of authority is inverted: the server defines the
|
||||
contract, clients prove conformance.
|
||||
- Track identity stays ordinal-based (`file:{id}:{kind}:{ordinal}`) — churn is
|
||||
not worth it — but the combined-ordinal ordering rules are specified here
|
||||
instead of by reference to Android's `buildSubtitleURLs`. The known
|
||||
`SubtitleTrackMerge` undercount (non-PGS burn-in tracks omitted from
|
||||
`subtitle_urls` but still counted in the downloaded-track offset) is fixed
|
||||
server-side as part of this pass; the Apple-side "do NOT fix" annotation is
|
||||
then removed.
|
||||
|
||||
### 3.8 Legacy playback protocol deletion
|
||||
|
||||
With web (video + audiobook) on v3, delete:
|
||||
|
||||
- `handleStartPlaybackLegacy` and `startPlaybackRequest` /
|
||||
`playbackSessionResponse` (`internal/api/handlers/playback.go`); the
|
||||
protocol-version dispatch envelope in `HandleStartPlayback` is replaced by a
|
||||
strict v3 decode. A start without `protocol_version: 3` gets
|
||||
`426 upgrade_required` with code `client_upgrade_required` so outdated apps
|
||||
render a clear "update required" state.
|
||||
- `POST /playback/transcode/start` (`HandleStartTranscode`) and
|
||||
`PATCH /playback/{session_id}/audio` (`HandleChangeAudioTrack`).
|
||||
- The shadow planner (`internal/api/handlers/playback_v3_shadow.go`) and
|
||||
`playback.protocol_v3_shadow_enabled` — its purpose (compare v3 against
|
||||
legacy production routes) ends when legacy routes end.
|
||||
- `playback.protocol_v3_enabled` — once v3 is the only protocol, "disabled"
|
||||
would mean "no playback". Removing both settings rows is a new Goose
|
||||
migration (`make migrate-create NAME=remove_playback_protocol_v3_flags`).
|
||||
- Legacy `resolver.go` paths that only legacy start reached (verify with
|
||||
coverage before deleting; jellycompat has its own resolution surface and
|
||||
must be untouched).
|
||||
|
||||
Per the v1 API rules, every removed endpoint and the changed start-body
|
||||
requirement are recorded in the pre-lock removals table in
|
||||
`docs/architecture/v1-scope.md` before merge. `/api/v1` is not locked, so this
|
||||
is in scope; it stops being possible after lock.
|
||||
|
||||
Unchanged surfaces: progress, stop, HLS manifest/segment delivery, the
|
||||
realtime control socket, stream tokens and restart reconstruction, watch
|
||||
together, downloads, jellycompat.
|
||||
|
||||
## 4. Client repository work
|
||||
|
||||
**silo-android** — mostly deletion. Remove the attempt-key implementation and
|
||||
its fixtures-as-source-of-truth role, the engine self-description under
|
||||
`media3_*` keys (becomes `deliveries`), and the `Build` dump (moves to
|
||||
`platform_details`). Adopt `plan_attempt_key` echo, `output_context_id`,
|
||||
`video_evidence: "exact"`, and the two new replan operations (its quality/track
|
||||
UI currently routes through replan-with-failure or legacy paths). Consume Go
|
||||
fixtures in CI.
|
||||
|
||||
**silo-apple** — the shim shrinks to near nothing. Delete the Swift FNV hash,
|
||||
the Media3 alias table, and the hardcoded decode-capability fabrication;
|
||||
advertise `video_evidence: "platform_attested"` with honest VideoToolbox
|
||||
attestation, and `audio_evidence: "platform_attested"` (no passthrough claims).
|
||||
Keep the plan validator (`validatedForApple()`) — defensive validation of
|
||||
server plans is good practice, not translation tax. Remove the subtitle
|
||||
undercount workaround once the server fix lands.
|
||||
|
||||
**web (this repo)** — the real migration. Video player: replace the legacy
|
||||
start payload with a v3 request (`video_evidence: "declared"` from
|
||||
`isTypeSupported` probes, `deliveries: {original_http, progressive, hls}`),
|
||||
consume `PlanV3` (URL, timeline, tracks, warnings), switch quality/track
|
||||
changes to replan, delete `useTranscodeQuality`'s ladder and recipe posting,
|
||||
and implement `route-events` for at least plan-failed/terminal so web failures
|
||||
become visible in the same telemetry as the apps. Audiobook player: v3 start
|
||||
against the audio-only planner path (§3.6). `WEBTEST_KNOWN_FAILURES` may only
|
||||
shrink during this work.
|
||||
|
||||
## 5. Rollout
|
||||
|
||||
One coordinated release train; no compatibility window.
|
||||
|
||||
1. Land the server contract changes with v3 still dark. Fixtures and JSON
|
||||
Schemas merge first so client work can start against them.
|
||||
2. Land Android and Apple ports; both CI suites consume the Go fixtures.
|
||||
3. Land the web migration (video, then audiobooks) in this repo.
|
||||
4. On the release: server advertises the finalized contract via
|
||||
`GET /playback/capability`; legacy start returns `426`. App-store builds and
|
||||
the server release ship together; older apps show the update-required state.
|
||||
5. After the release train merges and soaks on a validation deployment, the
|
||||
contract is **frozen**: all future changes go through the additive feature
|
||||
tokens and capability endpoint that v3 already has. This pass is the last
|
||||
breaking window by design.
|
||||
|
||||
Rollback within the window is "fix forward or revert the train": with the
|
||||
legacy protocol deleted there is deliberately no protocol fallback. That is the
|
||||
accepted cost of the no-compatibility decision, and it is why step 4 gates on
|
||||
full fixture parity in all three repos, not on manual testing alone.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- Regenerate all planner/contract fixtures from the Go generator; add
|
||||
fixtures for: evidence-tier gating (exact vs platform_attested vs declared
|
||||
reaching different routes for the same source), `deliveries` negotiation,
|
||||
opaque attempt-key echo and loop rejection, `track_change`/`quality_change`
|
||||
replans (idempotent duplicates, concurrent, mid-seek), `available_qualities`
|
||||
correctness against the ladder, audio-only planning, `output_context_id`
|
||||
change invalidation, and `426` on legacy start bodies.
|
||||
- Keep the full existing v3 matrix from the original plan §10 (HDR/DV, audio,
|
||||
subtitles, recovery, restart, capacity) — the planner logic it exercises is
|
||||
unchanged; fixtures re-key from engine to delivery.
|
||||
- Fire TV quirk tests unchanged (manufacturer/model matching survives §3.4).
|
||||
- Web: player integration tests against a real backend via the
|
||||
`web-ui-testing` flow; jellycompat and audiobook suites must stay green.
|
||||
|
||||
Verification before each PR in the train:
|
||||
|
||||
```text
|
||||
make lint
|
||||
make test
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
## 7. Sequencing within silo-server
|
||||
|
||||
Each step is one reviewable PR (`Part of` the playback-protocol epic):
|
||||
|
||||
1. **Spec + schemas + fixture generator** — docs, JSON Schemas, Go generator,
|
||||
no behavior change.
|
||||
2. **Attempt-key ownership** — `plan_attempt_key` in responses, opaque echo,
|
||||
delete `KotlinName()`, re-canonicalize, bump `PlanRecipeVersionV3`.
|
||||
3. **Engine removal + claim rename** — wire types, planner re-key, registry
|
||||
claim strings, node-advertisement test update.
|
||||
4. **Evidence tiers + neutral device/output context** — request schema,
|
||||
validation, planner gating, terminal reasons.
|
||||
5. **Replan operations + `available_qualities`** — planner and handler.
|
||||
6. **Audio-only planning.**
|
||||
7. **Web migration** (video, then audiobooks).
|
||||
8. **Legacy deletion + settings-flag removal migration + v1-scope table
|
||||
entries + subtitle-ordinal fix.**
|
||||
|
||||
Android/Apple ports proceed in parallel from step 1's artifacts; step 8 merges
|
||||
only after both client ports are green against the fixtures.
|
||||
|
||||
## 8. Definition of done
|
||||
|
||||
- No Media3, Kotlin, or `android.os.Build` identifier appears in the wire
|
||||
contract, the attempt-key canonical string, or the normative docs.
|
||||
- Clients treat `plan_attempt_key` as opaque; exactly one attempt-key
|
||||
implementation exists, in Go.
|
||||
- The same source file yields correct, tier-appropriate routes for an `exact`
|
||||
Android request, a `platform_attested` Apple request, and a `declared` web
|
||||
request in fixture tests.
|
||||
- Web video and audiobook playback run on v3; `useTranscodeQuality`'s ladder
|
||||
and recipe posting are deleted.
|
||||
- Legacy start, `transcode/start`, and the audio PATCH are removed and
|
||||
recorded in the v1-scope pre-lock removals table.
|
||||
- `docs/architecture/playback-protocol-v3.md` is sufficient for a third-party
|
||||
client to implement playback without reading any client repository.
|
||||
- Android and Apple CI consume the Go fixtures; the Apple shim's alias table
|
||||
and Swift hash are gone.
|
||||
- Existing jellycompat, watch-together, downloads, and restart-resilience
|
||||
tests remain green.
|
||||
@@ -289,9 +289,9 @@ Decision tree:
|
||||
`ErrDownloadNotAllowed` (403). Missing artifact pipeline → `ErrQualityUnavailable` (501).
|
||||
4. `remux` and `transcode` as request values are invalid public qualities.
|
||||
|
||||
Target codec/resolution for compatibility artifacts and bitrate transcodes is chosen by reusing `playback.Resolve` /
|
||||
`playback.SelectVersion` (`internal/playback/resolver.go:60`) against the device's declared capabilities
|
||||
and the admin transcode ceilings, so download encoding matches streaming decisions exactly (no
|
||||
Target codec/resolution for compatibility artifacts and bitrate transcodes is chosen by reusing
|
||||
`playback.Resolve` (`internal/playback/resolver.go`) against the device's declared capabilities and
|
||||
the admin transcode ceilings, so download encoding matches streaming decisions exactly (no
|
||||
duplicated codec logic).
|
||||
|
||||
## Transcode-to-file pipeline (v1)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Transcode Resolution Clamp Design
|
||||
|
||||
**Status: superseded — the invariant survives, the implementation described here
|
||||
does not.** `POST /api/v1/playback/transcode/start` and the handler-side helper
|
||||
were removed with the legacy playback protocol
|
||||
([spec](../../architecture/playback-protocol-v3.md),
|
||||
[plan](../plans/2026-07-30-playback-protocol-v3-neutral-contract.md)). The
|
||||
decision below still holds: read it for *why* the server clamps, and see
|
||||
"Where it lives now" for *where*.
|
||||
|
||||
## Problem
|
||||
|
||||
When video encoding is requested for a 2160p file while 4K transcoding is
|
||||
@@ -34,14 +42,29 @@ The normalization rules are:
|
||||
Recognized resolutions are the same tiers already supported by the transcode
|
||||
filter builders: 2160p, 1080p, 720p, 480p, 420p, and 328p.
|
||||
|
||||
## Architecture and Data Flow
|
||||
## Where it lives now
|
||||
|
||||
A small pure helper in `internal/api/handlers/playback.go` compares requested
|
||||
and effective-source resolution tiers. `HandleStartTranscode` calls it once
|
||||
after the 4K alternate guard has selected and probed the effective file.
|
||||
Under protocol v3 the client no longer names an output resolution, so there is
|
||||
no requested tier to clamp at the API boundary. The invariant moved into the
|
||||
planner, where it is enforced twice against the probed source:
|
||||
|
||||
The normalized value replaces `req.TargetResolution`. Existing downstream
|
||||
paths then automatically use one consistent value for:
|
||||
- `availableQualitiesV3` (`internal/playback/plan_v3.go`) omits every ladder rung
|
||||
at or above the source height, so an over-source target is never offered.
|
||||
- The encode-target path clamps `targetHeight` to `source.Height` before building
|
||||
the recipe, so a stale or hand-built `quality_change` cannot reintroduce one.
|
||||
|
||||
Because the plan is the only way to reach FFmpeg, one clamp now covers session
|
||||
state, activity reporting, and restart reconstruction — the paths the original
|
||||
design had to reach through `req.TargetResolution`.
|
||||
|
||||
## Architecture and Data Flow (as originally shipped)
|
||||
|
||||
A small pure helper in `internal/api/handlers/playback.go` compared requested
|
||||
and effective-source resolution tiers. `HandleStartTranscode` called it once
|
||||
after the 4K alternate guard had selected and probed the effective file.
|
||||
|
||||
The normalized value replaced `req.TargetResolution`. Existing downstream
|
||||
paths then automatically used one consistent value for:
|
||||
|
||||
- transcode-node planning and requests;
|
||||
- integrated/local `playback.TranscodeOpts`;
|
||||
|
||||
+70
-2361
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
func TestPlaybackHandlerTranscodeRuntimeConfigIncludesSegmentRetention(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.PlaybackConfig = func() config.PlaybackConfig {
|
||||
return config.PlaybackConfig{SegmentRetentionSeconds: 300}
|
||||
}
|
||||
|
||||
if got := handler.tm.Config().SegmentRetentionSeconds; got != 300 {
|
||||
t.Fatalf("configured segment retention = %d, want 300", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
|
||||
)
|
||||
|
||||
func TestProxyToTranscodeNodeAcknowledgesOnlyFullDownstreamResponse(t *testing.T) {
|
||||
const (
|
||||
body = "complete segment"
|
||||
generation = "17"
|
||||
)
|
||||
tests := []struct {
|
||||
name string
|
||||
rangeHeader string
|
||||
wantStatus int
|
||||
wantAck int32
|
||||
}{
|
||||
{name: "ordinary get", wantStatus: http.StatusOK, wantAck: 1},
|
||||
{name: "whole-file range", rangeHeader: "bytes=0-", wantStatus: http.StatusPartialContent, wantAck: 1},
|
||||
{name: "partial range", rangeHeader: "bytes=1-", wantStatus: http.StatusPartialContent},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var acknowledgements atomic.Int32
|
||||
seenRange := make(chan string, 1)
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if r.Header.Get(transcodeproxy.RequestHeader) != "1" {
|
||||
t.Error("segment request omitted proxy marker")
|
||||
}
|
||||
seenRange <- r.Header.Get("Range")
|
||||
w.Header().Set(transcodeproxy.GenerationHeader, generation)
|
||||
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
|
||||
case http.MethodPost:
|
||||
if r.Header.Get(transcodeproxy.GenerationHeader) != generation {
|
||||
t.Errorf("ack generation = %q, want %q", r.Header.Get(transcodeproxy.GenerationHeader), generation)
|
||||
}
|
||||
acknowledgements.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
defer node.Close()
|
||||
|
||||
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
|
||||
req := withPlaybackRouteParam(
|
||||
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
|
||||
"session_id",
|
||||
"public",
|
||||
)
|
||||
req.Header.Set("Range", tt.rangeHeader)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.proxyToTranscodeNode(rr, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
|
||||
|
||||
if rr.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %q", rr.Code, tt.wantStatus, rr.Body.String())
|
||||
}
|
||||
if got := <-seenRange; got != tt.rangeHeader {
|
||||
t.Fatalf("forwarded Range = %q, want %q", got, tt.rangeHeader)
|
||||
}
|
||||
if got := rr.Header().Get(transcodeproxy.GenerationHeader); got != "" {
|
||||
t.Fatalf("internal generation leaked downstream: %q", got)
|
||||
}
|
||||
if got := acknowledgements.Load(); got != tt.wantAck {
|
||||
t.Fatalf("acknowledgements = %d, want %d", got, tt.wantAck)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyToTranscodeNodeDoesNotAcknowledgeFailedDownstreamWrite(t *testing.T) {
|
||||
const body = "complete segment"
|
||||
var acknowledgements atomic.Int32
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
acknowledgements.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set(transcodeproxy.GenerationHeader, "21")
|
||||
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
|
||||
}))
|
||||
defer node.Close()
|
||||
|
||||
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
|
||||
req := withPlaybackRouteParam(
|
||||
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
|
||||
"session_id",
|
||||
"public",
|
||||
)
|
||||
w := &failingProxyResponseWriter{header: make(http.Header), remaining: 5}
|
||||
handler.proxyToTranscodeNode(w, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
|
||||
|
||||
if got := acknowledgements.Load(); got != 0 {
|
||||
t.Fatalf("failed downstream transfer produced %d acknowledgement(s)", got)
|
||||
}
|
||||
}
|
||||
|
||||
type failingProxyResponseWriter struct {
|
||||
header http.Header
|
||||
status int
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (w *failingProxyResponseWriter) Header() http.Header { return w.header }
|
||||
|
||||
func (w *failingProxyResponseWriter) WriteHeader(status int) { w.status = status }
|
||||
|
||||
func (w *failingProxyResponseWriter) Write(p []byte) (int, error) {
|
||||
if w.remaining <= 0 {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
n := min(len(p), w.remaining)
|
||||
w.remaining -= n
|
||||
return n, io.ErrClosedPipe
|
||||
}
|
||||
@@ -51,16 +51,22 @@ func TestPlaybackSessionMissingResponsesUseStableErrorCode(t *testing.T) {
|
||||
handle: playbackHandler.HandleStopPlayback,
|
||||
},
|
||||
{
|
||||
name: "audio",
|
||||
name: "replan",
|
||||
request: func() *http.Request {
|
||||
return playbackTestRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/playback/"+missingSessionID+"/audio",
|
||||
[]byte(`{"audio_track_index":1}`),
|
||||
http.MethodPost,
|
||||
"/api/v1/playback/"+missingSessionID+"/replan",
|
||||
// A fully valid replan body: the point of this case is the
|
||||
// missing session, so nothing earlier may reject it.
|
||||
[]byte(`{"protocol_version":3,"playback_attempt_id":"attempt-00000001",`+
|
||||
`"replan_request_id":"replan-00000001","failed_plan_id":"plan:00000000000000",`+
|
||||
`"plan_attempt_id":"plan-attempt-1","plan_attempt_key":"v3:0000000000000001",`+
|
||||
`"attempt_count":1,"failure":{"classification":"decoder_error"},`+
|
||||
`"client_capabilities":{"video_evidence":"exact","audio_evidence":"exact"}}`),
|
||||
map[string]string{"session_id": missingSessionID},
|
||||
)
|
||||
},
|
||||
handle: playbackHandler.HandleChangeAudioTrack,
|
||||
handle: playbackHandler.HandleReplanPlaybackV3,
|
||||
},
|
||||
{
|
||||
name: "websocket",
|
||||
@@ -98,18 +104,6 @@ func TestPlaybackSessionMissingResponsesUseStableErrorCode(t *testing.T) {
|
||||
},
|
||||
handle: streamHandler.HandleSubtitle,
|
||||
},
|
||||
{
|
||||
name: "transcode start",
|
||||
request: func() *http.Request {
|
||||
return playbackTestRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/playback/transcode/start",
|
||||
[]byte(`{"session_id":"`+missingSessionID+`","seek_seconds":0,"target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
|
||||
nil,
|
||||
)
|
||||
},
|
||||
handle: playbackHandler.HandleStartTranscode,
|
||||
},
|
||||
{
|
||||
name: "transcode manifest",
|
||||
request: func() *http.Request {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
func TestSubtitleURLExt(t *testing.T) {
|
||||
cases := []struct {
|
||||
codec string
|
||||
want string
|
||||
}{
|
||||
{"ass", ".ass"},
|
||||
{"ssa", ".ass"},
|
||||
{"pgs", ".sup"},
|
||||
{"hdmv_pgs_subtitle", ".sup"},
|
||||
{"subrip", ".vtt"},
|
||||
{"srt", ".vtt"},
|
||||
{"", ".vtt"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := subtitleURLExt(tc.codec); got != tc.want {
|
||||
t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLsBindsDownloadedSubtitleIdentity(t *testing.T) {
|
||||
file := &models.MediaFile{ID: 42}
|
||||
urls := buildSubtitleURLs("sess-downloaded", file, []subtitles.DownloadedSubtitle{{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT}}, true)
|
||||
if len(urls) != 1 {
|
||||
t.Fatalf("downloaded subtitle URLs = %#v", urls)
|
||||
}
|
||||
if want := "/stream/sess-downloaded/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71"; urls[0].URL != want {
|
||||
t.Fatalf("downloaded subtitle URL = %q, want %q", urls[0].URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "subrip"},
|
||||
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
|
||||
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
|
||||
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-1", file, nil, true)
|
||||
|
||||
// Every bitmap track is deliverable now that server-side burn-in supports
|
||||
// bitmap codecs; PGS additionally streams as .sup for client rendering.
|
||||
if len(urls) != 4 {
|
||||
t.Fatalf("expected 4 subtitle URLs (text + PGS + DVD + DVB), got %d: %+v", len(urls), urls)
|
||||
}
|
||||
|
||||
srt := urls[0]
|
||||
if srt.MediaFileID != file.ID || srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt?file_id=42" {
|
||||
t.Errorf("unexpected text track entry: %+v", srt)
|
||||
}
|
||||
|
||||
pgs := urls[1]
|
||||
if pgs.MediaFileID != file.ID || pgs.Codec != "hdmv_pgs_subtitle" {
|
||||
t.Errorf("expected PGS track to be included, got %+v", pgs)
|
||||
}
|
||||
if pgs.URL != "/stream/sess-1/subtitles/1.sup?file_id=42" {
|
||||
t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL)
|
||||
}
|
||||
if pgs.FontBundleURL != "" {
|
||||
t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL)
|
||||
}
|
||||
|
||||
if dvd := urls[2]; dvd.Codec != "dvd_subtitle" || dvd.Index != 2 {
|
||||
t.Errorf("expected DVD bitmap track to be listed for burn-in selection, got %+v", dvd)
|
||||
}
|
||||
if dvb := urls[3]; dvb.Codec != "dvb_subtitle" || dvb.Index != 3 {
|
||||
t.Errorf("expected DVB bitmap track to be listed for burn-in selection, got %+v", dvb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_HidesBurnInOnlyTracksByDefault(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ID: 43,
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "subrip"},
|
||||
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
|
||||
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
|
||||
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-native", file, nil, false)
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected text and PGS only, got %d: %+v", len(urls), urls)
|
||||
}
|
||||
if urls[0].Codec != "subrip" || urls[1].Codec != "hdmv_pgs_subtitle" {
|
||||
t.Fatalf("unexpected default subtitle URLs: %+v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ID: 44,
|
||||
ExternalSubtitles: []models.ExternalSubtitle{
|
||||
{Path: "/media/movie.en.srt", Language: "en", Format: "srt"},
|
||||
},
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Index: 0, Language: "en", Codec: "pgs"},
|
||||
},
|
||||
}
|
||||
|
||||
urls := buildSubtitleURLs("sess-2", file, nil, false)
|
||||
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls)
|
||||
}
|
||||
pgs := urls[1]
|
||||
if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup?file_id=44" {
|
||||
t.Errorf("PGS track index should include the external offset, got %+v", pgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBurnInSubtitle_RemapsAlternateVersionByStableTraits(t *testing.T) {
|
||||
requested := &models.MediaFile{
|
||||
ID: 1,
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Language: "en", Codec: "subrip", Title: "English"},
|
||||
{Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese", Forced: true},
|
||||
},
|
||||
}
|
||||
effective := &models.MediaFile{
|
||||
ID: 2,
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese", Forced: true},
|
||||
{Language: "en", Codec: "subrip", Title: "English"},
|
||||
},
|
||||
}
|
||||
|
||||
index, codec, ok := resolveBurnInSubtitle(requested, effective, 1)
|
||||
if !ok || index != 0 || codec != "hdmv_pgs_subtitle" {
|
||||
t.Fatalf("resolveBurnInSubtitle() = (%d, %q, %v), want (0, hdmv_pgs_subtitle, true)", index, codec, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBurnInSubtitle_RejectsMissingAlternateTrack(t *testing.T) {
|
||||
requested := &models.MediaFile{
|
||||
ID: 1,
|
||||
SubtitleTracks: []models.SubtitleTrack{
|
||||
{Language: "ja", Codec: "hdmv_pgs_subtitle", Title: "Japanese"},
|
||||
},
|
||||
}
|
||||
effective := &models.MediaFile{ID: 2}
|
||||
|
||||
if index, codec, ok := resolveBurnInSubtitle(requested, effective, 0); ok || index != -1 || codec != "" {
|
||||
t.Fatalf("resolveBurnInSubtitle() = (%d, %q, %v), want unavailable", index, codec, ok)
|
||||
}
|
||||
}
|
||||
+176
-3010
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
const playbackV3ShadowSetting = "playback.protocol_v3_shadow_enabled"
|
||||
|
||||
func (h *PlaybackHandler) protocolV3ShadowEnabled(ctx context.Context) bool {
|
||||
return h.settingFlagCachedV3(ctx, playbackV3ShadowSetting)
|
||||
}
|
||||
|
||||
// shadowLegacyPlaybackV3 compares the production legacy route with a v3 plan
|
||||
// without advertising or executing that plan. Legacy capabilities lack the
|
||||
// detailed v3 fields, so exact source facts are used only as an explicitly
|
||||
// labelled validation inference; this is never a production compatibility
|
||||
// claim and cannot enable v3 playback.
|
||||
func (h *PlaybackHandler) shadowLegacyPlaybackV3(ctx context.Context, req startPlaybackRequest, requestedFile, effectiveFile *models.MediaFile, audioIndex int, productionMethod playback.PlayMethod, productionTranscodeAudio bool, sessionID string) {
|
||||
// The shadow planner runs on a bare goroutine off the production start
|
||||
// path; an escaped panic here would take down the whole process for a
|
||||
// telemetry-only comparison, so it must fail closed instead.
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
slog.ErrorContext(ctx, "playback v3 shadow planner panicked",
|
||||
"component", "playback", "playback_session_id", sessionID,
|
||||
"panic", recovered, "stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
if !h.protocolV3ShadowEnabled(ctx) || requestedFile == nil || effectiveFile == nil {
|
||||
return
|
||||
}
|
||||
request := legacyShadowRequestV3(req, effectiveFile, audioIndex, sessionID)
|
||||
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
|
||||
Request: request,
|
||||
RequestedFile: requestedFile,
|
||||
EffectiveFile: effectiveFile,
|
||||
AudioTrackIndex: audioIndex,
|
||||
Settings: h.plannerSettingsV3(ctx),
|
||||
Registry: h.transformationRegistryV3(ctx),
|
||||
HLSRegistry: h.lazyHLSPlanningRegistryV3(ctx),
|
||||
Now: time.Now(),
|
||||
})
|
||||
attrs := []any{
|
||||
"component", "playback",
|
||||
"playback_session_id", sessionID,
|
||||
"requested_file_id", requestedFile.ID,
|
||||
"effective_file_id", effectiveFile.ID,
|
||||
"production_method", productionMethod,
|
||||
"production_transcode_audio", productionTranscodeAudio,
|
||||
"capability_basis", "legacy_source_exact_inference",
|
||||
}
|
||||
if result.Plan != nil {
|
||||
attrs = append(attrs,
|
||||
"shadow_plan_id", result.Plan.PlanID,
|
||||
"shadow_delivery", result.Plan.Delivery,
|
||||
"shadow_method", result.PlayMethod,
|
||||
"shadow_transcode_audio", result.TranscodeAudio,
|
||||
"shadow_reason", result.Plan.DecisionReason,
|
||||
"route_match", result.PlayMethod == productionMethod && result.TranscodeAudio == productionTranscodeAudio,
|
||||
)
|
||||
} else if result.Terminal != nil {
|
||||
attrs = append(attrs, "shadow_terminal", result.Terminal.Reason, "route_match", false)
|
||||
}
|
||||
slog.InfoContext(ctx, "playback v3 shadow decision", attrs...)
|
||||
}
|
||||
|
||||
func legacyShadowRequestV3(req startPlaybackRequest, file *models.MediaFile, audioIndex int, sessionID string) playback.StartRequestV3 {
|
||||
source := playback.SourceDescriptorFromFileV3(file, audioIndex)
|
||||
hdr := legacyShadowHDRV3(req)
|
||||
features := []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}
|
||||
capabilities := playback.ClientCodecCapabilitiesV3{
|
||||
CodecsVideo: append([]string(nil), req.CodecsVideo...),
|
||||
CodecsAudio: append([]string(nil), req.CodecsAudio...),
|
||||
Containers: append([]string(nil), req.Containers...),
|
||||
MaxResolution: req.MaxResolution,
|
||||
HDR: req.HDR,
|
||||
HDRDetails: hdr,
|
||||
}
|
||||
if containsStringFoldV3(req.CodecsVideo, source.VideoCodec) {
|
||||
capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
|
||||
Codec: source.VideoCodec, Profiles: []string{source.VideoProfile}, Levels: []int{source.VideoLevel}, BitDepths: []int{source.BitDepth},
|
||||
MaxWidth: source.Width, MaxHeight: source.Height, MaxFrameRate: source.FrameRate, MaxBitrateKbps: source.BitrateKbps, Hardware: true,
|
||||
}}
|
||||
}
|
||||
var passthrough *playback.AudioPassthroughV3
|
||||
if req.AudioPassthrough != nil {
|
||||
passthrough = &playback.AudioPassthroughV3{PassthroughCodecs: append([]string(nil), req.AudioPassthrough.PassthroughCodecs...), SpatializerEnabled: req.AudioPassthrough.SpatializerEnabled, MaxChannels: req.AudioPassthrough.MaxChannels}
|
||||
if containsStringFoldV3(req.AudioPassthrough.PassthroughCodecs, source.AudioCodec) && source.AudioChannels > 0 && source.AudioLayout != "" && source.AudioChannels <= req.AudioPassthrough.MaxChannels {
|
||||
passthrough.Entries = []playback.AudioPassthroughEntryV3{{Codec: source.AudioCodec, ChannelCounts: []int{source.AudioChannels}, Layouts: []string{source.AudioLayout}}}
|
||||
features = append(features, playback.FeatureLayoutPassthrough)
|
||||
}
|
||||
capabilities.AudioPassthrough = passthrough
|
||||
}
|
||||
engines := map[string]playback.EngineCapabilityV3{
|
||||
string(playback.EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true, EmbeddedBitmap: true, SidecarBitmap: true}},
|
||||
string(playback.EngineMedia3ProgressiveRemuxV3): {Enabled: true, SupportedOnDevice: true},
|
||||
string(playback.EngineMedia3HLSV3): {Enabled: true, SupportedOnDevice: true},
|
||||
}
|
||||
return playback.StartRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, ClientFeatures: features, FileID: file.ID, ProfileID: req.ProfileID,
|
||||
PlaybackAttemptID: "shadow-" + sessionID, QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3,
|
||||
StartPosition: req.StartPosition, AudioTrackIndex: &audioIndex, Capabilities: capabilities,
|
||||
ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, Features: features, Platform: "legacy-shadow", Output: playback.OutputContextV3{HDRDetails: hdr, AudioPassthrough: passthrough}, Engines: engines},
|
||||
}
|
||||
}
|
||||
|
||||
func legacyShadowHDRV3(req startPlaybackRequest) *playback.HDRCapabilitiesV3 {
|
||||
if req.HdrDetails != nil {
|
||||
return &playback.HDRCapabilitiesV3{HDR10: req.HdrDetails.HDR10, HDR10Plus: req.HdrDetails.HDR10Plus, HLG: req.HdrDetails.HLG, DolbyVisionProfiles: append([]int(nil), req.HdrDetails.DolbyVisionProfiles...)}
|
||||
}
|
||||
if req.HDR {
|
||||
return &playback.HDRCapabilitiesV3{HDR10: true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ func TestHLSPlanningRegistryV3UnionsPooledNodeCapabilities(t *testing.T) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
|
||||
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
|
||||
}})
|
||||
}))
|
||||
@@ -42,7 +42,7 @@ func TestHLSPlanningRegistryV3UnionsPooledNodeCapabilities(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
|
||||
{Name: "video_to_h264", RecipeVersion: "1"},
|
||||
{Name: "video_to_h264", RecipeVersion: "2"},
|
||||
{Name: "audio_to_aac", RecipeVersion: "1"},
|
||||
{Name: "server_dv7_to_hdr10", RecipeVersion: "1"},
|
||||
}))
|
||||
@@ -123,7 +123,7 @@ func TestRemoteTransformationsV3FailureCacheSplit(t *testing.T) {
|
||||
func TestPlanNodeSessionV3PrefersCapabilityMatchingNode(t *testing.T) {
|
||||
capable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, playback.HWAccelInfo{Transformations: []playback.TransformationV3{
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
|
||||
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
|
||||
}})
|
||||
}))
|
||||
@@ -146,7 +146,7 @@ func TestPlanNodeSessionV3PrefersCapabilityMatchingNode(t *testing.T) {
|
||||
PlanID: "plan:heterogeneous",
|
||||
Delivery: playback.DeliveryTranscodeHLSV3,
|
||||
Transformations: []playback.TransformationV3{
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
|
||||
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
|
||||
},
|
||||
}
|
||||
@@ -165,14 +165,14 @@ func TestPlanNodeSessionV3PrefersCapabilityMatchingNode(t *testing.T) {
|
||||
func TestPrepareTransportV3LocalFallbackRejectsUnavailableTransformations(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
|
||||
{Name: "video_to_h264", RecipeVersion: "1"},
|
||||
{Name: "video_to_h264", RecipeVersion: "2"},
|
||||
{Name: "audio_to_aac", RecipeVersion: "1"},
|
||||
}))
|
||||
plan := &playback.PlanV3{
|
||||
PlanID: "plan:local-capability",
|
||||
Delivery: playback.DeliveryTranscodeHLSV3,
|
||||
Transformations: []playback.TransformationV3{
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"},
|
||||
{Name: "video_to_h264", Executor: "server", RecipeVersion: "2"},
|
||||
{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ func TestUpdateProfileSyncsCanonicalMetadataLanguage(t *testing.T) {
|
||||
|
||||
// TestUpdateProfileSyncsCanonicalAudioLanguage is the playback-start half: a
|
||||
// profile that never had a backfilled row chooses a spoken language, and the
|
||||
// canonical store — which handleStartPlaybackLegacy resolves — must carry it.
|
||||
// canonical store — which preferredAudioTrackIndexV3 resolves when a start omits
|
||||
// the audio track — must carry it.
|
||||
func TestUpdateProfileSyncsCanonicalAudioLanguage(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
||||
|
||||
@@ -20,6 +20,12 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
const (
|
||||
subtitleFormatASS = "ass"
|
||||
subtitleFormatSSA = "ssa"
|
||||
subtitleFormatSUP = "sup"
|
||||
)
|
||||
|
||||
// FilePathResolver looks up a media file by its ID.
|
||||
type FilePathResolver interface {
|
||||
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
||||
@@ -159,7 +165,18 @@ func (h *StreamHandler) HandleStream(w http.ResponseWriter, r *http.Request) {
|
||||
seekSeconds = s
|
||||
}
|
||||
}
|
||||
if err := playback.ServeRemuxWithDVMode(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile(), session.RemuxDVMode, h.ffmpegPath()); err != nil {
|
||||
// An audio-only source muxes an audio-only fMP4. The v3 plan promises
|
||||
// audio/mp4 for it, and a declared-tier client refuses to attach a
|
||||
// source buffer whose advertised type its probe rejected — so the
|
||||
// response has to keep the same promise the plan made.
|
||||
if err := playback.ServeRemuxWithOptions(w, r, file.FilePath, "mp4", seekSeconds, session.TranscodeAudio, session.AudioTrackIndex, file.PrimaryDVProfile(), playback.RemuxServeOptions{
|
||||
DVMode: session.RemuxDVMode,
|
||||
FFmpegPath: h.ffmpegPath(),
|
||||
ContentType: playback.RemuxContentType(file.IsAudioOnly()),
|
||||
AudioOnly: file.IsAudioOnly(),
|
||||
TargetAudioChannels: session.TargetAudioChannels,
|
||||
TargetAudioBitrateKbps: session.TargetAudioBitrateKbps,
|
||||
}); err != nil {
|
||||
h.handleTransportStartFailure(r.Context(), session, file, err)
|
||||
}
|
||||
|
||||
@@ -219,10 +236,16 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// New subtitle artifact URLs bind downloaded subtitles by their stable row
|
||||
// trackIndex is a combined ordinal, resolved through the same three
|
||||
// consecutive ranges playback.BuildSubtitleInventoryV3 assigns them from:
|
||||
// externals, then embedded container tracks, then downloaded ones. The
|
||||
// ranges cover the full track arrays — including bitmap tracks that have no
|
||||
// sidecar shape — so an ordinal always names the same track here as it does
|
||||
// in the published inventory.
|
||||
// Downloaded subtitle URLs additionally bind that ordinal to a stable row
|
||||
// identity. The path ordinal remains for compatibility and display, but it
|
||||
// must not be re-resolved against a mutable inventory after a seek reanchor.
|
||||
if rawID := strings.TrimSpace(r.URL.Query().Get(downloadedSubtitleIDParam)); rawID != "" {
|
||||
if rawID := strings.TrimSpace(r.URL.Query().Get(playback.DownloadedSubtitleIDParamV3)); rawID != "" {
|
||||
downloadedID, parseErr := strconv.Atoi(rawID)
|
||||
if parseErr != nil || downloadedID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid downloaded subtitle identity")
|
||||
@@ -246,13 +269,25 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
writeSubtitleRepresentationHead(w, requestedFormat)
|
||||
return
|
||||
}
|
||||
h.serveDownloadedSubtitle(w, r, *downloaded, requestedFormat)
|
||||
return
|
||||
}
|
||||
|
||||
externalCount := len(file.ExternalSubtitles)
|
||||
if trackIndex < externalCount {
|
||||
sub := file.ExternalSubtitles[trackIndex]
|
||||
if !subtitleSidecarFormatSupported(sub.Format, requestedFormat, false) {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
|
||||
"Requested subtitle extension does not match the selected track")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
writeSubtitleRepresentationHead(w, requestedFormat)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve ASS/SSA external subtitles as raw data for client-side rendering.
|
||||
if playback.IsASS(sub.Format) && requestedFormat != "vtt" {
|
||||
@@ -262,7 +297,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
"Failed to load external subtitle")
|
||||
return
|
||||
}
|
||||
playback.ServeSubtitle(w, data, "ass")
|
||||
playback.ServeSubtitle(w, data, subtitleFormatASS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -289,6 +324,15 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
"Bitmap subtitle tracks cannot be extracted as text")
|
||||
return
|
||||
}
|
||||
if !subtitleSidecarFormatSupported(track.Codec, requestedFormat, true) {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
|
||||
"Requested subtitle extension does not match the selected track")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodHead && requestedFormat != subtitleFormatSUP {
|
||||
writeSubtitleRepresentationHead(w, requestedFormat)
|
||||
return
|
||||
}
|
||||
|
||||
// Dedicated streaming extract — ffmpeg seeks to the current
|
||||
// playback position and pipes cues to the response as they're
|
||||
@@ -318,6 +362,10 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
downloadedIndex := embeddedIndex - len(file.SubtitleTracks)
|
||||
if downloadedIndex >= 0 && downloadedIndex < len(downloaded) {
|
||||
if r.Method == http.MethodHead {
|
||||
writeSubtitleRepresentationHead(w, requestedFormat)
|
||||
return
|
||||
}
|
||||
h.serveDownloadedSubtitle(w, r, downloaded[downloadedIndex], requestedFormat)
|
||||
return
|
||||
}
|
||||
@@ -327,6 +375,11 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.Request, subtitle subtitles.DownloadedSubtitle, requestedFormat string) {
|
||||
if !subtitleSidecarFormatSupported(string(subtitle.Format), requestedFormat, false) {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type",
|
||||
"Requested subtitle extension does not match the selected track")
|
||||
return
|
||||
}
|
||||
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, subtitle.S3Key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
|
||||
@@ -335,7 +388,7 @@ func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.R
|
||||
|
||||
// Serve ASS/SSA downloaded subtitles as raw data.
|
||||
if playback.IsASS(string(subtitle.Format)) && requestedFormat != "vtt" {
|
||||
playback.ServeSubtitle(w, data, "ass")
|
||||
playback.ServeSubtitle(w, data, subtitleFormatASS)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -354,6 +407,40 @@ func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.R
|
||||
playback.ServeSubtitle(w, vttData, "vtt")
|
||||
}
|
||||
|
||||
// subtitleSidecarFormatSupported keeps bitmap and styled-text requests within
|
||||
// the representations the server can produce. Plain text tracks preserve the
|
||||
// v1 endpoint's permissive extension behavior and are always returned as VTT;
|
||||
// ASS/SSA may also be served losslessly, and only an embedded PGS track has a
|
||||
// binary .sup representation.
|
||||
func subtitleSidecarFormatSupported(codec, requestedFormat string, embeddedPGS bool) bool {
|
||||
requestedFormat = strings.ToLower(strings.TrimSpace(requestedFormat))
|
||||
if requestedFormat == "" {
|
||||
return true
|
||||
}
|
||||
if playback.IsPGS(codec) {
|
||||
return embeddedPGS && requestedFormat == subtitleFormatSUP
|
||||
}
|
||||
if playback.NeedsBurnIn(codec) {
|
||||
return false
|
||||
}
|
||||
if playback.IsASS(codec) {
|
||||
return requestedFormat == subtitleFormatASS || requestedFormat == subtitleFormatSSA || requestedFormat == "vtt"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeSubtitleRepresentationHead(w http.ResponseWriter, requestedFormat string) {
|
||||
switch strings.ToLower(strings.TrimSpace(requestedFormat)) {
|
||||
case subtitleFormatASS, subtitleFormatSSA:
|
||||
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
|
||||
case subtitleFormatSUP:
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// subtitleSourceFileID pins a subtitle URL to the file whose track list was
|
||||
// used to create it. A quality/seek restart may change session.MediaFileID to
|
||||
// an alternate version; interpreting the old combined track index against the
|
||||
@@ -527,9 +614,9 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
outFormat := "vtt"
|
||||
switch {
|
||||
case playback.IsASS(track.Codec):
|
||||
outFormat = "ass"
|
||||
outFormat = subtitleFormatASS
|
||||
case playback.IsPGS(track.Codec):
|
||||
outFormat = "sup"
|
||||
outFormat = subtitleFormatSUP
|
||||
}
|
||||
|
||||
// ASS is fetched exactly once and consumed whole by its client-side
|
||||
@@ -549,7 +636,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
case "vtt":
|
||||
seek = subtitleSeekPosition(r, session)
|
||||
duration = subtitleWindowDuration(r)
|
||||
case "sup":
|
||||
case subtitleFormatSUP:
|
||||
allowWindow, seek, duration = playback.PGSWindowRequest(r.URL.Query())
|
||||
}
|
||||
slog.InfoContext(r.Context(), "subtitle stream requested", "component", "api",
|
||||
@@ -594,14 +681,14 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
|
||||
// cached full track when present (warming it in the background when
|
||||
// not). All other formats stream uncached: VTT is already windowed
|
||||
// and fast, ASS is small.
|
||||
if outFormat == "sup" {
|
||||
if outFormat == subtitleFormatSUP {
|
||||
err := h.SubtitleCache.ServeSUPExtract(w, r, opts, playback.StreamExtractSubtitle)
|
||||
playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex)
|
||||
return
|
||||
}
|
||||
|
||||
switch outFormat {
|
||||
case "ass":
|
||||
case subtitleFormatASS:
|
||||
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
@@ -199,6 +201,77 @@ func TestHandleStream_KeepsSessionWhenLookupFailsForNonMissingReason(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// A v3 plan for an audio-only source promises audio/mp4, because a
|
||||
// declared-tier client probes the advertised MIME with isTypeSupported before
|
||||
// it will attach a source buffer and "video/mp4" for a stream carrying no video
|
||||
// track is exactly the mismatch that makes that probe lie. The remux response
|
||||
// has to keep the promise the plan made.
|
||||
func TestHandleStream_AudioOnlyRemuxServesAudioContentType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
file *models.MediaFile
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "audio only source",
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "audiobook-1",
|
||||
BaseType: "audiobook",
|
||||
CodecAudio: "flac",
|
||||
Duration: 39600,
|
||||
},
|
||||
wantContent: playback.AudioOnlyRemuxMIMEV3,
|
||||
},
|
||||
{
|
||||
name: "video source",
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
CodecVideo: "h264",
|
||||
CodecAudio: "flac",
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264"}},
|
||||
Duration: 3600,
|
||||
},
|
||||
wantContent: "video/mp4",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
file := *tc.file
|
||||
file.FilePath = writePlaybackTestMediaFile(t, "source.mkv")
|
||||
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
ffmpeg := filepath.Join(t.TempDir(), "ffmpeg")
|
||||
if err := os.WriteFile(ffmpeg, []byte("#!/bin/sh\nprintf muxed\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake ffmpeg: %v", err)
|
||||
}
|
||||
handler := NewStreamHandler(sessionMgr, testPlaybackFileResolver{file: &file})
|
||||
handler.PlaybackConfig = func() config.PlaybackConfig {
|
||||
return config.PlaybackConfig{FFmpegPath: ffmpeg}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID, nil)
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
req = withPlaybackRouteParam(req, "session_id", session.ID)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStream(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); got != tc.wantContent {
|
||||
t.Fatalf("Content-Type = %q, want %q", got, tc.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500 pins the fix for
|
||||
// issue #248: a failure listing downloaded subtitles must surface as a 500 with
|
||||
// an "internal_error" code, not be swallowed and reported to the client as a
|
||||
@@ -363,6 +436,166 @@ func TestHandleSubtitle_BitmapTrackVTTRequestReturns415(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubtitle_ExternalTextTrackSRTRequestReturnsVTT(t *testing.T) {
|
||||
subtitlePath := filepath.Join(t.TempDir(), "movie.en.srt")
|
||||
if err := os.WriteFile(subtitlePath, []byte("1\n00:00:01,000 --> 00:00:02,000\nHello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: "/tmp/movie.mkv",
|
||||
Duration: 3600,
|
||||
ExternalSubtitles: []models.ExternalSubtitle{
|
||||
{Path: subtitlePath, Language: "eng", Format: "srt"},
|
||||
},
|
||||
}
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.srt", nil)
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("session_id", session.ID)
|
||||
routeCtx.URLParams.Add("track", "0.srt")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSubtitle(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, content-type = %q, body = %s; want 200", rr.Code, rr.Header().Get("Content-Type"), rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/vtt") {
|
||||
t.Fatalf("content type = %q, want WebVTT", got)
|
||||
}
|
||||
if body := rr.Body.String(); !strings.HasPrefix(body, "WEBVTT") || !strings.Contains(body, "Hello") {
|
||||
t.Fatalf("body = %q, want converted WebVTT", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubtitle_ExternalASSTrackASSRequestReturnsRawASS(t *testing.T) {
|
||||
const assBody = "[Script Info]\nTitle: Test\n"
|
||||
subtitlePath := filepath.Join(t.TempDir(), "movie.en.ass")
|
||||
if err := os.WriteFile(subtitlePath, []byte(assBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: "/tmp/movie.mkv",
|
||||
Duration: 3600,
|
||||
ExternalSubtitles: []models.ExternalSubtitle{{Path: subtitlePath, Language: "eng", Format: "ass"}},
|
||||
}
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.ass", nil)
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("session_id", session.ID)
|
||||
routeCtx.URLParams.Add("track", "0.ass")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSubtitle(rr, req)
|
||||
if rr.Code != http.StatusOK || rr.Body.String() != assBody {
|
||||
t.Fatalf("status = %d, content-type = %q, body = %q; want raw ASS", rr.Code, rr.Header().Get("Content-Type"), rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/x-ssa") {
|
||||
t.Fatalf("content type = %q, want raw ASS", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubtitle_ExternalTextHEADDoesNotLoadTheArtifact(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: "/missing/movie.mkv",
|
||||
Duration: 3600,
|
||||
ExternalSubtitles: []models.ExternalSubtitle{{Path: "/missing/movie.en.srt", Language: "eng", Format: "srt"}},
|
||||
}
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", file.ID, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
|
||||
req := httptest.NewRequest(http.MethodHead, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt", nil)
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("session_id", session.ID)
|
||||
routeCtx.URLParams.Add("track", "0.vtt")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSubtitle(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK || rr.Body.Len() != 0 || !strings.HasPrefix(rr.Header().Get("Content-Type"), "text/vtt") {
|
||||
t.Fatalf("HEAD status=%d type=%q body=%q", rr.Code, rr.Header().Get("Content-Type"), rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubtitle_EmbeddedPGSSupportsCachedHEADAndRange(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "movie.mkv"),
|
||||
Duration: 3600,
|
||||
// The external track deliberately occupies combined ordinal 0. The PGS
|
||||
// container track is therefore addressed as ordinal 1, even though its
|
||||
// embedded subtitle-stream index is 0.
|
||||
ExternalSubtitles: []models.ExternalSubtitle{{Path: "/tmp/movie.en.srt", Format: "srt"}},
|
||||
SubtitleTracks: []models.SubtitleTrack{{Index: 2, Language: "eng", Codec: "hdmv_pgs_subtitle"}},
|
||||
}
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", file.ID, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
cacheRoot := t.TempDir()
|
||||
cache := playback.NewSubtitleCache(func() string { return cacheRoot })
|
||||
warmReq := httptest.NewRequest(http.MethodGet, "/sub.sup", nil)
|
||||
warmRR := httptest.NewRecorder()
|
||||
if err := cache.ServeSUPExtract(warmRR, warmReq, playback.StreamExtractOpts{
|
||||
InputPath: file.FilePath, TrackIndex: 0, SourceCodec: "hdmv_pgs_subtitle",
|
||||
}, func(_ context.Context, opts playback.StreamExtractOpts) error {
|
||||
_, err := opts.Writer.Write([]byte("SUP PAYLOAD"))
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("warm PGS cache: %v", err)
|
||||
}
|
||||
|
||||
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
|
||||
handler.SubtitleCache = cache
|
||||
request := func(method, rangeHeader string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, "/api/v1/stream/"+session.ID+"/subtitles/1.sup?file_id=42", nil)
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("session_id", session.ID)
|
||||
routeCtx.URLParams.Add("track", "1.sup")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSubtitle(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
head := request(http.MethodHead, "")
|
||||
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Type") != "application/octet-stream" || head.Header().Get("Content-Length") != "11" {
|
||||
t.Fatalf("HEAD status=%d type=%q length=%q body=%q", head.Code, head.Header().Get("Content-Type"), head.Header().Get("Content-Length"), head.Body.String())
|
||||
}
|
||||
ranged := request(http.MethodGet, "bytes=4-10")
|
||||
if ranged.Code != http.StatusPartialContent || ranged.Body.String() != "PAYLOAD" || ranged.Header().Get("Content-Type") != "application/octet-stream" {
|
||||
t.Fatalf("Range status=%d type=%q body=%q", ranged.Code, ranged.Header().Get("Content-Type"), ranged.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleSourceFileIDPinsURLAcrossEffectiveFileSwitch(t *testing.T) {
|
||||
session := &playback.Session{MediaFileID: 200, RequestedMediaFileID: 100}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
// SubtitleInventoryResolver supplies the playback package with the two
|
||||
// repositories it needs to rebuild a file's combined-ordinal subtitle
|
||||
// inventory outside a request: the media file itself and the downloaded
|
||||
// subtitle rows that occupy the tail of the ordinal space.
|
||||
//
|
||||
// It exists so realtime subtitle events can publish the ordinal a newly
|
||||
// generated track will hold in the next plan, instead of leaving each client to
|
||||
// reconstruct one by counting the tracks it can see.
|
||||
type SubtitleInventoryResolver struct {
|
||||
files FilePathResolver
|
||||
subtitles subtitles.Repository
|
||||
}
|
||||
|
||||
// NewSubtitleInventoryResolver returns a resolver, or nil when no file lookup
|
||||
// is available. A nil subtitle repository is fine: the inventory then covers
|
||||
// only the file's external and embedded tracks.
|
||||
func NewSubtitleInventoryResolver(files FilePathResolver, repo subtitles.Repository) *SubtitleInventoryResolver {
|
||||
if files == nil {
|
||||
return nil
|
||||
}
|
||||
return &SubtitleInventoryResolver{files: files, subtitles: repo}
|
||||
}
|
||||
|
||||
// MediaFile loads the file whose subtitle inventory is being resolved.
|
||||
func (r *SubtitleInventoryResolver) MediaFile(ctx context.Context, fileID int) (*models.MediaFile, error) {
|
||||
if r == nil || r.files == nil {
|
||||
return nil, errors.New("subtitle inventory resolver has no media file lookup")
|
||||
}
|
||||
return r.files.GetByID(ctx, fileID)
|
||||
}
|
||||
|
||||
// AdditionalSubtitles returns the downloaded and generated tracks that follow
|
||||
// the file's external and embedded tracks in the combined ordinal space.
|
||||
func (r *SubtitleInventoryResolver) AdditionalSubtitles(ctx context.Context, file *models.MediaFile) ([]playback.SubtitleInventoryEntryV3, error) {
|
||||
if r == nil || r.subtitles == nil || file == nil {
|
||||
return nil, nil
|
||||
}
|
||||
downloaded, err := r.subtitles.ListDownloadedSubtitles(ctx, file.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return downloadedSubtitleEntriesV3(file, downloaded), nil
|
||||
}
|
||||
+17
-3
@@ -1037,7 +1037,22 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
playbackHandler.MarkerUpserter = deps.FileRepo
|
||||
}
|
||||
playbackHandler.MarkerUpdateNotifier = playback.NewMarkerUpdateNotifier(deps.SessionMgr, realtimeHub)
|
||||
subtitleAINotifier = playback.NewSubtitleReadyNotifier(deps.SessionMgr, realtimeHub)
|
||||
// A resolver lets subtitle realtime events carry the combined ordinal
|
||||
// the new track will hold in the next plan. Without a file repository
|
||||
// the notifier still fires; its events just omit the track block.
|
||||
var subtitleInventoryResolver playback.SubtitleInventoryResolver
|
||||
if deps.FileRepo != nil {
|
||||
// subtitleRepo is a concrete pointer: pass it only when non-nil so
|
||||
// the resolver holds a nil interface rather than a typed nil.
|
||||
var subtitleReader subtitles.Repository
|
||||
if subtitleRepo != nil {
|
||||
subtitleReader = subtitleRepo
|
||||
}
|
||||
if resolver := handlers.NewSubtitleInventoryResolver(deps.FileRepo, subtitleReader); resolver != nil {
|
||||
subtitleInventoryResolver = resolver
|
||||
}
|
||||
}
|
||||
subtitleAINotifier = playback.NewSubtitleReadyNotifier(deps.SessionMgr, realtimeHub, subtitleInventoryResolver)
|
||||
adminPlaybackControlHandler = handlers.NewAdminPlaybackControlHandler(playbackHandler)
|
||||
|
||||
if deps.DB != nil && deps.FileRepo != nil && viewerResolver != nil && deps.Config != nil && detailSvc != nil {
|
||||
@@ -2599,9 +2614,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Post("/{session_id}/replan", playbackHandler.HandleReplanPlaybackV3)
|
||||
r.Post("/route-events", playbackHandler.HandlePlaybackRouteEventV3)
|
||||
r.Post("/{session_id}/progress", playbackHandler.HandleUpdateProgress)
|
||||
r.Patch("/{session_id}/audio", playbackHandler.HandleChangeAudioTrack)
|
||||
r.Delete("/{session_id}", playbackHandler.HandleStopPlayback)
|
||||
r.Post("/transcode/start", playbackHandler.HandleStartTranscode)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -2632,6 +2645,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/stream/{session_id}", streamHandler.HandleStream)
|
||||
r.Head("/stream/{session_id}", streamHandler.HandleStream)
|
||||
r.Get("/stream/{session_id}/subtitles/{track}", streamHandler.HandleSubtitle)
|
||||
r.Head("/stream/{session_id}/subtitles/{track}", streamHandler.HandleSubtitle)
|
||||
r.Get("/stream/{session_id}/subtitles/{track}/fonts", streamHandler.HandleSubtitleFonts)
|
||||
}
|
||||
|
||||
|
||||
@@ -806,14 +806,19 @@ func buildEpisodeCatalogEntryQueryWhere(def QueryDefinition, argIdx int) (string
|
||||
switch len(groupClauses) {
|
||||
case 0:
|
||||
return "", args, argIdx, true, nil
|
||||
// The returned expression is always parenthesized, mirroring
|
||||
// QueryBuilder.Build: a match-any group emits a top-level OR, and the
|
||||
// callers AND library, rating, snapshot, and other access constraints
|
||||
// onto the result. Without the parentheses, SQL precedence lets an OR
|
||||
// arm bypass every one of them.
|
||||
case 1:
|
||||
return groupClauses[0], args, argIdx, true, nil
|
||||
return "(" + groupClauses[0] + ")", args, argIdx, true, nil
|
||||
default:
|
||||
wrapped := make([]string, len(groupClauses))
|
||||
for i, clause := range groupClauses {
|
||||
wrapped[i] = "(" + clause + ")"
|
||||
}
|
||||
return strings.Join(wrapped, topJoiner), args, argIdx, true, nil
|
||||
return "(" + strings.Join(wrapped, topJoiner) + ")", args, argIdx, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -230,3 +230,37 @@ func TestExtractEpisodeCatalogUserStatePlanForLastWatched(t *testing.T) {
|
||||
t.Fatalf("expected genre rule to remain in entry def, got %+v", plan.entryDef.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEpisodeCatalogEntryQueryWhereParenthesizesMatchAnyGroup(t *testing.T) {
|
||||
// Both episode preview paths append this expression to an AND-joined
|
||||
// whereParts list that already carries the library, content-rating, and
|
||||
// series-parent-guard constraints. A match-any group emits a top-level OR,
|
||||
// so the result must come back parenthesized — otherwise SQL precedence
|
||||
// detaches an OR arm from every one of those constraints.
|
||||
def := QueryDefinition{
|
||||
MediaScope: "episode",
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "any",
|
||||
Rules: []QueryRule{
|
||||
{Field: "genre", Op: "contains", Value: "Action"},
|
||||
{Field: "genre", Op: "contains", Value: "Adventure"},
|
||||
},
|
||||
}},
|
||||
}.Normalize()
|
||||
|
||||
where, args, nextArgIdx, ok, err := buildEpisodeCatalogEntryQueryWhere(def, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("buildEpisodeCatalogEntryQueryWhere returned error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("buildEpisodeCatalogEntryQueryWhere unexpectedly fell back")
|
||||
}
|
||||
want := "(ece.genres @> ARRAY[$5]::text[] OR ece.genres @> ARRAY[$6]::text[])"
|
||||
if where != want {
|
||||
t.Fatalf("WHERE = %q, want %q", where, want)
|
||||
}
|
||||
if nextArgIdx != 7 || len(args) != 2 {
|
||||
t.Fatalf("nextArgIdx = %d, len(args) = %d; want 7, 2", nextArgIdx, len(args))
|
||||
}
|
||||
}
|
||||
|
||||
+325
-66
@@ -3,6 +3,7 @@ package catalog
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -57,27 +58,63 @@ func (r *NextUpRepository) ListNextUp(ctx context.Context, q NextUpQuery) ([]Nex
|
||||
limit = 20
|
||||
}
|
||||
|
||||
query, args := buildListNextUpQuery(q, limit)
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying next-up episodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []NextUpResult
|
||||
for rows.Next() {
|
||||
var res NextUpResult
|
||||
if err := rows.Scan(
|
||||
&res.ContentID, &res.SeriesID, &res.SeriesTitle,
|
||||
&res.SeasonNumber, &res.EpisodeNumber, &res.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning next-up row: %w", err)
|
||||
if q.SeriesID != "" {
|
||||
query, args := buildListNextUpQuery(q, limit, nil)
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying next-up episodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var res NextUpResult
|
||||
if err := rows.Scan(
|
||||
&res.ContentID, &res.SeriesID, &res.SeriesTitle,
|
||||
&res.SeasonNumber, &res.EpisodeNumber, &res.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning next-up row: %w", err)
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating next-up rows: %w", err)
|
||||
}
|
||||
} else {
|
||||
remaining := limit
|
||||
var cursor *nextUpWalkCursor
|
||||
batchesRun := 0
|
||||
exhausted := false
|
||||
|
||||
// Every batch resumes below the previous compound frontier, so appending
|
||||
// batches preserves strictly descending (updated_at, media_item_id) order.
|
||||
for batch := 0; batch < nextUpAnchorMaxBatches; batch++ {
|
||||
batchResults, frontier, err := r.listNextUpBatch(ctx, q, remaining, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batchesRun++
|
||||
results = append(results, batchResults...)
|
||||
remaining -= len(batchResults)
|
||||
|
||||
if remaining <= 0 {
|
||||
exhausted = true
|
||||
break
|
||||
}
|
||||
if frontier == nil || frontier.n < nextUpAnchorMaxSeries {
|
||||
exhausted = true
|
||||
break
|
||||
}
|
||||
cursor = &frontier.nextUpWalkCursor
|
||||
}
|
||||
|
||||
if !exhausted && remaining > 0 {
|
||||
slog.WarnContext(ctx, "next-up anchor walk hit batch cap; eligible series tail left unscanned",
|
||||
"component", "catalog",
|
||||
"profile_id", q.ProfileID,
|
||||
"batches", batchesRun,
|
||||
"results_found", len(results))
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating next-up rows: %w", err)
|
||||
}
|
||||
|
||||
if q.EnableResumable {
|
||||
@@ -109,22 +146,82 @@ func (r *NextUpRepository) ListNextUp(ctx context.Context, q NextUpQuery) ([]Nex
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// nextUpAnchorMaxRows bounds how many of the profile's most recent completed
|
||||
// rows the global next-up query considers when deriving per-series anchors.
|
||||
// Without a bound the completed_episodes CTE re-reads the profile's entire
|
||||
// completed history per call — 233k rows joined to episodes for the worst
|
||||
// bulk-import profile, measured at up to 44.7s. A next-up rail surfaces ~24
|
||||
// series; the 500 most recently completed episodes cover every series that can
|
||||
// realistically rank on it (a series whose last completed episode is older
|
||||
// than 500 completions of other content sorts far past the rail's limit).
|
||||
// Progress rows are recency-ordered on idx_uwp_profile_completed, so the
|
||||
// bounded anchor scan is an ordered index walk. Series-scoped calls (the
|
||||
// show-detail tile) stay unbounded: they must anchor on the series' last
|
||||
// completed episode no matter how long ago it was watched, and are naturally
|
||||
// bounded by one series.
|
||||
const nextUpAnchorMaxRows = 500
|
||||
type nextUpWalkCursor struct {
|
||||
updatedAt time.Time
|
||||
mediaItemID string
|
||||
seen []string
|
||||
}
|
||||
|
||||
func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
|
||||
type nextUpWalkFrontier struct {
|
||||
nextUpWalkCursor
|
||||
n int
|
||||
}
|
||||
|
||||
// nextUpAnchorMaxSeries bounds the number of distinct series anchors visited by
|
||||
// one global next-up batch. A row-based cap let one series consume the window;
|
||||
// counting distinct series prevents that, while batching continues past a full
|
||||
// batch whose anchors are all ineligible. Series-scoped calls stay unbounded so
|
||||
// they can anchor on the series' last completed episode regardless of age.
|
||||
const nextUpAnchorMaxSeries = 96
|
||||
|
||||
// nextUpAnchorMaxBatches bounds a global call to 10 × 96 = 960 visited series
|
||||
// as a runaway guard.
|
||||
const nextUpAnchorMaxBatches = 10
|
||||
|
||||
func (r *NextUpRepository) listNextUpBatch(
|
||||
ctx context.Context,
|
||||
q NextUpQuery,
|
||||
limit int,
|
||||
cursor *nextUpWalkCursor,
|
||||
) ([]NextUpResult, *nextUpWalkFrontier, error) {
|
||||
query, args := buildListNextUpQuery(q, limit, cursor)
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("querying next-up episodes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []NextUpResult
|
||||
var frontier *nextUpWalkFrontier
|
||||
for rows.Next() {
|
||||
currentFrontier := nextUpWalkFrontier{}
|
||||
var contentID, seriesID, seriesTitle *string
|
||||
var seasonNumber, episodeNumber *int
|
||||
var completedAt *time.Time
|
||||
if err := rows.Scan(
|
||||
¤tFrontier.updatedAt,
|
||||
¤tFrontier.mediaItemID,
|
||||
¤tFrontier.seen,
|
||||
¤tFrontier.n,
|
||||
&contentID,
|
||||
&seriesID,
|
||||
&seriesTitle,
|
||||
&seasonNumber,
|
||||
&episodeNumber,
|
||||
&completedAt,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("scanning next-up row: %w", err)
|
||||
}
|
||||
frontier = ¤tFrontier
|
||||
if contentID == nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, NextUpResult{
|
||||
ContentID: *contentID,
|
||||
SeriesID: *seriesID,
|
||||
SeriesTitle: *seriesTitle,
|
||||
SeasonNumber: *seasonNumber,
|
||||
EpisodeNumber: *episodeNumber,
|
||||
CompletedAt: *completedAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("iterating next-up rows: %w", err)
|
||||
}
|
||||
return results, frontier, nil
|
||||
}
|
||||
|
||||
func buildListNextUpQuery(q NextUpQuery, limit int, cursor *nextUpWalkCursor) (string, []interface{}) {
|
||||
args := []interface{}{q.UserID, q.ProfileID, limit}
|
||||
argIdx := 4
|
||||
|
||||
@@ -142,6 +239,21 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
|
||||
argIdx++
|
||||
}
|
||||
|
||||
// seedSeen is projected alongside the seed's picked series, which the global
|
||||
// walk exposes as `pick`; the series-scoped branch has no walk and no seed.
|
||||
cursorFilter := ""
|
||||
seedSeen := "ARRAY[pick.series_id]"
|
||||
if q.SeriesID == "" && cursor != nil {
|
||||
cursorUpdatedAtArg := argIdx
|
||||
cursorMediaItemIDArg := argIdx + 1
|
||||
cursorSeenArg := argIdx + 2
|
||||
cursorFilter = fmt.Sprintf(`
|
||||
AND (uwp.updated_at, uwp.media_item_id) < ($%d, $%d)
|
||||
AND NOT (e.series_id = ANY($%d))`, cursorUpdatedAtArg, cursorMediaItemIDArg, cursorSeenArg)
|
||||
seedSeen = fmt.Sprintf("$%d::text[] || pick.series_id", cursorSeenArg)
|
||||
args = append(args, cursor.updatedAt, cursor.mediaItemID, cursor.seen)
|
||||
}
|
||||
|
||||
// When resumable items are disabled, suppress next-up only if the series has
|
||||
// newer in-progress activity than the most recent completed episode. Older
|
||||
// partial watches should not block the user's current progression.
|
||||
@@ -169,11 +281,16 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
|
||||
sourceTable = "eligible_series"
|
||||
}
|
||||
|
||||
// Global queries derive series anchors from a bounded recent-completions
|
||||
// scan (see nextUpAnchorMaxRows); series-scoped queries keep the unbounded
|
||||
// shape so the anchor is the series' last completed episode regardless of
|
||||
// age. The hidden-items exclusion and date cutoff apply inside the bounded
|
||||
// scan so hidden/old rows never consume the anchor budget.
|
||||
// Global queries use a recursive loose-index scan to find at most
|
||||
// nextUpAnchorMaxSeries distinct series without letting one series' rows
|
||||
// consume the budget. The compound cursor is the total order required when
|
||||
// bulk updates give many rows the same timestamp; because media_item_id is
|
||||
// unique it can only order the walk, never decide which episode of a series
|
||||
// anchors it — see the anchor lateral below.
|
||||
// Series-scoped queries keep the unbounded shape so the anchor is the
|
||||
// series' last completed episode regardless of age. Hidden rows and the date
|
||||
// cutoff are excluded inside every walk step so they neither become anchors
|
||||
// nor advance the cursor.
|
||||
var completedEpisodesCTE string
|
||||
if q.SeriesID != "" {
|
||||
completedEpisodesCTE = fmt.Sprintf(`completed_episodes AS (
|
||||
@@ -200,37 +317,121 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
|
||||
ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC
|
||||
)`, seriesFilter, dateCutoffFilter)
|
||||
} else {
|
||||
completedEpisodesCTE = fmt.Sprintf(`recent_completed AS (
|
||||
SELECT uwp.media_item_id, uwp.updated_at
|
||||
FROM user_watch_progress uwp
|
||||
WHERE uwp.user_id = $1
|
||||
AND uwp.profile_id = $2
|
||||
AND uwp.completed = TRUE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = uwp.user_id
|
||||
AND hhi.profile_id = uwp.profile_id
|
||||
AND hhi.media_item_id = uwp.media_item_id
|
||||
AND uwp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
%s
|
||||
ORDER BY uwp.updated_at DESC
|
||||
LIMIT %d
|
||||
// Picking the series and picking which of its episodes to anchor on are
|
||||
// two different orderings, so they are two different steps.
|
||||
//
|
||||
// The walk advances on (updated_at, media_item_id), the total order that
|
||||
// guarantees forward progress. media_item_id is unique, so no two rows
|
||||
// ever tie on that pair and any season/episode clause appended behind it
|
||||
// is unreachable. A bulk mark-watched series — every row written with one
|
||||
// timestamp — would then anchor on whichever content_id sorts highest,
|
||||
// which is an arbitrary season once IDs are not scan-ordered (season 2
|
||||
// scanned before a season-1 backfill), and the rail would surface an
|
||||
// already-watched episode's successor.
|
||||
//
|
||||
// So `pick` chooses the series and the cursor position, and the anchor
|
||||
// lateral then chooses the episode by (season, episode) among that
|
||||
// series' rows sharing pick.updated_at — its newest completed timestamp.
|
||||
// Pinning updated_at rather than re-sorting keeps this an index probe,
|
||||
// and both rows carry the same updated_at, so cursor order and the
|
||||
// reported CompletedAt are unchanged by the split.
|
||||
anchorLateral := `JOIN LATERAL (
|
||||
SELECT e_a.season_number, e_a.episode_number
|
||||
FROM episodes e_a
|
||||
JOIN user_watch_progress uwp_a
|
||||
ON uwp_a.media_item_id = e_a.content_id
|
||||
AND uwp_a.user_id = $1
|
||||
AND uwp_a.profile_id = $2
|
||||
AND uwp_a.completed = TRUE
|
||||
AND uwp_a.updated_at = pick.updated_at
|
||||
WHERE e_a.series_id = pick.series_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = uwp_a.user_id
|
||||
AND hhi.profile_id = uwp_a.profile_id
|
||||
AND hhi.media_item_id = uwp_a.media_item_id
|
||||
AND uwp_a.updated_at <= hhi.hidden_before
|
||||
)
|
||||
ORDER BY e_a.season_number DESC, e_a.episode_number DESC
|
||||
LIMIT 1
|
||||
) anchor ON TRUE`
|
||||
|
||||
completedEpisodesCTE = fmt.Sprintf(`RECURSIVE walk AS (
|
||||
(
|
||||
SELECT
|
||||
pick.series_id,
|
||||
anchor.season_number,
|
||||
anchor.episode_number,
|
||||
pick.updated_at,
|
||||
pick.media_item_id,
|
||||
%s AS seen,
|
||||
1 AS n
|
||||
FROM (
|
||||
SELECT e.series_id, uwp.updated_at, uwp.media_item_id
|
||||
FROM user_watch_progress uwp
|
||||
JOIN episodes e ON e.content_id = uwp.media_item_id
|
||||
WHERE uwp.user_id = $1
|
||||
AND uwp.profile_id = $2
|
||||
AND uwp.completed = TRUE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = uwp.user_id
|
||||
AND hhi.profile_id = uwp.profile_id
|
||||
AND hhi.media_item_id = uwp.media_item_id
|
||||
AND uwp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
%s
|
||||
%s
|
||||
ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC
|
||||
LIMIT 1
|
||||
) pick
|
||||
%s
|
||||
)
|
||||
UNION ALL
|
||||
SELECT
|
||||
pick.series_id,
|
||||
anchor.season_number,
|
||||
anchor.episode_number,
|
||||
pick.updated_at,
|
||||
pick.media_item_id,
|
||||
w.seen || pick.series_id,
|
||||
w.n + 1
|
||||
FROM walk w
|
||||
JOIN LATERAL (
|
||||
SELECT e.series_id, uwp.updated_at, uwp.media_item_id
|
||||
FROM user_watch_progress uwp
|
||||
JOIN episodes e ON e.content_id = uwp.media_item_id
|
||||
WHERE uwp.user_id = $1
|
||||
AND uwp.profile_id = $2
|
||||
AND uwp.completed = TRUE
|
||||
AND (uwp.updated_at, uwp.media_item_id) < (w.updated_at, w.media_item_id)
|
||||
AND NOT (e.series_id = ANY(w.seen))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_history_hidden_items hhi
|
||||
WHERE hhi.user_id = uwp.user_id
|
||||
AND hhi.profile_id = uwp.profile_id
|
||||
AND hhi.media_item_id = uwp.media_item_id
|
||||
AND uwp.updated_at <= hhi.hidden_before
|
||||
)
|
||||
%s
|
||||
ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC
|
||||
LIMIT 1
|
||||
) pick ON true
|
||||
%s
|
||||
WHERE w.n < %d
|
||||
),
|
||||
completed_episodes AS (
|
||||
SELECT DISTINCT ON (e.series_id)
|
||||
e.series_id,
|
||||
e.season_number,
|
||||
e.episode_number,
|
||||
uwp.updated_at
|
||||
FROM recent_completed uwp
|
||||
JOIN episodes e ON e.content_id = uwp.media_item_id
|
||||
ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC
|
||||
)`, dateCutoffFilter, nextUpAnchorMaxRows)
|
||||
SELECT series_id, season_number, episode_number, updated_at
|
||||
FROM walk
|
||||
)`, seedSeen, cursorFilter, dateCutoffFilter, anchorLateral,
|
||||
dateCutoffFilter, anchorLateral, nextUpAnchorMaxSeries)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
if q.SeriesID != "" {
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s
|
||||
%s
|
||||
SELECT
|
||||
@@ -260,6 +461,64 @@ func buildListNextUpQuery(q NextUpQuery, limit int) (string, []interface{}) {
|
||||
) next_ep ON true
|
||||
ORDER BY es.updated_at DESC
|
||||
LIMIT $3`, completedEpisodesCTE, inProgressExclusion, sourceTable)
|
||||
return query, args
|
||||
}
|
||||
|
||||
resultQuery := fmt.Sprintf(`
|
||||
SELECT
|
||||
next_ep.content_id,
|
||||
next_ep.series_id,
|
||||
si.title,
|
||||
next_ep.season_number,
|
||||
next_ep.episode_number,
|
||||
es.updated_at AS completed_at
|
||||
FROM %s es
|
||||
JOIN media_items si ON si.content_id = es.series_id
|
||||
JOIN LATERAL (
|
||||
SELECT e2.content_id, e2.series_id, e2.season_number, e2.episode_number
|
||||
FROM episodes e2
|
||||
WHERE e2.series_id = es.series_id
|
||||
AND (e2.season_number, e2.episode_number) > (es.season_number, es.episode_number)
|
||||
AND EXISTS (SELECT 1 FROM media_files mf WHERE mf.episode_id = e2.content_id AND mf.missing_since IS NULL)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_watch_progress uwp2
|
||||
WHERE uwp2.user_id = $1
|
||||
AND uwp2.profile_id = $2
|
||||
AND uwp2.media_item_id = e2.content_id
|
||||
AND (uwp2.completed = TRUE OR uwp2.position_seconds > 0)
|
||||
)
|
||||
ORDER BY e2.season_number, e2.episode_number
|
||||
LIMIT 1
|
||||
) next_ep ON true
|
||||
ORDER BY es.updated_at DESC
|
||||
LIMIT $3`, sourceTable)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH %s
|
||||
%s
|
||||
,
|
||||
frontier AS (
|
||||
SELECT w.updated_at, w.media_item_id, w.seen, w.n
|
||||
FROM walk w
|
||||
ORDER BY w.n DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
f.updated_at AS frontier_updated_at,
|
||||
f.media_item_id AS frontier_media_item_id,
|
||||
f.seen AS frontier_seen,
|
||||
f.n AS frontier_n,
|
||||
r.content_id,
|
||||
r.series_id,
|
||||
r.title,
|
||||
r.season_number,
|
||||
r.episode_number,
|
||||
r.completed_at
|
||||
FROM frontier f
|
||||
LEFT JOIN (
|
||||
%s
|
||||
) r ON true
|
||||
ORDER BY r.completed_at DESC NULLS LAST`, completedEpisodesCTE, inProgressExclusion, resultQuery)
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
type nextUpTestStoreProvider struct{}
|
||||
|
||||
func (nextUpTestStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (nextUpTestStoreProvider) Close() error { return nil }
|
||||
|
||||
func newNextUpTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("SILO_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test database: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
var tableName *string
|
||||
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.media_items')::text`).Scan(&tableName); err != nil {
|
||||
t.Fatalf("check media_items table: %v", err)
|
||||
}
|
||||
if tableName == nil || *tableName == "" {
|
||||
t.Skip("test database has not applied base schema")
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query, args := buildListNextUpQuery(NextUpQuery{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
}, 20)
|
||||
}, 20, nil)
|
||||
|
||||
expectedFragments := []string{
|
||||
"eligible_series AS (",
|
||||
@@ -22,7 +62,7 @@ func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t *
|
||||
"FROM user_history_hidden_items hhi",
|
||||
"uwp.updated_at <= hhi.hidden_before",
|
||||
"AND (uwp2.completed = TRUE OR uwp2.position_seconds > 0)",
|
||||
"ORDER BY e.series_id, uwp.updated_at DESC, e.season_number DESC, e.episode_number DESC",
|
||||
"ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC",
|
||||
"LIMIT $3",
|
||||
}
|
||||
for _, fragment := range expectedFragments {
|
||||
@@ -43,29 +83,100 @@ func TestBuildListNextUpQuery_PrefersRecentCompletedOverOlderPartialProgress(t *
|
||||
func TestBuildListNextUpQuery_GlobalBoundsAnchorScan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query, _ := buildListNextUpQuery(NextUpQuery{
|
||||
q := NextUpQuery{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
}, 20)
|
||||
|
||||
// The global query must derive series anchors from a bounded
|
||||
// recent-completions scan, not the profile's entire completed history.
|
||||
if !strings.Contains(query, "recent_completed AS (") {
|
||||
t.Fatalf("expected global query to scan recent completions, got:\n%s", query)
|
||||
}
|
||||
if !strings.Contains(query, fmt.Sprintf("LIMIT %d", nextUpAnchorMaxRows)) {
|
||||
t.Fatalf("expected global anchor scan bounded at %d rows, got:\n%s", nextUpAnchorMaxRows, query)
|
||||
query, args := buildListNextUpQuery(q, 20, nil)
|
||||
|
||||
expectedFragments := []string{
|
||||
"WITH RECURSIVE",
|
||||
"(uwp.updated_at, uwp.media_item_id) < (w.updated_at, w.media_item_id)",
|
||||
"NOT (e.series_id = ANY(w.seen))",
|
||||
fmt.Sprintf("w.n < %d", nextUpAnchorMaxSeries),
|
||||
"frontier AS (",
|
||||
"LEFT JOIN (",
|
||||
}
|
||||
for _, fragment := range expectedFragments {
|
||||
if !strings.Contains(query, fragment) {
|
||||
t.Fatalf("expected global anchor walk to contain %q, got:\n%s", fragment, query)
|
||||
}
|
||||
}
|
||||
// Seed step, recursive step, and the anchor lateral attached to each.
|
||||
if got := strings.Count(query, "FROM user_history_hidden_items hhi"); got != 4 {
|
||||
t.Fatalf("expected hidden-items filter in every walk step and anchor, got %d occurrences:\n%s", got, query)
|
||||
}
|
||||
seed := strings.SplitN(query, "UNION ALL", 2)[0]
|
||||
if strings.Contains(seed, "(uwp.updated_at, uwp.media_item_id) < ($") {
|
||||
t.Fatalf("first batch seed must not contain a continuation cursor, got:\n%s", seed)
|
||||
}
|
||||
if strings.Contains(seed, "e.series_id = ANY($") {
|
||||
t.Fatalf("first batch seed must not contain a seen-series exclusion, got:\n%s", seed)
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected first batch args only, got %v", args)
|
||||
}
|
||||
|
||||
cursorAt := time.Now().UTC()
|
||||
cursor := &nextUpWalkCursor{
|
||||
updatedAt: cursorAt,
|
||||
mediaItemID: "episode-96",
|
||||
seen: []string{"series-1", "series-2"},
|
||||
}
|
||||
continuedQuery, continuedArgs := buildListNextUpQuery(q, 7, cursor)
|
||||
continuedSeed := strings.SplitN(continuedQuery, "UNION ALL", 2)[0]
|
||||
continuedFragments := []string{
|
||||
"(uwp.updated_at, uwp.media_item_id) < ($4, $5)",
|
||||
"NOT (e.series_id = ANY($6))",
|
||||
"$6::text[] || pick.series_id AS seen",
|
||||
"frontier AS (",
|
||||
"LEFT JOIN (",
|
||||
}
|
||||
for _, fragment := range continuedFragments {
|
||||
if !strings.Contains(continuedQuery, fragment) {
|
||||
t.Fatalf("expected continued query to contain %q, got:\n%s", fragment, continuedQuery)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(continuedSeed, "(uwp.updated_at, uwp.media_item_id) < ($4, $5)") ||
|
||||
!strings.Contains(continuedSeed, "NOT (e.series_id = ANY($6))") {
|
||||
t.Fatalf("continued batch seed must contain cursor and seen-series predicates, got:\n%s", continuedSeed)
|
||||
}
|
||||
if len(continuedArgs) != 6 || continuedArgs[3] != cursorAt || continuedArgs[4] != cursor.mediaItemID {
|
||||
t.Fatalf("unexpected continuation args: %v", continuedArgs)
|
||||
}
|
||||
seenArg, ok := continuedArgs[5].([]string)
|
||||
if !ok || len(seenArg) != len(cursor.seen) || seenArg[0] != cursor.seen[0] || seenArg[1] != cursor.seen[1] {
|
||||
t.Fatalf("expected cursor seen array verbatim, got %#v", continuedArgs[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListNextUpQuery_GlobalDateCutoffAppliesToEveryWalkStep(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour)
|
||||
query, args := buildListNextUpQuery(NextUpQuery{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
DateCutoff: &cutoff,
|
||||
}, 20, nil)
|
||||
|
||||
if got := strings.Count(query, "AND uwp.updated_at >= $4"); got != 2 {
|
||||
t.Fatalf("expected date cutoff in seed and recursive step, got %d occurrences:\n%s", got, query)
|
||||
}
|
||||
if len(args) != 4 || args[3] != cutoff {
|
||||
t.Fatalf("expected one shared date-cutoff arg, got %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListNextUpQuery_SeriesScopedKeepsUnboundedAnchor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query, args := buildListNextUpQuery(NextUpQuery{
|
||||
q := NextUpQuery{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
SeriesID: "series-42",
|
||||
}, 20)
|
||||
}
|
||||
query, args := buildListNextUpQuery(q, 20, nil)
|
||||
|
||||
// The show-detail tile must anchor on the series' last completed episode
|
||||
// no matter how long ago it was watched: the recency bound would make a
|
||||
@@ -73,12 +184,461 @@ func TestBuildListNextUpQuery_SeriesScopedKeepsUnboundedAnchor(t *testing.T) {
|
||||
if strings.Contains(query, "recent_completed AS (") {
|
||||
t.Fatalf("series-scoped query must not bound the anchor scan, got:\n%s", query)
|
||||
}
|
||||
if strings.Contains(query, "WITH RECURSIVE") {
|
||||
t.Fatalf("series-scoped query must keep its non-recursive SQL shape, got:\n%s", query)
|
||||
}
|
||||
if !strings.Contains(query, "AND e.series_id = $4") {
|
||||
t.Fatalf("series-scoped query must filter by series_id, got:\n%s", query)
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("expected 4 args with SeriesID, got %d (%v)", len(args), args)
|
||||
}
|
||||
|
||||
queryWithCursor, argsWithCursor := buildListNextUpQuery(q, 20, &nextUpWalkCursor{
|
||||
updatedAt: time.Now().UTC(),
|
||||
mediaItemID: "ignored-episode",
|
||||
seen: []string{"ignored-series"},
|
||||
})
|
||||
if queryWithCursor != query || fmt.Sprint(argsWithCursor) != fmt.Sprint(args) {
|
||||
t.Fatalf("series-scoped SQL and args must be byte-identical when a walk cursor is supplied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListNextUpQuery_GlobalAnchorOrdersByEpisodeNotMediaItemID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// media_item_id is unique, so a season/episode tie-break appended behind it
|
||||
// can never be reached. Choosing the series and choosing which of its
|
||||
// episodes anchors the rail have to be separate orderings.
|
||||
query, _ := buildListNextUpQuery(NextUpQuery{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
}, 20, nil)
|
||||
|
||||
if strings.Contains(query, `ORDER BY uwp.updated_at DESC, uwp.media_item_id DESC,
|
||||
e.season_number DESC, e.episode_number DESC`) {
|
||||
t.Fatalf("season/episode must not sit behind the unique media_item_id tie-break, got:\n%s", query)
|
||||
}
|
||||
if got := strings.Count(query, "ORDER BY e_a.season_number DESC, e_a.episode_number DESC"); got != 2 {
|
||||
t.Fatalf("expected episode-ordered anchor in seed and recursive step, got %d occurrences:\n%s", got, query)
|
||||
}
|
||||
if got := strings.Count(query, "AND uwp_a.updated_at = pick.updated_at"); got != 2 {
|
||||
t.Fatalf("expected anchor pinned to the series' newest completed timestamp, got %d:\n%s", got, query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextUpRepository_BulkMarkWatchedAnchorsOnHighestEpisode(t *testing.T) {
|
||||
// A bulk mark-watched series writes every row with one timestamp. The anchor
|
||||
// must then be the highest (season, episode), not the highest content_id:
|
||||
// with production-shape IDs where season 2 was scanned before a season-1
|
||||
// backfill, season 1 sorts higher and the rail would surface s01e04 — an
|
||||
// episode after one the user already watched — instead of s02e04.
|
||||
pool := newNextUpTestPool(t)
|
||||
ctx := context.Background()
|
||||
prefix := fmt.Sprintf("nextup-order-%d", time.Now().UnixNano())
|
||||
seriesID := prefix + "-series"
|
||||
|
||||
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, seriesID)
|
||||
})
|
||||
seedNextUpSeries(t, ctx, pool, seriesID, prefix+" Ordering")
|
||||
|
||||
// Season 2 scanned first (lower IDs), season 1 backfilled later (higher IDs).
|
||||
const wantContentID = "1" + "00000000000000004" // s02e04
|
||||
const trapContentID = "9" + "00000000000000004" // s01e04
|
||||
episodeIDs := []string{
|
||||
"100000000000000001", "100000000000000002", "100000000000000003", wantContentID,
|
||||
"900000000000000001", "900000000000000002", "900000000000000003", trapContentID,
|
||||
}
|
||||
seasons := []int{2, 2, 2, 2, 1, 1, 1, 1}
|
||||
numbers := []int{1, 2, 3, 4, 1, 2, 3, 4}
|
||||
for i, episodeID := range episodeIDs {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO episodes (content_id, series_id, season_number, episode_number, title)
|
||||
VALUES ($1, $2, $3, $4, 'Ordering Episode')
|
||||
`, episodeID, seriesID, seasons[i], numbers[i]); err != nil {
|
||||
t.Fatalf("seed episode %s: %v", episodeID, err)
|
||||
}
|
||||
}
|
||||
seedNextUpFiles(t, ctx, pool, folderID, episodeIDs)
|
||||
|
||||
// Every completed row shares one timestamp, as a bulk mark-watched write does.
|
||||
bulkAt := time.Now().UTC().Truncate(time.Microsecond)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_watch_progress (
|
||||
user_id, profile_id, media_item_id, position_seconds,
|
||||
duration_seconds, completed, updated_at
|
||||
)
|
||||
SELECT $1, $2, unnest($3::text[]), 0, 1800, TRUE, $4
|
||||
`, userID, profileID, []string{
|
||||
"100000000000000001", "100000000000000002", "100000000000000003",
|
||||
"900000000000000001", "900000000000000002", "900000000000000003",
|
||||
}, bulkAt); err != nil {
|
||||
t.Fatalf("seed bulk progress: %v", err)
|
||||
}
|
||||
|
||||
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
|
||||
results, err := repo.ListNextUp(ctx, NextUpQuery{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
Limit: 20,
|
||||
EnableResumable: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNextUp: %v", err)
|
||||
}
|
||||
assertNextUpContentIDs(t, results, wantContentID)
|
||||
}
|
||||
|
||||
func TestNextUpRepository_GlobalAnchorWalkSurvivesFloodedSeries(t *testing.T) {
|
||||
pool := newNextUpTestPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
prefix := fmt.Sprintf("nextup-flood-%d", suffix)
|
||||
seriesA := prefix + "-series-a"
|
||||
seriesB := prefix + "-series-b"
|
||||
seriesC := prefix + "-series-c"
|
||||
seriesIDs := []string{seriesA, seriesB, seriesC}
|
||||
episodeAPrefix := prefix + "-episode-a-"
|
||||
episodeB1 := prefix + "-episode-b-1"
|
||||
episodeB2 := prefix + "-episode-b-2"
|
||||
episodeC1 := prefix + "-episode-c-1"
|
||||
episodeC2 := prefix + "-episode-c-2"
|
||||
|
||||
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
|
||||
})
|
||||
|
||||
seedNextUpSeries(t, ctx, pool, seriesA, prefix+" Series A")
|
||||
seedNextUpSeries(t, ctx, pool, seriesB, prefix+" Series B")
|
||||
seedNextUpSeries(t, ctx, pool, seriesC, prefix+" Series C")
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO episodes (content_id, series_id, season_number, episode_number, title)
|
||||
SELECT $1 || gs::text, $2, 1, gs, 'Flood Episode ' || gs::text
|
||||
FROM generate_series(1, 600) gs
|
||||
`, episodeAPrefix, seriesA); err != nil {
|
||||
t.Fatalf("seed flooded episodes: %v", err)
|
||||
}
|
||||
seedNextUpEpisodes(t, ctx, pool,
|
||||
[]string{episodeB1, episodeB2, episodeC1, episodeC2},
|
||||
[]string{seriesB, seriesB, seriesC, seriesC},
|
||||
[]int{1, 2, 1, 2},
|
||||
)
|
||||
seedNextUpFiles(t, ctx, pool, folderID, []string{episodeB2, episodeC2})
|
||||
|
||||
floodedAt := time.Now().UTC().Truncate(time.Microsecond)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_watch_progress (
|
||||
user_id, profile_id, media_item_id, position_seconds,
|
||||
duration_seconds, completed, updated_at
|
||||
)
|
||||
SELECT $1, $2, $3 || gs::text, 0, 1800, TRUE, $4
|
||||
FROM generate_series(1, 600) gs
|
||||
`, userID, profileID, episodeAPrefix, floodedAt); err != nil {
|
||||
t.Fatalf("seed flooded progress: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_watch_progress (
|
||||
user_id, profile_id, media_item_id, position_seconds,
|
||||
duration_seconds, completed, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 0, 1800, TRUE, $5),
|
||||
($1, $2, $4, 0, 1800, TRUE, $6)
|
||||
`, userID, profileID, episodeB1, episodeC1, floodedAt.Add(-time.Hour), floodedAt.Add(-2*time.Hour)); err != nil {
|
||||
t.Fatalf("seed older series progress: %v", err)
|
||||
}
|
||||
|
||||
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
|
||||
results, err := repo.ListNextUp(ctx, NextUpQuery{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
Limit: 20,
|
||||
EnableResumable: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNextUp: %v", err)
|
||||
}
|
||||
assertNextUpContentIDs(t, results, episodeB2, episodeC2)
|
||||
}
|
||||
|
||||
func TestNextUpRepository_GlobalAnchorWalkKeepsSameTimestampSeries(t *testing.T) {
|
||||
pool := newNextUpTestPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
prefix := fmt.Sprintf("nextup-cursor-%d", suffix)
|
||||
seriesD := prefix + "-series-d"
|
||||
seriesE := prefix + "-series-e"
|
||||
seriesIDs := []string{seriesD, seriesE}
|
||||
episodeD1 := prefix + "-episode-d-1"
|
||||
episodeD2 := prefix + "-episode-d-2"
|
||||
episodeE1 := prefix + "-episode-e-1"
|
||||
episodeE2 := prefix + "-episode-e-2"
|
||||
|
||||
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
|
||||
})
|
||||
|
||||
seedNextUpSeries(t, ctx, pool, seriesD, prefix+" Series D")
|
||||
seedNextUpSeries(t, ctx, pool, seriesE, prefix+" Series E")
|
||||
seedNextUpEpisodes(t, ctx, pool,
|
||||
[]string{episodeD1, episodeD2, episodeE1, episodeE2},
|
||||
[]string{seriesD, seriesD, seriesE, seriesE},
|
||||
[]int{1, 2, 1, 2},
|
||||
)
|
||||
seedNextUpFiles(t, ctx, pool, folderID, []string{episodeD2, episodeE2})
|
||||
|
||||
completedAt := time.Now().UTC().Truncate(time.Microsecond)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_watch_progress (
|
||||
user_id, profile_id, media_item_id, position_seconds,
|
||||
duration_seconds, completed, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 0, 1800, TRUE, $5),
|
||||
($1, $2, $4, 0, 1800, TRUE, $5)
|
||||
`, userID, profileID, episodeD1, episodeE1, completedAt); err != nil {
|
||||
t.Fatalf("seed same-timestamp progress: %v", err)
|
||||
}
|
||||
|
||||
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
|
||||
results, err := repo.ListNextUp(ctx, NextUpQuery{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
Limit: 20,
|
||||
EnableResumable: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNextUp: %v", err)
|
||||
}
|
||||
assertNextUpContentIDs(t, results, episodeD2, episodeE2)
|
||||
}
|
||||
|
||||
func TestNextUpRepository_GlobalAnchorWalkContinuesPastIneligibleSeries(t *testing.T) {
|
||||
pool := newNextUpTestPool(t)
|
||||
ctx := context.Background()
|
||||
prefix := fmt.Sprintf("nextup-ineligible-%d", time.Now().UnixNano())
|
||||
|
||||
const ineligibleCount = 100
|
||||
seriesIDs := make([]string, 0, ineligibleCount+2)
|
||||
episodeIDs := make([]string, 0, ineligibleCount+4)
|
||||
episodeSeriesIDs := make([]string, 0, ineligibleCount+4)
|
||||
episodeNumbers := make([]int, 0, ineligibleCount+4)
|
||||
completedEpisodeIDs := make([]string, 0, ineligibleCount+2)
|
||||
completedTimes := make([]time.Time, 0, ineligibleCount+2)
|
||||
|
||||
userID, profileID, folderID := seedNextUpTestOwner(t, ctx, pool, prefix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
|
||||
})
|
||||
|
||||
newest := time.Now().UTC().Truncate(time.Microsecond)
|
||||
for i := 0; i < ineligibleCount; i++ {
|
||||
seriesID := fmt.Sprintf("%s-series-%03d", prefix, i)
|
||||
episodeID := seriesID + "-episode-1"
|
||||
seriesIDs = append(seriesIDs, seriesID)
|
||||
episodeIDs = append(episodeIDs, episodeID)
|
||||
episodeSeriesIDs = append(episodeSeriesIDs, seriesID)
|
||||
episodeNumbers = append(episodeNumbers, 1)
|
||||
completedEpisodeIDs = append(completedEpisodeIDs, episodeID)
|
||||
completedTimes = append(completedTimes, newest.Add(-time.Duration(i)*time.Minute))
|
||||
seedNextUpSeries(t, ctx, pool, seriesID, fmt.Sprintf("%s Ineligible %03d", prefix, i))
|
||||
}
|
||||
|
||||
olderSeriesA := prefix + "-older-series-a"
|
||||
olderSeriesB := prefix + "-older-series-b"
|
||||
olderA1 := olderSeriesA + "-episode-1"
|
||||
olderA2 := olderSeriesA + "-episode-2"
|
||||
olderB1 := olderSeriesB + "-episode-1"
|
||||
olderB2 := olderSeriesB + "-episode-2"
|
||||
seriesIDs = append(seriesIDs, olderSeriesA, olderSeriesB)
|
||||
seedNextUpSeries(t, ctx, pool, olderSeriesA, prefix+" Older A")
|
||||
seedNextUpSeries(t, ctx, pool, olderSeriesB, prefix+" Older B")
|
||||
episodeIDs = append(episodeIDs, olderA1, olderA2, olderB1, olderB2)
|
||||
episodeSeriesIDs = append(episodeSeriesIDs, olderSeriesA, olderSeriesA, olderSeriesB, olderSeriesB)
|
||||
episodeNumbers = append(episodeNumbers, 1, 2, 1, 2)
|
||||
completedEpisodeIDs = append(completedEpisodeIDs, olderA1, olderB1)
|
||||
completedTimes = append(completedTimes,
|
||||
newest.Add(-(ineligibleCount+1)*time.Minute),
|
||||
newest.Add(-(ineligibleCount+2)*time.Minute),
|
||||
)
|
||||
|
||||
seedNextUpEpisodes(t, ctx, pool, episodeIDs, episodeSeriesIDs, episodeNumbers)
|
||||
seedNextUpFiles(t, ctx, pool, folderID, []string{olderA2, olderB2})
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_watch_progress (
|
||||
user_id, profile_id, media_item_id, position_seconds,
|
||||
duration_seconds, completed, updated_at
|
||||
)
|
||||
SELECT $1, $2, episode_id, 0, 1800, TRUE, completed_at
|
||||
FROM unnest($3::text[], $4::timestamptz[]) AS fixture(episode_id, completed_at)
|
||||
`, userID, profileID, completedEpisodeIDs, completedTimes); err != nil {
|
||||
t.Fatalf("seed ineligible-flood progress: %v", err)
|
||||
}
|
||||
|
||||
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
|
||||
results, err := repo.ListNextUp(ctx, NextUpQuery{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
Limit: 20,
|
||||
EnableResumable: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNextUp: %v", err)
|
||||
}
|
||||
assertNextUpContentIDs(t, results, olderA2, olderB2)
|
||||
}
|
||||
|
||||
func TestNextUpRepository_GlobalAnchorWalkStopsWhenHistoryExhausted(t *testing.T) {
|
||||
pool := newNextUpTestPool(t)
|
||||
ctx := context.Background()
|
||||
prefix := fmt.Sprintf("nextup-exhausted-%d", time.Now().UnixNano())
|
||||
seriesIDs := []string{
|
||||
prefix + "-series-a",
|
||||
prefix + "-series-b",
|
||||
prefix + "-series-c",
|
||||
}
|
||||
episodeIDs := []string{
|
||||
seriesIDs[0] + "-episode-1",
|
||||
seriesIDs[1] + "-episode-1",
|
||||
seriesIDs[2] + "-episode-1",
|
||||
}
|
||||
|
||||
userID, profileID, _ := seedNextUpTestOwner(t, ctx, pool, prefix)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, seriesIDs)
|
||||
})
|
||||
for i, seriesID := range seriesIDs {
|
||||
seedNextUpSeries(t, ctx, pool, seriesID, fmt.Sprintf("%s Finished %d", prefix, i))
|
||||
}
|
||||
seedNextUpEpisodes(t, ctx, pool, episodeIDs, seriesIDs, []int{1, 1, 1})
|
||||
|
||||
completedAt := time.Now().UTC().Truncate(time.Microsecond)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_watch_progress (
|
||||
user_id, profile_id, media_item_id, position_seconds,
|
||||
duration_seconds, completed, updated_at
|
||||
)
|
||||
SELECT $1, $2, episode_id, 0, 1800, TRUE, $4::timestamptz - (ordinality * INTERVAL '1 minute')
|
||||
FROM unnest($3::text[]) WITH ORDINALITY AS fixture(episode_id, ordinality)
|
||||
`, userID, profileID, episodeIDs, completedAt); err != nil {
|
||||
t.Fatalf("seed exhausted progress: %v", err)
|
||||
}
|
||||
|
||||
repo := NewNextUpRepository(pool, nextUpTestStoreProvider{})
|
||||
results, err := repo.ListNextUp(ctx, NextUpQuery{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
Limit: 20,
|
||||
EnableResumable: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNextUp: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("ListNextUp returned %d rows after exhausting finished history: %+v", len(results), results)
|
||||
}
|
||||
}
|
||||
|
||||
func seedNextUpTestOwner(t *testing.T, ctx context.Context, pool *pgxpool.Pool, prefix string) (int, string, int) {
|
||||
t.Helper()
|
||||
|
||||
var folderID int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO media_folders (type, name, enabled)
|
||||
VALUES ('series', $1, TRUE)
|
||||
RETURNING id
|
||||
`, prefix+" Library").Scan(&folderID); err != nil {
|
||||
t.Fatalf("seed media folder: %v", err)
|
||||
}
|
||||
|
||||
var userID int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (username, role)
|
||||
VALUES ($1, 'user')
|
||||
RETURNING id
|
||||
`, prefix+"-user").Scan(&userID); err != nil {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
profileID := fmt.Sprintf("00000000-0000-4000-8000-%012d", time.Now().UnixNano()%1_000_000_000_000)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_profiles (id, user_id, name)
|
||||
VALUES ($1, $2, 'Next Up Regression')
|
||||
`, profileID, userID); err != nil {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
|
||||
t.Fatalf("seed profile: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE media_folder_id = $1`, folderID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
|
||||
})
|
||||
|
||||
return userID, profileID, folderID
|
||||
}
|
||||
|
||||
func seedNextUpSeries(t *testing.T, ctx context.Context, pool *pgxpool.Pool, seriesID, title string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_items (content_id, type, title, status, genres)
|
||||
VALUES ($1, 'series', $2, 'matched', '{}'::text[])
|
||||
`, seriesID, title); err != nil {
|
||||
t.Fatalf("seed series %s: %v", seriesID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedNextUpEpisodes(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
episodeIDs []string,
|
||||
seriesIDs []string,
|
||||
episodeNumbers []int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO episodes (content_id, series_id, season_number, episode_number, title)
|
||||
SELECT episode_id, series_id, 1, episode_number, 'Next Up Episode ' || episode_number::text
|
||||
FROM unnest($1::text[], $2::text[], $3::int[]) AS fixture(episode_id, series_id, episode_number)
|
||||
`, episodeIDs, seriesIDs, episodeNumbers); err != nil {
|
||||
t.Fatalf("seed episodes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedNextUpFiles(t *testing.T, ctx context.Context, pool *pgxpool.Pool, folderID int, episodeIDs []string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_files (episode_id, media_folder_id, file_path, duration)
|
||||
SELECT episode_id, $2, '/nextup-regression/' || episode_id || '.mkv', 1800
|
||||
FROM unnest($1::text[]) AS fixture(episode_id)
|
||||
`, episodeIDs, folderID); err != nil {
|
||||
t.Fatalf("seed media files: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNextUpContentIDs(t *testing.T, results []NextUpResult, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if len(results) != len(want) {
|
||||
t.Fatalf("ListNextUp returned %d rows, want %d: %+v", len(results), len(want), results)
|
||||
}
|
||||
got := make(map[string]bool, len(results))
|
||||
for _, result := range results {
|
||||
got[result.ContentID] = true
|
||||
}
|
||||
for _, contentID := range want {
|
||||
if !got[contentID] {
|
||||
t.Errorf("ListNextUp missing %s: %+v", contentID, results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListNextUpQuery_EnableResumableSkipsSeriesSuppressionCTE(t *testing.T) {
|
||||
@@ -88,7 +648,7 @@ func TestBuildListNextUpQuery_EnableResumableSkipsSeriesSuppressionCTE(t *testin
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
EnableResumable: true,
|
||||
}, 20)
|
||||
}, 20, nil)
|
||||
|
||||
if strings.Contains(query, "eligible_series AS (") {
|
||||
t.Fatalf("expected resumable query to skip eligible_series suppression CTE, got:\n%s", query)
|
||||
|
||||
@@ -100,15 +100,20 @@ func (qb *QueryBuilder) Build(input any) (string, []any, error) {
|
||||
if len(groupClauses) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
// The returned expression is always parenthesized so it stays atomic when
|
||||
// callers AND access, library, and other outer constraints onto it. A
|
||||
// match-any group (or a match-any top level) emits a top-level OR; without
|
||||
// the parentheses, SQL precedence lets the first OR arm bypass every
|
||||
// constraint appended afterward.
|
||||
if len(groupClauses) == 1 {
|
||||
return groupClauses[0], qb.args, nil
|
||||
return "(" + groupClauses[0] + ")", qb.args, nil
|
||||
}
|
||||
|
||||
wrapped := make([]string, len(groupClauses))
|
||||
for i, clause := range groupClauses {
|
||||
wrapped[i] = "(" + clause + ")"
|
||||
}
|
||||
return strings.Join(wrapped, topJoiner), qb.args, nil
|
||||
return "(" + strings.Join(wrapped, topJoiner) + ")", qb.args, nil
|
||||
}
|
||||
|
||||
func normalizeBuilderInput(input any) (QueryDefinition, error) {
|
||||
|
||||
@@ -1048,3 +1048,54 @@ func TestUserHistoryCTESQLIncludesCompletedEbookReaderProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParenthesizesResultForSafeANDComposition(t *testing.T) {
|
||||
// Build's callers (query_executor.go, handlers/catalog.go) AND library and
|
||||
// access-filter constraints onto the returned expression. A match-any
|
||||
// group emits a top-level OR, so the result must come back parenthesized —
|
||||
// otherwise SQL precedence lets the first OR arm bypass every constraint
|
||||
// appended afterward.
|
||||
tests := []struct {
|
||||
name string
|
||||
def QueryDefinition
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single match-any group",
|
||||
def: QueryDefinition{
|
||||
Match: "all",
|
||||
Groups: []QueryGroup{{
|
||||
Match: "any",
|
||||
Rules: []QueryRule{
|
||||
{Field: "genre", Op: "contains", Value: "Action"},
|
||||
{Field: "genre", Op: "contains", Value: "Adventure"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
want: "(mi.genres @> ARRAY[$1]::text[] OR mi.genres @> ARRAY[$2]::text[])",
|
||||
},
|
||||
{
|
||||
name: "match-any top level",
|
||||
def: QueryDefinition{
|
||||
Match: "any",
|
||||
Groups: []QueryGroup{
|
||||
{Match: "all", Rules: []QueryRule{{Field: "genre", Op: "contains", Value: "Action"}}},
|
||||
{Match: "all", Rules: []QueryRule{{Field: "genre", Op: "contains", Value: "Adventure"}}},
|
||||
},
|
||||
},
|
||||
want: "((mi.genres @> ARRAY[$1]::text[]) OR (mi.genres @> ARRAY[$2]::text[]))",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clause, _, err := NewQueryBuilder("mi").Build(tt.def)
|
||||
if err != nil {
|
||||
t.Fatalf("Build returned error: %v", err)
|
||||
}
|
||||
if clause != tt.want {
|
||||
t.Fatalf("Build clause = %q, want %q", clause, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +281,9 @@ func (e *QueryExecutor) buildPreviewPagePlan(
|
||||
argIdx := builder.ArgIdx() + filterArgOffset
|
||||
|
||||
if filterWhere != "" {
|
||||
// QueryBuilder.Build returns a parenthesized expression, so AND-ing
|
||||
// access, library, and other outer constraints onto it cannot let a
|
||||
// match-any OR arm escape them.
|
||||
conditions = append(conditions, filterWhere)
|
||||
}
|
||||
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueryExecutorGroupQueryPreservesAccessFilters(t *testing.T) {
|
||||
executor := &QueryExecutor{}
|
||||
def := QueryDefinition{
|
||||
Match: "all",
|
||||
LibraryIDs: []int{42},
|
||||
Groups: []QueryGroup{{
|
||||
Match: "any",
|
||||
Rules: []QueryRule{
|
||||
{Field: "genre", Op: "contains", Value: "Action"},
|
||||
{Field: "genre", Op: "contains", Value: "Adventure"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
sql, args, err := executor.buildPreviewPageSQL(def, AccessFilter{MaxContentRating: "PG"}, 20, 0, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build preview SQL: %v", err)
|
||||
}
|
||||
groupedFilter := "WHERE (mi.genres @> ARRAY[$1]::text[] OR mi.genres @> ARRAY[$2]::text[]) AND EXISTS"
|
||||
if !strings.Contains(sql, groupedFilter) {
|
||||
t.Fatalf("group predicate is not isolated from access filters:\n%s", sql)
|
||||
}
|
||||
if len(args) != 6 || args[0] != "Action" || args[1] != "Adventure" ||
|
||||
!reflect.DeepEqual(args[2], []int{42}) || args[4] != 42 || args[5] != 21 {
|
||||
t.Fatalf("unexpected query args: %#v", args)
|
||||
}
|
||||
allowedRatings, ok := args[3].([]string)
|
||||
if !ok || !slices.Contains(allowedRatings, "PG") || slices.Contains(allowedRatings, "R") {
|
||||
t.Fatalf("rating arg = %#v, want PG allowed and R blocked", args[3])
|
||||
}
|
||||
}
|
||||
@@ -5,49 +5,193 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
type FrameExtractOptions struct {
|
||||
InputPath string
|
||||
SeekSeconds float64
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
HWDevice string
|
||||
ToneMap bool
|
||||
RunFunc func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error)
|
||||
InputPath string
|
||||
SeekSeconds float64
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
HWDevice string
|
||||
ToneMap bool
|
||||
AllowSoftwareToneMap bool
|
||||
RunFunc func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error)
|
||||
|
||||
softwareToneMapResolver *softwareToneMapFilterResolver
|
||||
}
|
||||
|
||||
const (
|
||||
hwAccelNone = "none"
|
||||
hwAccelQSV = "qsv"
|
||||
hwAccelVAAPI = "vaapi"
|
||||
reasonChapterExtractFailed = "chapter_extract_failed"
|
||||
reasonDecodeInvalidData = "decode_invalid_data"
|
||||
reasonFFmpegProbeFailed = "ffmpeg_probe_failed"
|
||||
reasonToneMapUnsupported = "tonemap_unsupported"
|
||||
softwareToneMapProbeTimeout = 3 * time.Second
|
||||
softwareToneMapFilterBT2390 = "zscale=t=linear:npl=100,format=gbrpf32le,tonemapx=tonemap=bt2390,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p"
|
||||
softwareToneMapFilterHable = "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p"
|
||||
)
|
||||
|
||||
type softwareToneMapProbeResult struct {
|
||||
filter string
|
||||
failureReason string
|
||||
detail string
|
||||
cacheable bool
|
||||
}
|
||||
|
||||
type softwareToneMapProbeCall struct {
|
||||
done chan struct{}
|
||||
result softwareToneMapProbeResult
|
||||
waiters int
|
||||
}
|
||||
|
||||
type softwareToneMapFilterResolver struct {
|
||||
mu sync.Mutex
|
||||
byPath map[string]softwareToneMapProbeResult
|
||||
inFlight map[string]*softwareToneMapProbeCall
|
||||
probeFn func(ffmpegPath string) ([]byte, error)
|
||||
}
|
||||
|
||||
var defaultSoftwareToneMapFilterResolver = newSoftwareToneMapFilterResolver(runFFmpegFilterProbe)
|
||||
|
||||
func newSoftwareToneMapFilterResolver(probeFn func(ffmpegPath string) ([]byte, error)) *softwareToneMapFilterResolver {
|
||||
return &softwareToneMapFilterResolver{
|
||||
byPath: make(map[string]softwareToneMapProbeResult),
|
||||
inFlight: make(map[string]*softwareToneMapProbeCall),
|
||||
probeFn: probeFn,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *softwareToneMapFilterResolver) resolve(ffmpegPath string) (string, string, error) {
|
||||
cacheKey := softwareToneMapCacheKey(ffmpegPath)
|
||||
r.mu.Lock()
|
||||
if result, ok := r.byPath[cacheKey]; ok {
|
||||
r.mu.Unlock()
|
||||
return softwareToneMapProbeResultValue(result)
|
||||
}
|
||||
if call, ok := r.inFlight[cacheKey]; ok {
|
||||
call.waiters++
|
||||
r.mu.Unlock()
|
||||
<-call.done
|
||||
return softwareToneMapProbeResultValue(call.result)
|
||||
}
|
||||
call := &softwareToneMapProbeCall{done: make(chan struct{})}
|
||||
r.inFlight[cacheKey] = call
|
||||
r.mu.Unlock()
|
||||
|
||||
result := probeSoftwareToneMapFilter(ffmpegPath, r.probeFn)
|
||||
r.mu.Lock()
|
||||
if result.cacheable {
|
||||
r.byPath[cacheKey] = result
|
||||
}
|
||||
call.result = result
|
||||
delete(r.inFlight, cacheKey)
|
||||
close(call.done)
|
||||
r.mu.Unlock()
|
||||
return softwareToneMapProbeResultValue(result)
|
||||
}
|
||||
|
||||
func softwareToneMapCacheKey(ffmpegPath string) string {
|
||||
if strings.ContainsRune(ffmpegPath, os.PathSeparator) {
|
||||
if absolutePath, err := filepath.Abs(ffmpegPath); err == nil {
|
||||
return filepath.Clean(absolutePath)
|
||||
}
|
||||
return filepath.Clean(ffmpegPath)
|
||||
}
|
||||
return ffmpegPath
|
||||
}
|
||||
|
||||
func softwareToneMapProbeResultValue(result softwareToneMapProbeResult) (string, string, error) {
|
||||
if result.filter != "" {
|
||||
return result.filter, "", nil
|
||||
}
|
||||
if result.failureReason == "" {
|
||||
result.failureReason = reasonChapterExtractFailed
|
||||
}
|
||||
if result.detail == "" {
|
||||
result.detail = "configured FFmpeg software HDR tone-map capability could not be determined"
|
||||
}
|
||||
return "", result.failureReason, errors.New(result.detail)
|
||||
}
|
||||
|
||||
func probeSoftwareToneMapFilter(
|
||||
ffmpegPath string,
|
||||
probeFn func(ffmpegPath string) ([]byte, error),
|
||||
) softwareToneMapProbeResult {
|
||||
if probeFn == nil {
|
||||
return softwareToneMapProbeResult{
|
||||
failureReason: reasonFFmpegProbeFailed,
|
||||
detail: "FFmpeg filter probe is unavailable",
|
||||
}
|
||||
}
|
||||
output, err := probeFn(ffmpegPath)
|
||||
if err != nil {
|
||||
return softwareToneMapProbeResult{
|
||||
failureReason: reasonFFmpegProbeFailed,
|
||||
detail: "FFmpeg filter probe failed: " + playback.FormatFFmpegProbeFailure(err, output),
|
||||
}
|
||||
}
|
||||
if !ffmpegFilterOutputHasToken(output, "zscale") {
|
||||
return softwareToneMapProbeResult{
|
||||
failureReason: reasonToneMapUnsupported,
|
||||
detail: "configured FFmpeg lacks the required zscale filter",
|
||||
cacheable: true,
|
||||
}
|
||||
}
|
||||
if ffmpegFilterOutputHasToken(output, "tonemapx") {
|
||||
return softwareToneMapProbeResult{filter: softwareToneMapFilterBT2390, cacheable: true}
|
||||
}
|
||||
if ffmpegFilterOutputHasToken(output, "tonemap") {
|
||||
return softwareToneMapProbeResult{filter: softwareToneMapFilterHable, cacheable: true}
|
||||
}
|
||||
return softwareToneMapProbeResult{
|
||||
failureReason: reasonToneMapUnsupported,
|
||||
detail: "configured FFmpeg lacks the required tonemapx or tonemap filter",
|
||||
cacheable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string, error) {
|
||||
ffmpegPath := opts.FFmpegPath
|
||||
if ffmpegPath == "" {
|
||||
ffmpegPath = "ffmpeg"
|
||||
}
|
||||
ffmpegPath := playback.ResolveFFmpegPath(opts.FFmpegPath)
|
||||
runExtract := opts.RunFunc
|
||||
if runExtract == nil {
|
||||
runExtract = runFFmpegFrameExtract
|
||||
}
|
||||
|
||||
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
|
||||
// Resolve a multi-device hw_device list to one concrete GPU for this
|
||||
// extraction; the reservation spans only the hardware attempt below.
|
||||
resolvedDevice, releaseHWDevice := playback.AcquireHWDevice(opts.HWDevice, resolvedAccel)
|
||||
defer releaseHWDevice()
|
||||
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
|
||||
resolvedDevice = playback.PickRenderDevice("")
|
||||
softwareToneMapResolver := opts.softwareToneMapResolver
|
||||
if softwareToneMapResolver == nil {
|
||||
softwareToneMapResolver = defaultSoftwareToneMapFilterResolver
|
||||
}
|
||||
cpuOpts := cpuFrameExtractOptions{
|
||||
ctx: ctx,
|
||||
inputPath: opts.InputPath,
|
||||
seekSeconds: opts.SeekSeconds,
|
||||
toneMap: opts.ToneMap,
|
||||
runExtract: runExtract,
|
||||
ffmpegPath: ffmpegPath,
|
||||
softwareToneMapResolver: softwareToneMapResolver,
|
||||
}
|
||||
|
||||
if resolvedAccel != "none" {
|
||||
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
|
||||
if supportsHardwareFrameExtract(resolvedAccel) {
|
||||
// Resolve a multi-device hw_device list to one concrete GPU for this
|
||||
// extraction; the reservation spans only the hardware attempt below.
|
||||
resolvedDevice, releaseHWDevice := playback.AcquireHWDevice(opts.HWDevice, resolvedAccel)
|
||||
defer releaseHWDevice()
|
||||
if resolvedDevice == "" {
|
||||
resolvedDevice = playback.PickRenderDevice("")
|
||||
}
|
||||
|
||||
args, buildErr := buildFrameExtractArgs(opts.InputPath, opts.SeekSeconds, resolvedAccel, resolvedDevice, opts.ToneMap)
|
||||
if buildErr != nil {
|
||||
if opts.ToneMap {
|
||||
return nil, "tonemap_unsupported", wrapReason("tonemap_unsupported", buildErr)
|
||||
}
|
||||
} else {
|
||||
if buildErr == nil {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, extractTimeoutForAttempt(true, opts.ToneMap))
|
||||
data, err := runExtract(attemptCtx, ffmpegPath, args)
|
||||
cancel()
|
||||
@@ -57,44 +201,107 @@ func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string
|
||||
}
|
||||
|
||||
hwReason := classifyExtractError("hw", err)
|
||||
if opts.ToneMap {
|
||||
if hwReason == reasonDecodeInvalidData {
|
||||
return nil, hwReason, wrapReason(hwReason, err)
|
||||
}
|
||||
|
||||
cpuData, cpuReason, cpuErr := extractFrameCPU(
|
||||
ctx,
|
||||
opts.InputPath,
|
||||
opts.SeekSeconds,
|
||||
false,
|
||||
runExtract,
|
||||
ffmpegPath,
|
||||
)
|
||||
if cpuErr == nil {
|
||||
return cpuData, "", nil
|
||||
if opts.ToneMap && !opts.AllowSoftwareToneMap {
|
||||
return nil, hwReason, wrapReason(hwReason, err)
|
||||
}
|
||||
return nil, cpuReason, fmt.Errorf("hardware extraction failed: %v; cpu fallback failed: %w", wrapReason(hwReason, err), cpuErr)
|
||||
return extractFrameCPUFallback(
|
||||
cpuOpts,
|
||||
hwReason,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
releaseHWDevice()
|
||||
if opts.ToneMap && !opts.AllowSoftwareToneMap {
|
||||
return nil, reasonToneMapUnsupported, wrapReason(reasonToneMapUnsupported, buildErr)
|
||||
}
|
||||
return extractFrameCPUFallback(
|
||||
cpuOpts,
|
||||
reasonChapterExtractFailed,
|
||||
buildErr,
|
||||
)
|
||||
}
|
||||
|
||||
if opts.ToneMap {
|
||||
err := errors.New("hardware HDR tone mapping unavailable")
|
||||
return nil, "tonemap_unsupported", wrapReason("tonemap_unsupported", err)
|
||||
if opts.ToneMap && !opts.AllowSoftwareToneMap {
|
||||
err := errors.New("software HDR tone mapping is disabled")
|
||||
return nil, reasonToneMapUnsupported, wrapReason(reasonToneMapUnsupported, err)
|
||||
}
|
||||
|
||||
return extractFrameCPU(ctx, opts.InputPath, opts.SeekSeconds, opts.ToneMap, runExtract, ffmpegPath)
|
||||
if resolvedAccel != hwAccelNone && !opts.ToneMap {
|
||||
return extractFrameUnsupportedSDRWithRetry(cpuOpts)
|
||||
}
|
||||
|
||||
return extractFrameCPU(cpuOpts)
|
||||
}
|
||||
|
||||
func supportsHardwareFrameExtract(hwAccel string) bool {
|
||||
return hwAccel == hwAccelQSV || hwAccel == hwAccelVAAPI
|
||||
}
|
||||
|
||||
type cpuFrameExtractOptions struct {
|
||||
ctx context.Context
|
||||
inputPath string
|
||||
seekSeconds float64
|
||||
toneMap bool
|
||||
runExtract func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error)
|
||||
ffmpegPath string
|
||||
softwareToneMapResolver *softwareToneMapFilterResolver
|
||||
}
|
||||
|
||||
func extractFrameUnsupportedSDRWithRetry(opts cpuFrameExtractOptions) ([]byte, string, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(opts.ctx, extractTimeoutForAttempt(true, false))
|
||||
data, err := opts.runExtract(
|
||||
attemptCtx,
|
||||
opts.ffmpegPath,
|
||||
buildCPUFrameExtractArgs(opts.inputPath, opts.seekSeconds, ""),
|
||||
)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return data, "", nil
|
||||
}
|
||||
|
||||
hwReason := classifyExtractError("hw", err)
|
||||
return extractFrameCPUFallback(opts, hwReason, err)
|
||||
}
|
||||
|
||||
func extractFrameCPUFallback(
|
||||
opts cpuFrameExtractOptions,
|
||||
hwReason string,
|
||||
hwErr error,
|
||||
) ([]byte, string, error) {
|
||||
cpuData, cpuReason, cpuErr := extractFrameCPU(opts)
|
||||
if cpuErr == nil {
|
||||
return cpuData, "", nil
|
||||
}
|
||||
return nil, cpuReason, fmt.Errorf(
|
||||
"hardware extraction failed: %w; cpu fallback failed: %w",
|
||||
wrapReason(hwReason, hwErr),
|
||||
cpuErr,
|
||||
)
|
||||
}
|
||||
|
||||
func extractFrameCPU(
|
||||
ctx context.Context,
|
||||
inputPath string,
|
||||
seekSeconds float64,
|
||||
toneMap bool,
|
||||
runExtract func(ctx context.Context, ffmpegPath string, args []string) ([]byte, error),
|
||||
ffmpegPath string,
|
||||
opts cpuFrameExtractOptions,
|
||||
) ([]byte, string, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, extractTimeoutForAttempt(false, toneMap))
|
||||
softwareToneMapFilter := ""
|
||||
if opts.toneMap {
|
||||
filter, reason, err := opts.softwareToneMapResolver.resolve(opts.ffmpegPath)
|
||||
if err != nil {
|
||||
return nil, reason, wrapReason(reason, err)
|
||||
}
|
||||
softwareToneMapFilter = filter
|
||||
}
|
||||
|
||||
attemptCtx, cancel := context.WithTimeout(opts.ctx, extractTimeoutForAttempt(false, opts.toneMap))
|
||||
defer cancel()
|
||||
data, err := runExtract(attemptCtx, ffmpegPath, buildCPUFrameExtractArgs(inputPath, seekSeconds, toneMap))
|
||||
data, err := opts.runExtract(
|
||||
attemptCtx,
|
||||
opts.ffmpegPath,
|
||||
buildCPUFrameExtractArgs(opts.inputPath, opts.seekSeconds, softwareToneMapFilter),
|
||||
)
|
||||
if err != nil {
|
||||
reason := classifyExtractError("cpu", err)
|
||||
return nil, reason, wrapReason(reason, err)
|
||||
@@ -110,12 +317,12 @@ func classifyExtractError(stage string, err error) string {
|
||||
lower := strings.ToLower(message)
|
||||
switch {
|
||||
case strings.Contains(message, "No such filter") || strings.Contains(message, "tonemap") && strings.Contains(message, "Error"):
|
||||
return "tonemap_unsupported"
|
||||
return reasonToneMapUnsupported
|
||||
case strings.Contains(lower, "invalid nal unit size"),
|
||||
strings.Contains(lower, "error splitting the input into nal units"),
|
||||
strings.Contains(lower, "invalid data found when processing input"),
|
||||
strings.Contains(lower, "invalid as first byte of an ebml number"):
|
||||
return "decode_invalid_data"
|
||||
return reasonDecodeInvalidData
|
||||
case stage == "hw" && strings.Contains(message, "signal: killed"):
|
||||
return "hw_killed"
|
||||
case stage == "hw" && isDeadlineError(err):
|
||||
@@ -123,7 +330,7 @@ func classifyExtractError(stage string, err error) string {
|
||||
case stage == "cpu" && isDeadlineError(err):
|
||||
return "cpu_timeout"
|
||||
default:
|
||||
return "chapter_extract_failed"
|
||||
return reasonChapterExtractFailed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,15 +354,15 @@ func extractTimeoutForAttempt(hardware bool, hdr bool) time.Duration {
|
||||
return cpuExtractTimeoutSDR
|
||||
}
|
||||
|
||||
func buildCPUFrameExtractArgs(inputPath string, seekSeconds float64, toneMap bool) []string {
|
||||
func buildCPUFrameExtractArgs(inputPath string, seekSeconds float64, softwareToneMapFilter string) []string {
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-ss", fmt.Sprintf("%.3f", seekSeconds),
|
||||
"-i", inputPath,
|
||||
}
|
||||
if toneMap {
|
||||
args = append(args, "-vf", "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=bt2390,zscale=p=bt709:t=bt709:m=bt709:r=tv,format=yuv420p")
|
||||
if softwareToneMapFilter != "" {
|
||||
args = append(args, "-vf", softwareToneMapFilter)
|
||||
}
|
||||
args = append(args,
|
||||
"-frames:v", "1",
|
||||
@@ -172,7 +379,7 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
|
||||
"-loglevel", "error",
|
||||
}
|
||||
switch hwAccel {
|
||||
case "qsv":
|
||||
case hwAccelQSV:
|
||||
if hwDevice == "" {
|
||||
return nil, fmt.Errorf("qsv requires a render device")
|
||||
}
|
||||
@@ -183,7 +390,7 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
|
||||
"-hwaccel", "vaapi",
|
||||
"-hwaccel_output_format", "vaapi",
|
||||
)
|
||||
case "vaapi":
|
||||
case hwAccelVAAPI:
|
||||
if hwDevice == "" {
|
||||
return nil, fmt.Errorf("vaapi requires a render device")
|
||||
}
|
||||
@@ -194,7 +401,7 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
|
||||
"-hwaccel_output_format", "vaapi",
|
||||
)
|
||||
default:
|
||||
return buildCPUFrameExtractArgs(inputPath, seekSeconds, toneMap), nil
|
||||
return nil, fmt.Errorf("hardware chapter thumbnail extraction does not support %q", hwAccel)
|
||||
}
|
||||
|
||||
filter := "hwdownload,format=nv12"
|
||||
@@ -213,6 +420,25 @@ func buildFrameExtractArgs(inputPath string, seekSeconds float64, hwAccel string
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func runFFmpegFilterProbe(ffmpegPath string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), softwareToneMapProbeTimeout)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-filters").CombinedOutput()
|
||||
}
|
||||
|
||||
func ffmpegFilterOutputHasToken(output []byte, token string) bool {
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 || !strings.Contains(fields[2], "->") {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(fields[1], token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runFFmpegFrameExtract(ctx context.Context, ffmpegPath string, args []string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
package chapterthumbs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSoftwareToneMapFilterResolver(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
probeErr error
|
||||
wantFilter string
|
||||
wantReason string
|
||||
wantError string
|
||||
wantCalls int
|
||||
}{
|
||||
{
|
||||
name: "prefers tonemapx bt2390",
|
||||
output: " .S. zscale V->V\n .S. tonemap V->V\n .S. tonemapx V->V\n",
|
||||
wantFilter: softwareToneMapFilterBT2390,
|
||||
wantCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "falls back to standard hable",
|
||||
output: " .S. zscale V->V\n .S. tonemap V->V\n",
|
||||
wantFilter: softwareToneMapFilterHable,
|
||||
wantCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "requires zscale",
|
||||
output: " .S. tonemapx V->V\n",
|
||||
wantReason: reasonToneMapUnsupported,
|
||||
wantError: "lacks the required zscale filter",
|
||||
wantCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "requires a tone map filter",
|
||||
output: " .S. zscale V->V Description mentions tonemap but does not provide it.\n",
|
||||
wantReason: reasonToneMapUnsupported,
|
||||
wantError: "lacks the required tonemapx or tonemap filter",
|
||||
wantCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "does not cache transient probe failure",
|
||||
output: "probe stderr",
|
||||
probeErr: errors.New("exit status 1"),
|
||||
wantReason: reasonFFmpegProbeFailed,
|
||||
wantError: "FFmpeg filter probe failed: exit status 1: probe stderr",
|
||||
wantCalls: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
calls := 0
|
||||
resolver := newSoftwareToneMapFilterResolver(func(ffmpegPath string) ([]byte, error) {
|
||||
calls++
|
||||
if cacheKey := softwareToneMapCacheKey(ffmpegPath); cacheKey != "/test/ffmpeg" {
|
||||
t.Fatalf("ffmpegPath = %q, cache key = %q, want /test/ffmpeg", ffmpegPath, cacheKey)
|
||||
}
|
||||
return []byte(tt.output), tt.probeErr
|
||||
})
|
||||
|
||||
for _, ffmpegPath := range []string{"/test/ffmpeg", "/test/../test/ffmpeg"} {
|
||||
filter, reason, err := resolver.resolve(ffmpegPath)
|
||||
if tt.wantError != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("resolve() error = %v, want containing %q", err, tt.wantError)
|
||||
}
|
||||
if reason != tt.wantReason {
|
||||
t.Fatalf("resolve() reason = %q, want %q", reason, tt.wantReason)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolve() error = %v", err)
|
||||
}
|
||||
if filter != tt.wantFilter {
|
||||
t.Fatalf("resolve() filter = %q, want %q", filter, tt.wantFilter)
|
||||
}
|
||||
if reason != "" {
|
||||
t.Fatalf("resolve() reason = %q, want empty", reason)
|
||||
}
|
||||
}
|
||||
if calls != tt.wantCalls {
|
||||
t.Fatalf("probe calls = %d, want %d", calls, tt.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftwareToneMapFilterResolverKeepsExecutableSeparateFromCacheKey(t *testing.T) {
|
||||
workingDirectory, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve working directory: %v", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
resolver := newSoftwareToneMapFilterResolver(func(ffmpegPath string) ([]byte, error) {
|
||||
calls++
|
||||
if ffmpegPath != "./ffmpeg" {
|
||||
t.Fatalf("probe path = %q, want ./ffmpeg", ffmpegPath)
|
||||
}
|
||||
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
|
||||
})
|
||||
|
||||
for _, ffmpegPath := range []string{"./ffmpeg", filepath.Join(workingDirectory, "ffmpeg")} {
|
||||
filter, reason, err := resolver.resolve(ffmpegPath)
|
||||
if err != nil || reason != "" || filter != softwareToneMapFilterHable {
|
||||
t.Fatalf("resolve(%q) = %q, %q, %v", ffmpegPath, filter, reason, err)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("probe calls = %d, want 1 cached call", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftwareToneMapFilterResolverCoalescesTransientProbeFailures(t *testing.T) {
|
||||
const callers = 8
|
||||
|
||||
probeStarted := make(chan struct{})
|
||||
releaseProbe := make(chan struct{})
|
||||
var probeCalls atomic.Int32
|
||||
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
|
||||
if probeCalls.Add(1) == 1 {
|
||||
close(probeStarted)
|
||||
<-releaseProbe
|
||||
return []byte("temporary stderr"), errors.New("resource temporarily unavailable")
|
||||
}
|
||||
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
|
||||
})
|
||||
|
||||
type resolveResult struct {
|
||||
filter string
|
||||
reason string
|
||||
err error
|
||||
}
|
||||
results := make(chan resolveResult, callers)
|
||||
start := make(chan struct{})
|
||||
var callersReady sync.WaitGroup
|
||||
callersReady.Add(callers)
|
||||
for range callers {
|
||||
go func() {
|
||||
callersReady.Done()
|
||||
<-start
|
||||
filter, reason, err := resolver.resolve("/test/ffmpeg")
|
||||
results <- resolveResult{filter: filter, reason: reason, err: err}
|
||||
}()
|
||||
}
|
||||
callersReady.Wait()
|
||||
close(start)
|
||||
<-probeStarted
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
resolver.mu.Lock()
|
||||
call := resolver.inFlight["/test/ffmpeg"]
|
||||
waiters := 0
|
||||
if call != nil {
|
||||
waiters = call.waiters
|
||||
}
|
||||
resolver.mu.Unlock()
|
||||
if waiters == callers-1 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("coalesced waiters = %d, want %d", waiters, callers-1)
|
||||
}
|
||||
runtime.Gosched()
|
||||
}
|
||||
close(releaseProbe)
|
||||
|
||||
for range callers {
|
||||
result := <-results
|
||||
if result.filter != "" || result.reason != reasonFFmpegProbeFailed || result.err == nil {
|
||||
t.Fatalf("resolve() = %q, %q, %v, want shared transient failure", result.filter, result.reason, result.err)
|
||||
}
|
||||
}
|
||||
if calls := probeCalls.Load(); calls != 1 {
|
||||
t.Fatalf("concurrent probe calls = %d, want 1", calls)
|
||||
}
|
||||
|
||||
filter, reason, err := resolver.resolve("/test/ffmpeg")
|
||||
if err != nil || reason != "" || filter != softwareToneMapFilterHable {
|
||||
t.Fatalf("retry resolve() = %q, %q, %v", filter, reason, err)
|
||||
}
|
||||
if calls := probeCalls.Load(); calls != 2 {
|
||||
t.Fatalf("probe calls after retry = %d, want 2", calls)
|
||||
}
|
||||
|
||||
filter, reason, err = resolver.resolve("/test/ffmpeg")
|
||||
if err != nil || reason != "" || filter != softwareToneMapFilterHable {
|
||||
t.Fatalf("cached resolve() = %q, %q, %v", filter, reason, err)
|
||||
}
|
||||
if calls := probeCalls.Load(); calls != 2 {
|
||||
t.Fatalf("probe calls after cached resolve = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameSoftwareHDRWithoutHardware(t *testing.T) {
|
||||
resolver := resolverWithFilters(t, "zscale", "tonemapx")
|
||||
var remaining time.Duration
|
||||
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
FFmpegPath: "/test/ffmpeg",
|
||||
HWAccel: hwAccelNone,
|
||||
ToneMap: true,
|
||||
AllowSoftwareToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(ctx context.Context, _ string, args []string) ([]byte, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("software HDR extraction has no deadline")
|
||||
}
|
||||
remaining = time.Until(deadline)
|
||||
if !slices.Contains(args, softwareToneMapFilterBT2390) {
|
||||
t.Fatalf("software extraction args missing BT.2390 filter: %#v", args)
|
||||
}
|
||||
return []byte("frame"), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractFrame() error = %v", err)
|
||||
}
|
||||
if reason != "" {
|
||||
t.Fatalf("ExtractFrame() reason = %q, want empty", reason)
|
||||
}
|
||||
if string(data) != "frame" {
|
||||
t.Fatalf("ExtractFrame() data = %q, want frame", data)
|
||||
}
|
||||
assertApproximateDeadline(t, remaining, cpuExtractTimeoutHDR)
|
||||
}
|
||||
|
||||
func TestExtractFrameSoftwareHDRDisabledByDefault(t *testing.T) {
|
||||
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
|
||||
t.Fatal("software filter probe should not run while CPU tone mapping is disabled")
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
FFmpegPath: "/test/ffmpeg",
|
||||
HWAccel: hwAccelNone,
|
||||
ToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(context.Context, string, []string) ([]byte, error) {
|
||||
t.Fatal("CPU extraction should not run while CPU tone mapping is disabled")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "software HDR tone mapping is disabled") {
|
||||
t.Fatalf("ExtractFrame() error = %v, want disabled software tone-map error", err)
|
||||
}
|
||||
if reason != reasonToneMapUnsupported {
|
||||
t.Fatalf("ExtractFrame() reason = %q, want %q", reason, reasonToneMapUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameUnsupportedHardwareUsesCPU(t *testing.T) {
|
||||
var args []string
|
||||
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
HWAccel: "nvenc",
|
||||
RunFunc: func(_ context.Context, _ string, got []string) ([]byte, error) {
|
||||
args = append([]string(nil), got...)
|
||||
return []byte("frame"), nil
|
||||
},
|
||||
})
|
||||
if err != nil || reason != "" || string(data) != "frame" {
|
||||
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
if strings.Contains(joined, "-hwaccel") || strings.Contains(joined, "nvenc") {
|
||||
t.Fatalf("unsupported chapter-thumbnail accelerator used hardware args: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameUnsupportedHardwarePreservesSDRRetry(t *testing.T) {
|
||||
calls := 0
|
||||
var firstRemaining, retryRemaining time.Duration
|
||||
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
HWAccel: "nvenc",
|
||||
RunFunc: func(ctx context.Context, _ string, args []string) ([]byte, error) {
|
||||
calls++
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatalf("extraction attempt %d has no deadline", calls)
|
||||
}
|
||||
if strings.Contains(strings.Join(args, " "), "-hwaccel") {
|
||||
t.Fatalf("unsupported accelerator used hardware args: %#v", args)
|
||||
}
|
||||
if calls == 1 {
|
||||
firstRemaining = time.Until(deadline)
|
||||
return nil, errors.New("transient software decode failure")
|
||||
}
|
||||
retryRemaining = time.Until(deadline)
|
||||
return []byte("frame"), nil
|
||||
},
|
||||
})
|
||||
if err != nil || reason != "" || string(data) != "frame" {
|
||||
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("extract calls = %d, want 2", calls)
|
||||
}
|
||||
assertApproximateDeadline(t, firstRemaining, hwExtractTimeoutSDR)
|
||||
assertApproximateDeadline(t, retryRemaining, cpuExtractTimeoutSDR)
|
||||
}
|
||||
|
||||
func TestExtractFrameProbesAndRunsSameRelativeFFmpeg(t *testing.T) {
|
||||
var probedPath, extractPath string
|
||||
resolver := newSoftwareToneMapFilterResolver(func(ffmpegPath string) ([]byte, error) {
|
||||
probedPath = ffmpegPath
|
||||
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
|
||||
})
|
||||
|
||||
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
FFmpegPath: " ./ffmpeg ",
|
||||
HWAccel: hwAccelNone,
|
||||
ToneMap: true,
|
||||
AllowSoftwareToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(_ context.Context, ffmpegPath string, _ []string) ([]byte, error) {
|
||||
extractPath = ffmpegPath
|
||||
return []byte("frame"), nil
|
||||
},
|
||||
})
|
||||
if err != nil || reason != "" {
|
||||
t.Fatalf("ExtractFrame() reason = %q, error = %v", reason, err)
|
||||
}
|
||||
if probedPath != "./ffmpeg" || extractPath != "./ffmpeg" {
|
||||
t.Fatalf("probe path = %q, extract path = %q, want ./ffmpeg for both", probedPath, extractPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameRetriesTransientSoftwareProbeFailure(t *testing.T) {
|
||||
probeCalls := 0
|
||||
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
|
||||
probeCalls++
|
||||
if probeCalls == 1 {
|
||||
return []byte("temporary stderr"), errors.New("resource temporarily unavailable")
|
||||
}
|
||||
return []byte(" .S. zscale V->V\n .S. tonemap V->V\n"), nil
|
||||
})
|
||||
opts := FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
FFmpegPath: "/test/ffmpeg",
|
||||
HWAccel: hwAccelNone,
|
||||
ToneMap: true,
|
||||
AllowSoftwareToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(context.Context, string, []string) ([]byte, error) {
|
||||
return []byte("frame"), nil
|
||||
},
|
||||
}
|
||||
|
||||
if _, reason, err := ExtractFrame(context.Background(), opts); err == nil || reason != reasonFFmpegProbeFailed {
|
||||
t.Fatalf("first ExtractFrame() reason = %q, error = %v, want retryable probe failure", reason, err)
|
||||
}
|
||||
data, reason, err := ExtractFrame(context.Background(), opts)
|
||||
if err != nil || reason != "" || string(data) != "frame" {
|
||||
t.Fatalf("second ExtractFrame() = %q, %q, %v", data, reason, err)
|
||||
}
|
||||
if probeCalls != 2 {
|
||||
t.Fatalf("probe calls = %d, want 2", probeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameHardwareHDRSuccessSkipsSoftwareProbe(t *testing.T) {
|
||||
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
|
||||
t.Fatal("software filter probe should not run after hardware success")
|
||||
return nil, nil
|
||||
})
|
||||
calls := 0
|
||||
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
HWAccel: "vaapi",
|
||||
HWDevice: "/dev/dri/renderD128",
|
||||
ToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(_ context.Context, _ string, args []string) ([]byte, error) {
|
||||
calls++
|
||||
if !strings.Contains(strings.Join(args, " "), "tonemap_vaapi") {
|
||||
t.Fatalf("hardware extraction args missing VAAPI tone map: %#v", args)
|
||||
}
|
||||
return []byte("frame"), nil
|
||||
},
|
||||
})
|
||||
if err != nil || reason != "" || string(data) != "frame" {
|
||||
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("extract calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameHardwareHDRFailureDoesNotUseSoftwareWhenDisabled(t *testing.T) {
|
||||
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
|
||||
t.Fatal("software filter probe should not run while CPU tone mapping is disabled")
|
||||
return nil, nil
|
||||
})
|
||||
calls := 0
|
||||
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
HWAccel: hwAccelVAAPI,
|
||||
HWDevice: "/dev/dri/renderD128",
|
||||
ToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(context.Context, string, []string) ([]byte, error) {
|
||||
calls++
|
||||
return nil, context.DeadlineExceeded
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ExtractFrame() error = nil, want hardware failure")
|
||||
}
|
||||
if reason != "hw_timeout" {
|
||||
t.Fatalf("ExtractFrame() reason = %q, want hw_timeout", reason)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("extract calls = %d, want hardware attempt only", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameHardwareHDRFailureFallsBackWithFreshDeadline(t *testing.T) {
|
||||
resolver := resolverWithFilters(t, "zscale", "tonemapx")
|
||||
calls := 0
|
||||
var hwRemaining, cpuRemaining time.Duration
|
||||
data, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
HWAccel: "vaapi",
|
||||
HWDevice: "/dev/dri/renderD128",
|
||||
ToneMap: true,
|
||||
AllowSoftwareToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(ctx context.Context, _ string, args []string) ([]byte, error) {
|
||||
calls++
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatalf("extraction attempt %d has no deadline", calls)
|
||||
}
|
||||
if calls == 1 {
|
||||
hwRemaining = time.Until(deadline)
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
cpuRemaining = time.Until(deadline)
|
||||
if !slices.Contains(args, softwareToneMapFilterBT2390) {
|
||||
t.Fatalf("CPU fallback args missing BT.2390 filter: %#v", args)
|
||||
}
|
||||
return []byte("frame"), nil
|
||||
},
|
||||
})
|
||||
if err != nil || reason != "" || string(data) != "frame" {
|
||||
t.Fatalf("ExtractFrame() = %q, %q, %v", data, reason, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("extract calls = %d, want 2", calls)
|
||||
}
|
||||
assertApproximateDeadline(t, hwRemaining, hwExtractTimeoutHDR)
|
||||
assertApproximateDeadline(t, cpuRemaining, cpuExtractTimeoutHDR)
|
||||
}
|
||||
|
||||
func TestExtractFrameInvalidHardwareMediaDoesNotRetryOnCPU(t *testing.T) {
|
||||
resolver := newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
|
||||
t.Fatal("software filter probe should not run for invalid media")
|
||||
return nil, nil
|
||||
})
|
||||
calls := 0
|
||||
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
HWAccel: "vaapi",
|
||||
HWDevice: "/dev/dri/renderD128",
|
||||
ToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(context.Context, string, []string) ([]byte, error) {
|
||||
calls++
|
||||
return nil, errors.New("Invalid NAL unit size")
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ExtractFrame() error = nil, want invalid-media error")
|
||||
}
|
||||
if reason != "decode_invalid_data" {
|
||||
t.Fatalf("ExtractFrame() reason = %q, want decode_invalid_data", reason)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("extract calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameMissingSoftwareFiltersIsActionable(t *testing.T) {
|
||||
resolver := resolverWithFilters(t, "zscale")
|
||||
calls := 0
|
||||
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
HWAccel: hwAccelNone,
|
||||
ToneMap: true,
|
||||
AllowSoftwareToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(context.Context, string, []string) ([]byte, error) {
|
||||
calls++
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "lacks the required tonemapx or tonemap filter") {
|
||||
t.Fatalf("ExtractFrame() error = %v, want actionable missing-filter error", err)
|
||||
}
|
||||
if reason != "tonemap_unsupported" {
|
||||
t.Fatalf("ExtractFrame() reason = %q, want tonemap_unsupported", reason)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("extract calls = %d, want 0", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFramePreservesHardwareAndCPUFailures(t *testing.T) {
|
||||
resolver := resolverWithFilters(t, "zscale", "tonemap")
|
||||
calls := 0
|
||||
_, reason, err := ExtractFrame(context.Background(), FrameExtractOptions{
|
||||
InputPath: "/media/movie.mkv",
|
||||
SeekSeconds: 42.5,
|
||||
HWAccel: "vaapi",
|
||||
HWDevice: "/dev/dri/renderD128",
|
||||
ToneMap: true,
|
||||
AllowSoftwareToneMap: true,
|
||||
softwareToneMapResolver: resolver,
|
||||
RunFunc: func(context.Context, string, []string) ([]byte, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return nil, errors.New("software decode failed")
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ExtractFrame() error = nil, want combined failure")
|
||||
}
|
||||
if reason != "chapter_extract_failed" {
|
||||
t.Fatalf("ExtractFrame() reason = %q, want chapter_extract_failed", reason)
|
||||
}
|
||||
for _, want := range []string{"hardware extraction failed", "hw_timeout", "cpu fallback failed", "software decode failed"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("ExtractFrame() error = %q, want containing %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteExtractTimeoutBudgetsOnlyAllowedAttempts(t *testing.T) {
|
||||
sdrExtractBudget := extractTimeoutForAttempt(true, false) + extractTimeoutForAttempt(false, false)
|
||||
if got := remoteExtractTimeout(false, false); got <= sdrExtractBudget {
|
||||
t.Fatalf("remoteExtractTimeout(SDR) = %s, want more than extract budget %s", got, sdrExtractBudget)
|
||||
}
|
||||
|
||||
hardwareHDRBudget := extractTimeoutForAttempt(true, true)
|
||||
softwareHDRBudget := extractTimeoutForAttempt(false, true)
|
||||
if got := remoteExtractTimeout(true, false); got <= hardwareHDRBudget || got >= hardwareHDRBudget+softwareHDRBudget {
|
||||
t.Fatalf("remoteExtractTimeout(HDR hardware only) = %s, want hardware plus overhead without CPU budget", got)
|
||||
}
|
||||
|
||||
hdrExtractBudget := hardwareHDRBudget + softwareHDRBudget
|
||||
if got := remoteExtractTimeout(true, true); got-hdrExtractBudget <= softwareToneMapProbeTimeout {
|
||||
t.Fatalf(
|
||||
"remoteExtractTimeout(HDR with CPU) overhead = %s, want more than probe budget %s",
|
||||
got-hdrExtractBudget,
|
||||
softwareToneMapProbeTimeout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteProbeFailureAllowsPreferredLocalFallback(t *testing.T) {
|
||||
if !isInfrastructureRemoteFailure(reasonFFmpegProbeFailed) {
|
||||
t.Fatalf("isInfrastructureRemoteFailure(%q) = false, want true", reasonFFmpegProbeFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func resolverWithFilters(t *testing.T, filters ...string) *softwareToneMapFilterResolver {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
lines = append(lines, " .S. "+filter+" V->V")
|
||||
}
|
||||
output := strings.Join(lines, "\n")
|
||||
return newSoftwareToneMapFilterResolver(func(string) ([]byte, error) {
|
||||
return []byte(output), nil
|
||||
})
|
||||
}
|
||||
|
||||
func assertApproximateDeadline(t *testing.T, got time.Duration, want time.Duration) {
|
||||
t.Helper()
|
||||
if got < want-time.Second || got > want+time.Second {
|
||||
t.Fatalf("deadline remaining = %s, want about %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,15 @@ const (
|
||||
authJWTSecretSetting = "auth.jwt_secret"
|
||||
)
|
||||
|
||||
// remoteExtractOverheadBuffer covers HTTP transport and node-side scheduling
|
||||
// overhead on top of the extraction attempt deadlines.
|
||||
const remoteExtractOverheadBuffer = 3 * time.Second
|
||||
|
||||
type RemoteExtractRequest struct {
|
||||
InputPath string `json:"input_path"`
|
||||
SeekSeconds float64 `json:"seek_seconds"`
|
||||
ToneMap bool `json:"tone_map"`
|
||||
InputPath string `json:"input_path"`
|
||||
SeekSeconds float64 `json:"seek_seconds"`
|
||||
ToneMap bool `json:"tone_map"`
|
||||
AllowSoftwareToneMap bool `json:"allow_software_tone_map"`
|
||||
}
|
||||
|
||||
type RemoteExtractErrorResponse struct {
|
||||
@@ -58,10 +63,10 @@ func (e *httpRemoteFrameExtractor) ExtractFrame(
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, "chapter_extract_failed", fmt.Errorf("chapter thumbnail remote extract: marshal request: %w", err)
|
||||
return nil, reasonChapterExtractFailed, fmt.Errorf("chapter thumbnail remote extract: marshal request: %w", err)
|
||||
}
|
||||
|
||||
timeout := remoteExtractTimeout(req.ToneMap)
|
||||
timeout := remoteExtractTimeout(req.ToneMap, req.AllowSoftwareToneMap)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -103,22 +108,25 @@ func (e *httpRemoteFrameExtractor) ExtractFrame(
|
||||
return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: read response: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "chapter_extract_failed", fmt.Errorf("chapter thumbnail remote extract: empty response")
|
||||
return nil, reasonChapterExtractFailed, fmt.Errorf("chapter thumbnail remote extract: empty response")
|
||||
}
|
||||
return data, "", nil
|
||||
}
|
||||
|
||||
func remoteExtractTimeout(toneMap bool) time.Duration {
|
||||
timeout := extractTimeoutForAttempt(true, toneMap)
|
||||
if !toneMap {
|
||||
timeout += extractTimeoutForAttempt(false, false)
|
||||
func remoteExtractTimeout(toneMap bool, allowSoftwareToneMap bool) time.Duration {
|
||||
timeout := extractTimeoutForAttempt(true, toneMap) + remoteExtractOverheadBuffer
|
||||
if !toneMap || allowSoftwareToneMap {
|
||||
timeout += extractTimeoutForAttempt(false, toneMap)
|
||||
}
|
||||
return timeout + 3*time.Second
|
||||
if toneMap && allowSoftwareToneMap {
|
||||
timeout += softwareToneMapProbeTimeout
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func isInfrastructureRemoteFailure(reason string) bool {
|
||||
switch reason {
|
||||
case chapterThumbnailNodeUnavailableReason, chapterThumbnailNodeCapacityExhaustedReason:
|
||||
case chapterThumbnailNodeUnavailableReason, chapterThumbnailNodeCapacityExhaustedReason, reasonFFmpegProbeFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -31,10 +31,11 @@ const (
|
||||
cpuExtractTimeoutSDR = 10 * time.Second
|
||||
cpuExtractTimeoutHDR = 25 * time.Second
|
||||
|
||||
chapterThumbnailHDRPolicySetting = "playback.chapter_thumbnail_hdr_policy"
|
||||
chapterThumbnailHDRPolicyDefault = "best_effort"
|
||||
chapterThumbnailHDRPolicyDisabled = "disabled"
|
||||
chapterThumbnailHDRPolicyBestEffort = "best_effort"
|
||||
chapterThumbnailHDRPolicySetting = "playback.chapter_thumbnail_hdr_policy"
|
||||
chapterThumbnailHDRPolicyDefault = "best_effort"
|
||||
chapterThumbnailHDRPolicyDisabled = "disabled"
|
||||
chapterThumbnailHDRPolicyBestEffort = "best_effort"
|
||||
chapterThumbnailSoftwareToneMapSetting = "playback.chapter_thumbnail_software_tone_map_enabled"
|
||||
)
|
||||
|
||||
var chapterThumbnailRetrySchedule = []time.Duration{
|
||||
@@ -436,7 +437,7 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
mutated = true
|
||||
failed++
|
||||
if shouldApplyFileFailure(reason) {
|
||||
hardFileFailure = buildPersistentFileFailure(&updated, now, reason, err)
|
||||
hardFileFailure = buildFileFailure(&updated, now, reason, err)
|
||||
slog.WarnContext(ctx,
|
||||
"chapter thumbnail file marked failed", "component", "chapterthumbs",
|
||||
"file_id",
|
||||
@@ -460,11 +461,11 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques
|
||||
"chapter_index",
|
||||
chapter.Index,
|
||||
"reason",
|
||||
"chapter_extract_failed",
|
||||
reasonChapterExtractFailed,
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
recordChapterFailure(&updated.Chapters[candidate.offset], now, "chapter_extract_failed", err)
|
||||
recordChapterFailure(&updated.Chapters[candidate.offset], now, reasonChapterExtractFailed, err)
|
||||
mutated = true
|
||||
failed++
|
||||
continue
|
||||
@@ -587,9 +588,10 @@ func (s *Service) extractFrame(
|
||||
return s.extractFrameFunc(ctx, file, seekSeconds, hdrPolicy)
|
||||
}
|
||||
toneMap := needsTonemap(file) && hdrPolicy == chapterThumbnailHDRPolicyBestEffort
|
||||
allowSoftwareToneMap := toneMap && s.chapterThumbnailSoftwareToneMapEnabled(ctx)
|
||||
mode := s.chapterThumbnailExecutionMode(ctx)
|
||||
if mode == chapterThumbnailExecutionLocal {
|
||||
return s.extractFrameLocal(ctx, file.FilePath, seekSeconds, toneMap)
|
||||
return s.extractFrameLocal(ctx, file.FilePath, seekSeconds, toneMap, allowSoftwareToneMap)
|
||||
}
|
||||
|
||||
node, release, nodeReason := s.reserveRemoteNode(ctx)
|
||||
@@ -602,7 +604,7 @@ func (s *Service) extractFrame(
|
||||
"reason",
|
||||
nodeReason,
|
||||
)
|
||||
return s.extractFrameLocal(ctx, file.FilePath, seekSeconds, toneMap)
|
||||
return s.extractFrameLocal(ctx, file.FilePath, seekSeconds, toneMap, allowSoftwareToneMap)
|
||||
}
|
||||
return nil, nodeReason, wrapReason(nodeReason, fmt.Errorf("no transcode node available for chapter thumbnail extraction"))
|
||||
}
|
||||
@@ -610,9 +612,10 @@ func (s *Service) extractFrame(
|
||||
|
||||
jwtSecret := s.chapterThumbnailJWTSecret(ctx)
|
||||
data, reason, err := s.remoteExtractor.ExtractFrame(ctx, node, jwtSecret, RemoteExtractRequest{
|
||||
InputPath: file.FilePath,
|
||||
SeekSeconds: seekSeconds,
|
||||
ToneMap: toneMap,
|
||||
InputPath: file.FilePath,
|
||||
SeekSeconds: seekSeconds,
|
||||
ToneMap: toneMap,
|
||||
AllowSoftwareToneMap: allowSoftwareToneMap,
|
||||
})
|
||||
if err == nil {
|
||||
return data, "", nil
|
||||
@@ -630,22 +633,29 @@ func (s *Service) extractFrame(
|
||||
"error",
|
||||
err,
|
||||
)
|
||||
return s.extractFrameLocal(ctx, file.FilePath, seekSeconds, toneMap)
|
||||
return s.extractFrameLocal(ctx, file.FilePath, seekSeconds, toneMap, allowSoftwareToneMap)
|
||||
}
|
||||
|
||||
return nil, reason, err
|
||||
}
|
||||
|
||||
func (s *Service) extractFrameLocal(ctx context.Context, inputPath string, seekSeconds float64, toneMap bool) ([]byte, string, error) {
|
||||
func (s *Service) extractFrameLocal(
|
||||
ctx context.Context,
|
||||
inputPath string,
|
||||
seekSeconds float64,
|
||||
toneMap bool,
|
||||
allowSoftwareToneMap bool,
|
||||
) ([]byte, string, error) {
|
||||
resolvedAccel, resolvedDevice := s.resolveHWConfig()
|
||||
return ExtractFrame(ctx, FrameExtractOptions{
|
||||
InputPath: inputPath,
|
||||
SeekSeconds: seekSeconds,
|
||||
FFmpegPath: s.ffmpegPath,
|
||||
HWAccel: resolvedAccel,
|
||||
HWDevice: resolvedDevice,
|
||||
ToneMap: toneMap,
|
||||
RunFunc: s.runFFmpegFrameExtractFunc,
|
||||
InputPath: inputPath,
|
||||
SeekSeconds: seekSeconds,
|
||||
FFmpegPath: s.ffmpegPath,
|
||||
HWAccel: resolvedAccel,
|
||||
HWDevice: resolvedDevice,
|
||||
ToneMap: toneMap,
|
||||
AllowSoftwareToneMap: allowSoftwareToneMap,
|
||||
RunFunc: s.runFFmpegFrameExtractFunc,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1043,6 +1053,14 @@ func (s *Service) chapterThumbnailHDRPolicy(ctx context.Context) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) chapterThumbnailSoftwareToneMapEnabled(ctx context.Context) bool {
|
||||
if s == nil || s.settings == nil {
|
||||
return false
|
||||
}
|
||||
value, err := s.settings.Get(ctx, chapterThumbnailSoftwareToneMapSetting)
|
||||
return err == nil && strings.EqualFold(strings.TrimSpace(value), "true")
|
||||
}
|
||||
|
||||
func hasEligibleMissingChapter(chapters []models.MediaChapter, now time.Time) bool {
|
||||
for _, chapter := range chapters {
|
||||
if isChapterEligible(chapter, now) {
|
||||
@@ -1179,21 +1197,26 @@ func retryDurationForCount(failureCount int) time.Duration {
|
||||
|
||||
func shouldApplyFileFailure(reason string) bool {
|
||||
switch reason {
|
||||
case "decode_invalid_data":
|
||||
case reasonDecodeInvalidData, reasonFFmpegProbeFailed, reasonToneMapUnsupported:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildPersistentFileFailure(
|
||||
func buildFileFailure(
|
||||
file *models.MediaFile,
|
||||
now time.Time,
|
||||
reason string,
|
||||
err error,
|
||||
) *scanner.ChapterThumbnailFailureState {
|
||||
failureCount := len(chapterThumbnailRetrySchedule)
|
||||
if file != nil && file.ChapterThumbnailFailureCount >= failureCount {
|
||||
failureCount := 1
|
||||
if reason == reasonDecodeInvalidData {
|
||||
failureCount = len(chapterThumbnailRetrySchedule)
|
||||
if file != nil && file.ChapterThumbnailFailureCount >= failureCount {
|
||||
failureCount = file.ChapterThumbnailFailureCount + 1
|
||||
}
|
||||
} else if file != nil {
|
||||
failureCount = file.ChapterThumbnailFailureCount + 1
|
||||
}
|
||||
retryAfter := now.Add(retryDurationForCount(failureCount))
|
||||
|
||||
@@ -66,14 +66,10 @@ func TestBuildFrameExtractArgs(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown hw accel falls back to cpu args", func(t *testing.T) {
|
||||
args, err := buildFrameExtractArgs("/media/movie.mkv", 42.5, "none", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("buildFrameExtractArgs() error = %v", err)
|
||||
}
|
||||
want := buildCPUFrameExtractArgs("/media/movie.mkv", 42.5, false)
|
||||
if !slices.Equal(args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", args, want)
|
||||
t.Run("unsupported hw accel does not masquerade as hardware extraction", func(t *testing.T) {
|
||||
_, err := buildFrameExtractArgs("/media/movie.mkv", 42.5, "nvenc", "/dev/dri/renderD128", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not support") {
|
||||
t.Fatalf("buildFrameExtractArgs() error = %v, want unsupported accelerator error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -275,21 +271,23 @@ func (r testSettingsReader) Get(_ context.Context, key string) (string, error) {
|
||||
}
|
||||
|
||||
type testRemoteFrameExtractor struct {
|
||||
data []byte
|
||||
reason string
|
||||
err error
|
||||
nodes []string
|
||||
data []byte
|
||||
reason string
|
||||
err error
|
||||
nodes []string
|
||||
requests []RemoteExtractRequest
|
||||
}
|
||||
|
||||
func (e *testRemoteFrameExtractor) ExtractFrame(
|
||||
_ context.Context,
|
||||
node *nodepool.Node,
|
||||
_ string,
|
||||
_ RemoteExtractRequest,
|
||||
req RemoteExtractRequest,
|
||||
) ([]byte, string, error) {
|
||||
if node != nil {
|
||||
e.nodes = append(e.nodes, node.URL)
|
||||
}
|
||||
e.requests = append(e.requests, req)
|
||||
return e.data, e.reason, e.err
|
||||
}
|
||||
|
||||
@@ -426,6 +424,42 @@ func TestProcessRequestSkipsFileDuringCooldown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRequestSkipsHDRWhenPolicyDisabled(t *testing.T) {
|
||||
fileRepo := &testFileRepo{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
MediaFolderID: 9,
|
||||
FilePath: "/media/movie.mkv",
|
||||
HDR: true,
|
||||
Chapters: []models.MediaChapter{
|
||||
{Index: 0, StartSeconds: 0, EndSeconds: 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
service := &Service{
|
||||
fileRepo: fileRepo,
|
||||
folderRepo: &testFolderRepo{folder: &models.MediaFolder{ID: 9, Enabled: true, ChapterThumbnailsEnabled: true}},
|
||||
settings: testSettingsReader{values: map[string]string{
|
||||
chapterThumbnailHDRPolicySetting: chapterThumbnailHDRPolicyDisabled,
|
||||
}},
|
||||
extractFrameFunc: func(context.Context, *models.MediaFile, float64, string) ([]byte, string, error) {
|
||||
t.Fatal("HDR extraction should not run when the policy is disabled")
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
|
||||
requeue, err := service.processRequest(context.Background(), ChapterThumbnailRequest{FileID: 42}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("processRequest() error = %v", err)
|
||||
}
|
||||
if requeue {
|
||||
t.Fatal("processRequest() requeue = true, want false")
|
||||
}
|
||||
if fileRepo.updateCalls != 0 {
|
||||
t.Fatalf("updateCalls = %d, want 0", fileRepo.updateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRequestMarksDecodeInvalidDataAsFileFailure(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
fileRepo := &testFileRepo{
|
||||
@@ -485,6 +519,87 @@ func TestProcessRequestMarksDecodeInvalidDataAsFileFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRequestReportsToneMapCapabilityFailuresOnceWithNormalRetry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "unsupported filters",
|
||||
reason: reasonToneMapUnsupported,
|
||||
err: errors.New("configured FFmpeg lacks the required zscale filter"),
|
||||
},
|
||||
{
|
||||
name: "transient probe failure",
|
||||
reason: reasonFFmpegProbeFailed,
|
||||
err: errors.New("FFmpeg filter probe failed: resource temporarily unavailable"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
fileRepo := &testFileRepo{
|
||||
file: &models.MediaFile{
|
||||
ID: 42,
|
||||
MediaFolderID: 9,
|
||||
FilePath: "/media/movie.mkv",
|
||||
HDR: true,
|
||||
Chapters: []models.MediaChapter{
|
||||
{Index: 0, StartSeconds: 0, EndSeconds: 10},
|
||||
{Index: 1, StartSeconds: 10, EndSeconds: 20},
|
||||
},
|
||||
},
|
||||
}
|
||||
callCount := 0
|
||||
service := &Service{
|
||||
fileRepo: fileRepo,
|
||||
folderRepo: &testFolderRepo{folder: &models.MediaFolder{ID: 9, Enabled: true, ChapterThumbnailsEnabled: true}},
|
||||
clock: func() time.Time {
|
||||
return now
|
||||
},
|
||||
extractFrameFunc: func(context.Context, *models.MediaFile, float64, string) ([]byte, string, error) {
|
||||
callCount++
|
||||
return nil, tt.reason, tt.err
|
||||
},
|
||||
}
|
||||
|
||||
requeue, err := service.processRequest(context.Background(), ChapterThumbnailRequest{FileID: 42}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("processRequest() error = %v", err)
|
||||
}
|
||||
if requeue {
|
||||
t.Fatal("processRequest() requeue = true, want false")
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("callCount = %d, want 1", callCount)
|
||||
}
|
||||
if fileRepo.updateCalls != 1 {
|
||||
t.Fatalf("updateCalls = %d, want 1", fileRepo.updateCalls)
|
||||
}
|
||||
if fileRepo.file.ChapterThumbnailFailureCount != 1 {
|
||||
t.Fatalf("failureCount = %d, want 1", fileRepo.file.ChapterThumbnailFailureCount)
|
||||
}
|
||||
if fileRepo.file.ChapterThumbnailRetryAfter == nil {
|
||||
t.Fatal("expected file-level retry_after to be set")
|
||||
}
|
||||
if got, want := *fileRepo.file.ChapterThumbnailRetryAfter, now.Add(15*time.Minute); !got.Equal(want) {
|
||||
t.Fatalf("retryAfter = %v, want %v", got, want)
|
||||
}
|
||||
if !strings.HasPrefix(fileRepo.file.ChapterThumbnailLastError, tt.reason+":") {
|
||||
t.Fatalf("lastError = %q, want %s prefix", fileRepo.file.ChapterThumbnailLastError, tt.reason)
|
||||
}
|
||||
if fileRepo.file.Chapters[0].ThumbnailRetryAfter == nil {
|
||||
t.Fatal("expected first chapter retry_after to be set")
|
||||
}
|
||||
if fileRepo.file.Chapters[1].ThumbnailRetryAfter != nil {
|
||||
t.Fatal("expected later chapters to be skipped after file-level failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameCPUFallbackGetsFreshDeadline(t *testing.T) {
|
||||
service := &Service{
|
||||
hwAccel: "vaapi",
|
||||
@@ -569,6 +684,61 @@ func TestExtractFramePrefersRemoteNodeWhenEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFramePropagatesSoftwareToneMapSettingToRemoteNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settingValue string
|
||||
wantAllowed bool
|
||||
}{
|
||||
{name: "disabled by default", wantAllowed: false},
|
||||
{name: "explicitly enabled", settingValue: "true", wantAllowed: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
remote := &testRemoteFrameExtractor{data: []byte("remote-frame")}
|
||||
settings := map[string]string{
|
||||
chapterThumbnailExecutionSetting: chapterThumbnailExecutionPreferTranscode,
|
||||
authJWTSecretSetting: "secret",
|
||||
}
|
||||
if tt.settingValue != "" {
|
||||
settings[chapterThumbnailSoftwareToneMapSetting] = tt.settingValue
|
||||
}
|
||||
service := &Service{
|
||||
settings: testSettingsReader{values: settings},
|
||||
transcodePool: &nodepool.TranscodePool{},
|
||||
remoteReservations: make(map[string]int),
|
||||
remoteExtractor: remote,
|
||||
}
|
||||
service.transcodePool.SetNodes([]*nodepool.Node{{
|
||||
URL: "http://node-1",
|
||||
Enabled: true,
|
||||
Healthy: true,
|
||||
}})
|
||||
|
||||
_, _, err := service.extractFrame(
|
||||
context.Background(),
|
||||
&models.MediaFile{FilePath: "/media/movie.mkv", HDR: true},
|
||||
5,
|
||||
chapterThumbnailHDRPolicyBestEffort,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("extractFrame() error = %v", err)
|
||||
}
|
||||
if len(remote.requests) != 1 {
|
||||
t.Fatalf("remote requests = %d, want 1", len(remote.requests))
|
||||
}
|
||||
request := remote.requests[0]
|
||||
if !request.ToneMap {
|
||||
t.Fatal("remote request tone_map = false, want true for HDR")
|
||||
}
|
||||
if request.AllowSoftwareToneMap != tt.wantAllowed {
|
||||
t.Fatalf("remote request allow_software_tone_map = %t, want %t", request.AllowSoftwareToneMap, tt.wantAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameFallsBackLocalWhenPreferredNodeUnavailable(t *testing.T) {
|
||||
service := &Service{
|
||||
settings: testSettingsReader{values: map[string]string{
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/quic-go/quic-go v0.60.0
|
||||
github.com/quic-go/webtransport-go v0.10.0
|
||||
github.com/quic-go/webtransport-go v0.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -10,8 +10,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA=
|
||||
github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
|
||||
@@ -13,7 +13,11 @@ import (
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
const cloudflareURLMode = "cloudflare_token"
|
||||
const (
|
||||
cloudflareURLMode = "cloudflare_token"
|
||||
playbackSegmentRetentionSettingKey = "playback.segment_retention_seconds"
|
||||
chapterThumbnailSoftwareToneMapKey = "playback.chapter_thumbnail_software_tone_map_enabled"
|
||||
)
|
||||
|
||||
// adminSettingDefaults is the effective value shown by the Admin UI when no
|
||||
// row exists in server_settings. Keep these values aligned with the runtime
|
||||
@@ -50,6 +54,7 @@ var adminSettingDefaults = map[string]string{
|
||||
|
||||
"playback.ffmpeg_path": "/usr/lib/jellyfin-ffmpeg/ffmpeg",
|
||||
"playback.transcode_dir": DefaultTranscodeDir,
|
||||
playbackSegmentRetentionSettingKey: "600",
|
||||
"playback.hw_accel": "auto",
|
||||
"playback.transcode_enabled": "true",
|
||||
"playback.local_transcode_fallback": "true",
|
||||
@@ -57,6 +62,7 @@ var adminSettingDefaults = map[string]string{
|
||||
"playback.chapter_thumbnail_execution": "local",
|
||||
"playback.chapter_thumbnail_node_capacity": "1",
|
||||
"playback.chapter_thumbnail_hdr_policy": "best_effort",
|
||||
chapterThumbnailSoftwareToneMapKey: "false",
|
||||
"playback.watched_threshold": "90",
|
||||
"playback.min_resume_threshold": "5",
|
||||
"allow_4k_transcode": "false",
|
||||
@@ -267,6 +273,7 @@ func NormalizeAdminSetting(key, raw string) (string, error) {
|
||||
|
||||
switch key {
|
||||
case "metadata.cache_images", "playback.transcode_enabled", "playback.local_transcode_fallback",
|
||||
chapterThumbnailSoftwareToneMapKey,
|
||||
"allow_4k_transcode", "enable_transcode_throttle", "audiobookshelf_compat.enabled",
|
||||
"jellyfin_compat.enabled", "jellyfin_compat.web_enabled", "recommendations.enabled",
|
||||
"subtitle_ai.enabled", "subtitle_ai.transcribe_enabled", "metadata_ai.enabled",
|
||||
@@ -298,6 +305,16 @@ func NormalizeAdminSetting(key, raw string) (string, error) {
|
||||
return normalizeAdminInt(key, value, 1, 99)
|
||||
case "transcode_throttle_seconds":
|
||||
return normalizeAdminInt(key, value, 60, 86400)
|
||||
case playbackSegmentRetentionSettingKey:
|
||||
normalized, err := normalizeAdminInt(key, value, 0, 86400)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
seconds, _ := strconv.Atoi(normalized)
|
||||
if seconds != 0 && seconds < 120 {
|
||||
return "", fmt.Errorf("%s must be 0 or between 120 and 86400", key)
|
||||
}
|
||||
return normalized, nil
|
||||
case "ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs":
|
||||
return normalizeAdminInt(key, value, 1, 1024)
|
||||
case "subtitle_ai.batch_size":
|
||||
|
||||
@@ -179,6 +179,13 @@ func TestAdminSettingDefaultsAlignWithConfigRuntimeDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapterThumbnailSoftwareToneMapDefaultsDisabled(t *testing.T) {
|
||||
effective := EffectiveAdminSettings(nil)
|
||||
if got := effective[chapterThumbnailSoftwareToneMapKey]; got != "false" {
|
||||
t.Fatalf("software tone-map default = %q, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEffectiveRuntimeDefaults(cfg *Config) {
|
||||
if cfg.S3.Public.URLAuth == "" {
|
||||
cfg.S3.Public.URLAuth = "presigned"
|
||||
@@ -209,6 +216,7 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) {
|
||||
}{
|
||||
{key: "database.max_connections", value: "0"},
|
||||
{key: "metadata.cache_images", value: "maybe"},
|
||||
{key: chapterThumbnailSoftwareToneMapKey, value: "maybe"},
|
||||
{key: "auth.access_token_expiry", value: "forever"},
|
||||
{key: "recommendations.embeddings_cron", value: "not a cron"},
|
||||
{key: "notifications.server_channels.batch_seconds", value: "119"},
|
||||
@@ -217,6 +225,7 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) {
|
||||
{key: "theme.catalog_url", value: "http://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json"},
|
||||
{key: "theme.catalog_url", value: "https://example.com/catalog.json"},
|
||||
{key: "redis.url", value: "not-a-url"},
|
||||
{key: "playback.segment_retention_seconds", value: "119"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
@@ -227,6 +236,20 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdminSettingAcceptsSegmentRetentionBounds(t *testing.T) {
|
||||
for _, value := range []string{"0", "120", "86400"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
got, err := NormalizeAdminSetting("playback.segment_retention_seconds", value)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeAdminSetting: %v", err)
|
||||
}
|
||||
if got != value {
|
||||
t.Fatalf("normalized retention = %q, want %q", got, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdminSettingAcceptsApprovedThemeCatalogURL(t *testing.T) {
|
||||
got, err := NormalizeAdminSetting(
|
||||
"theme.catalog_url",
|
||||
|
||||
@@ -157,9 +157,10 @@ func (c MatcherConfig) TVSeriesRootQueueEnabled() bool {
|
||||
|
||||
// PlaybackConfig holds transcoding and playback settings.
|
||||
type PlaybackConfig struct {
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TranscodeDir string `yaml:"transcode_dir"`
|
||||
HWAccel string `yaml:"hw_accel"`
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TranscodeDir string `yaml:"transcode_dir"`
|
||||
SegmentRetentionSeconds int `yaml:"segment_retention_seconds"`
|
||||
HWAccel string `yaml:"hw_accel"`
|
||||
// HWDevice is the GPU render device for hardware transcodes. A single
|
||||
// path pins every GPU workload to that device; a comma-separated list
|
||||
// (e.g. "/dev/dri/renderD128,/dev/dri/renderD129") balances workloads
|
||||
@@ -479,6 +480,7 @@ func setDefaults() *configRaw {
|
||||
Playback: PlaybackConfig{
|
||||
FFmpegPath: "/usr/lib/jellyfin-ffmpeg/ffmpeg",
|
||||
TranscodeDir: DefaultTranscodeDir,
|
||||
SegmentRetentionSeconds: 600,
|
||||
HWAccel: "auto",
|
||||
ChapterThumbnailWorkers: 1,
|
||||
ChapterThumbnailExecution: "local",
|
||||
|
||||
@@ -317,6 +317,14 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
// Playback
|
||||
cfg.Playback.FFmpegPath = stringOr(m, "playback.ffmpeg_path", "/usr/lib/jellyfin-ffmpeg/ffmpeg")
|
||||
cfg.Playback.TranscodeDir = stringOr(m, "playback.transcode_dir", DefaultTranscodeDir)
|
||||
segmentRetentionSeconds, err := intOr(m, playbackSegmentRetentionSettingKey, 600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if segmentRetentionSeconds != 0 && (segmentRetentionSeconds < 120 || segmentRetentionSeconds > 86400) {
|
||||
return nil, fmt.Errorf("%s must be 0 or between 120 and 86400", playbackSegmentRetentionSettingKey)
|
||||
}
|
||||
cfg.Playback.SegmentRetentionSeconds = segmentRetentionSeconds
|
||||
cfg.Playback.HWAccel = stringOr(m, "playback.hw_accel", "auto")
|
||||
cfg.Playback.HWDevice = stringOr(m, "playback.hw_device", "")
|
||||
chapterThumbnailWorkers, err := intOr(m, "playback.chapter_thumbnail_workers", 1)
|
||||
|
||||
@@ -35,6 +35,17 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDBRejectsInvalidSegmentRetention(t *testing.T) {
|
||||
for _, value := range []string{"-1", "119", "86401"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
_, err := LoadFromDB(map[string]string{playbackSegmentRetentionSettingKey: value})
|
||||
if err == nil || !strings.Contains(err.Error(), playbackSegmentRetentionSettingKey) {
|
||||
t.Fatalf("LoadFromDB() error = %v, want retention bounds error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDBJellyfinWebEnabledDefaultsToTrue(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{})
|
||||
if err != nil {
|
||||
@@ -186,6 +197,16 @@ func TestYAMLToSettingsMapJellyfinCompatEnabledDefaultsToLegacyListener(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLToSettingsMapPreservesExplicitlyDisabledSegmentRetention(t *testing.T) {
|
||||
m := yamlSettingsMapFromString(t, `
|
||||
playback:
|
||||
segment_retention_seconds: 0
|
||||
`)
|
||||
if got := m[playbackSegmentRetentionSettingKey]; got != "0" {
|
||||
t.Fatalf("segment retention = %q, want explicit disable", got)
|
||||
}
|
||||
}
|
||||
|
||||
func yamlSettingsMapFromString(t *testing.T, body string) map[string]string {
|
||||
t.Helper()
|
||||
path := t.TempDir() + "/silo.yaml"
|
||||
|
||||
@@ -209,6 +209,7 @@ func YAMLToSettingsMap(path string) (map[string]string, error) {
|
||||
// Playback
|
||||
setIfNonEmpty(m, "playback.ffmpeg_path", raw.Playback.FFmpegPath)
|
||||
setIfNonEmpty(m, "playback.transcode_dir", raw.Playback.TranscodeDir)
|
||||
m[playbackSegmentRetentionSettingKey] = strconv.Itoa(raw.Playback.SegmentRetentionSeconds)
|
||||
setIfNonEmpty(m, "playback.hw_accel", raw.Playback.HWAccel)
|
||||
if raw.Playback.ChapterThumbnailWorkers != 0 {
|
||||
m["playback.chapter_thumbnail_workers"] = strconv.Itoa(raw.Playback.ChapterThumbnailWorkers)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJellycompatServerNameDefaultsToSilo(t *testing.T) {
|
||||
initialSchema, err := os.ReadFile("../../migrations/sql/001_schema.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read initial schema: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(initialSchema), "('jellyfin_compat.server_name', 'Silo')") {
|
||||
t.Fatal("initial schema does not seed the Jellyfin compat server name to Silo")
|
||||
}
|
||||
|
||||
migration, err := os.ReadFile("../../migrations/sql/20260806224241_rename_default_jellycompat_server_to_silo.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read server-name migration: %v", err)
|
||||
}
|
||||
normalized := strings.Join(strings.Fields(string(migration)), " ")
|
||||
for _, fragment := range []string{
|
||||
"SET value = 'Silo'",
|
||||
"WHERE key = 'jellyfin_compat.server_name' AND value = 'StreamApp'",
|
||||
} {
|
||||
if !strings.Contains(normalized, fragment) {
|
||||
t.Fatalf("server-name migration missing %q:\n%s", fragment, migration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTranscodeDirDefaultsToSiloPath(t *testing.T) {
|
||||
initialSchema, err := os.ReadFile("../../migrations/sql/001_schema.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read initial schema: %v", err)
|
||||
}
|
||||
schema := string(initialSchema)
|
||||
if !strings.Contains(schema, "('playback.transcode_dir', '/tmp/silo-transcode')") {
|
||||
t.Fatal("initial schema does not seed the Silo transcode directory")
|
||||
}
|
||||
if strings.Contains(schema, "('playback.transcode_dir', '/tmp/streamapp-transcode')") {
|
||||
t.Fatal("initial schema still seeds the legacy StreamApp transcode directory")
|
||||
}
|
||||
|
||||
migration, err := os.ReadFile("../../migrations/sql/20260809133921_rename_default_transcode_dir.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read transcode-dir migration: %v", err)
|
||||
}
|
||||
normalized := strings.Join(strings.Fields(string(migration)), " ")
|
||||
for _, fragment := range []string{
|
||||
"SET value = '/tmp/silo-transcode'",
|
||||
"WHERE key = 'playback.transcode_dir' AND value = '/tmp/streamapp-transcode'",
|
||||
} {
|
||||
if !strings.Contains(normalized, fragment) {
|
||||
t.Fatalf("transcode-dir migration missing %q:\n%s", fragment, migration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,19 +443,22 @@ func (m *ArtifactManager) buildOpts(file *models.MediaFile, a *Artifact) playbac
|
||||
cfg = *c
|
||||
}
|
||||
}
|
||||
sourceVideoCodec, sourceVideoProfile, sourceVideoBitDepth := playback.SourceVideoTranscodeFacts(file)
|
||||
return playback.TranscodeOpts{
|
||||
InputPath: file.FilePath,
|
||||
SourceVideoCodec: file.CodecVideo,
|
||||
TargetCodecVideo: a.CodecVideo,
|
||||
TargetCodecAudio: a.CodecAudio,
|
||||
TargetResolution: a.Resolution,
|
||||
TargetBitrateKbps: a.TargetBitrateKbps,
|
||||
AudioTrackIndex: a.AudioTrackIndex,
|
||||
SubtitleTrackIndex: -1,
|
||||
FFmpegPath: cfg.Playback.FFmpegPath,
|
||||
HWAccel: cfg.Playback.HWAccel,
|
||||
HWDevice: cfg.Playback.HWDevice,
|
||||
TotalDuration: float64(file.Duration),
|
||||
InputPath: file.FilePath,
|
||||
SourceVideoCodec: sourceVideoCodec,
|
||||
SourceVideoProfile: sourceVideoProfile,
|
||||
SourceVideoBitDepth: sourceVideoBitDepth,
|
||||
TargetCodecVideo: a.CodecVideo,
|
||||
TargetCodecAudio: a.CodecAudio,
|
||||
TargetResolution: a.Resolution,
|
||||
TargetBitrateKbps: a.TargetBitrateKbps,
|
||||
AudioTrackIndex: a.AudioTrackIndex,
|
||||
SubtitleTrackIndex: -1,
|
||||
FFmpegPath: cfg.Playback.FFmpegPath,
|
||||
HWAccel: cfg.Playback.HWAccel,
|
||||
HWDevice: cfg.Playback.HWDevice,
|
||||
TotalDuration: float64(file.Duration),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ func (c *JellyfinClient) AuthenticateServerUser(ctx context.Context, baseURL, us
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization", jellyfinAuthorizationHeader())
|
||||
setJellyfinAuthorizationHeader(req, jellyfinAuthorizationHeader())
|
||||
var resp jellyfinServerAuthResponse
|
||||
err = c.doJSON(req, &resp)
|
||||
if err == nil {
|
||||
@@ -128,7 +128,7 @@ func (c *JellyfinClient) FetchItemsByIDs(ctx context.Context, auth jellyfinLocal
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Emby-Authorization", jellyfinAuthorizationHeaderWithToken(auth.AccessToken))
|
||||
setJellyfinAuthorizationHeader(req, jellyfinAuthorizationHeaderWithToken(auth.AccessToken))
|
||||
|
||||
var payload jellyfinItemsResponse
|
||||
if err := c.doJSON(req, &payload); err != nil {
|
||||
@@ -156,7 +156,7 @@ func (c *JellyfinClient) FetchResumableItems(ctx context.Context, auth jellyfinL
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Emby-Authorization", jellyfinAuthorizationHeaderWithToken(auth.AccessToken))
|
||||
setJellyfinAuthorizationHeader(req, jellyfinAuthorizationHeaderWithToken(auth.AccessToken))
|
||||
|
||||
var payload jellyfinItemsResponse
|
||||
if err := c.doJSON(req, &payload); err != nil {
|
||||
@@ -191,7 +191,7 @@ func (c *JellyfinClient) fetchPagedItems(ctx context.Context, auth jellyfinLocal
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Emby-Authorization", jellyfinAuthorizationHeaderWithToken(auth.AccessToken))
|
||||
setJellyfinAuthorizationHeader(req, jellyfinAuthorizationHeaderWithToken(auth.AccessToken))
|
||||
|
||||
var payload jellyfinItemsResponse
|
||||
if err := c.doJSON(req, &payload); err != nil {
|
||||
@@ -244,13 +244,17 @@ func jellyfinAuthorizationHeaderWithToken(token string) string {
|
||||
return jellyfinAuthorizationHeader() + `, Token="` + token + `"`
|
||||
}
|
||||
|
||||
func setJellyfinAuthorizationHeader(req *http.Request, value string) {
|
||||
req.Header.Set("Authorization", value)
|
||||
}
|
||||
|
||||
// ListUsers returns all user accounts on the Jellyfin server using an admin API token.
|
||||
func (c *JellyfinClient) ListUsers(ctx context.Context, baseURL, adminToken string) ([]ExternalUser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/Users", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Emby-Authorization", jellyfinAuthorizationHeaderWithToken(adminToken))
|
||||
setJellyfinAuthorizationHeader(req, jellyfinAuthorizationHeaderWithToken(adminToken))
|
||||
var users []struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
|
||||
@@ -9,12 +9,74 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func assertJellyfinAuthorization(t *testing.T, r *http.Request, token string) {
|
||||
t.Helper()
|
||||
|
||||
want := `MediaBrowser Client="watch-importer", Device="Silo", DeviceId="silo-history-import", Version="1.0.0"`
|
||||
if token != "" {
|
||||
want += `, Token="` + token + `"`
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != want {
|
||||
t.Fatalf("Authorization = %q, want %q", got, want)
|
||||
}
|
||||
if got := r.Header.Get("X-Emby-Authorization"); got != "" {
|
||||
t.Fatalf("X-Emby-Authorization = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinAuthenticateServerUser_UsesStandardAuthorizationHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Path; got != "/Users/AuthenticateByName" {
|
||||
t.Fatalf("path = %q, want /Users/AuthenticateByName", got)
|
||||
}
|
||||
assertJellyfinAuthorization(t, r, "")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"AccessToken":"user-token","User":{"Id":"user-1"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewJellyfinClient()
|
||||
auth, err := client.AuthenticateServerUser(context.Background(), server.URL, "alice", "password")
|
||||
if err != nil {
|
||||
t.Fatalf("AuthenticateServerUser returned error: %v", err)
|
||||
}
|
||||
if auth.UserID != "user-1" || auth.AccessToken != "user-token" {
|
||||
t.Fatalf("auth = %+v, want user-1 with user-token", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinListUsers_UsesStandardAuthorizationHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Path; got != "/Users" {
|
||||
t.Fatalf("path = %q, want /Users", got)
|
||||
}
|
||||
assertJellyfinAuthorization(t, r, "admin-token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"Id":"user-1","Name":"Alice"},{"Id":"","Name":"Missing ID"}]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewJellyfinClient()
|
||||
users, err := client.ListUsers(context.Background(), server.URL, "admin-token")
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers returned error: %v", err)
|
||||
}
|
||||
if len(users) != 1 || users[0].ID != "user-1" || users[0].Name != "Alice" {
|
||||
t.Fatalf("users = %+v, want Alice (user-1)", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJellyfinFetchResumableItems_IncludesExpectedQueryAndPaginates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
assertJellyfinAuthorization(t, r, "token-1")
|
||||
|
||||
if got := r.URL.Path; got != "/UserItems/Resume" {
|
||||
t.Fatalf("path = %q, want /UserItems/Resume", got)
|
||||
@@ -71,6 +133,7 @@ func TestJellyfinFetchItems_PaginatesPlayedItems(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
assertJellyfinAuthorization(t, r, "token-1")
|
||||
|
||||
if got := r.URL.Path; got != "/Users/user-1/Items" {
|
||||
t.Fatalf("path = %q, want /Users/user-1/Items", got)
|
||||
|
||||
@@ -12,6 +12,7 @@ package httpstream
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -171,6 +172,26 @@ func (s *RollingDeadlineWriter) BytesWritten() int64 {
|
||||
return s.bytesWritten
|
||||
}
|
||||
|
||||
// CompletedFullResponse reports whether the complete representation was
|
||||
// accepted by the transport without a write error. The request context is not
|
||||
// consulted because net/http may cancel it after the response reaches the
|
||||
// client but before the handler performs post-response accounting. ServeContent
|
||||
// returns 206 for a whole-file Range request such as "bytes=0-", so byte count
|
||||
// alone is not enough to distinguish that transfer from a partial range.
|
||||
func (s *RollingDeadlineWriter) CompletedFullResponse(fullSize int64) bool {
|
||||
if fullSize <= 0 || s.bytesWritten != fullSize || s.firstWriteErr != nil {
|
||||
return false
|
||||
}
|
||||
switch s.statusCode {
|
||||
case http.StatusOK:
|
||||
return true
|
||||
case http.StatusPartialContent:
|
||||
return s.Header().Get("Content-Range") == fmt.Sprintf("bytes 0-%d/%d", fullSize-1, fullSize)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome classifies the first write failure, or a canceled request when no
|
||||
// write failure was surfaced by the transport.
|
||||
func (s *RollingDeadlineWriter) Outcome(ctx context.Context) StreamOutcome {
|
||||
|
||||
@@ -271,6 +271,42 @@ func TestServeContentReadFromOutcomeCompletedAndCounted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedFullResponse(t *testing.T) {
|
||||
const body = "complete segment"
|
||||
tests := []struct {
|
||||
name string
|
||||
rangeHeader string
|
||||
method string
|
||||
cancelAfterWrite bool
|
||||
want bool
|
||||
}{
|
||||
{name: "ordinary get", method: http.MethodGet, want: true},
|
||||
{name: "completed get with canceled context", method: http.MethodGet, cancelAfterWrite: true, want: true},
|
||||
{name: "open ended full range", method: http.MethodGet, rangeHeader: "bytes=0-", want: true},
|
||||
{name: "explicit full range", method: http.MethodGet, rangeHeader: "bytes=0-15", want: true},
|
||||
{name: "partial range", method: http.MethodGet, rangeHeader: "bytes=1-", want: false},
|
||||
{name: "head", method: http.MethodHead, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := newRollingDeadlineWriter(rr, time.Second, 0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
req := httptest.NewRequest(tt.method, "/segment.ts", nil).WithContext(ctx)
|
||||
req.Header.Set("Range", tt.rangeHeader)
|
||||
http.ServeContent(sw, req, "segment.ts", time.Time{}, strings.NewReader(body))
|
||||
if tt.cancelAfterWrite {
|
||||
cancel()
|
||||
}
|
||||
if got := sw.CompletedFullResponse(int64(len(body))); got != tt.want {
|
||||
t.Fatalf("CompletedFullResponse = %v, want %v (status=%d range=%q bytes=%d)",
|
||||
got, tt.want, sw.StatusCode(), rr.Header().Get("Content-Range"), sw.BytesWritten())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFromPreservesCompletion exercises the io.ReaderFrom path used by
|
||||
// http.ServeContent (sendfile) under a server WriteTimeout shorter than the
|
||||
// transfer, with a source large enough to require multiple bounded slices.
|
||||
|
||||
@@ -184,6 +184,7 @@ type PlaybackHandler struct {
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
TranscodeDir string
|
||||
SegmentRetentionSeconds func() int
|
||||
// tm is the shared transcode-session lifecycle (live map, reconstruct) — the
|
||||
// same type the native handler uses, so jellycompat gets the reconstruct cap
|
||||
// and node-affinity rule for free. The reconstruction recipe is carried in the
|
||||
@@ -268,36 +269,40 @@ func NewPlaybackHandler(
|
||||
transcodeDir := filepath.Join(os.TempDir(), "silo-transcode")
|
||||
ffmpegPath := ""
|
||||
hwAccel := ""
|
||||
segmentRetentionSeconds := 600
|
||||
if cfg != nil {
|
||||
if cfg.Playback.TranscodeDir != "" {
|
||||
transcodeDir = cfg.Playback.TranscodeDir
|
||||
}
|
||||
ffmpegPath = cfg.Playback.FFmpegPath
|
||||
hwAccel = cfg.Playback.HWAccel
|
||||
segmentRetentionSeconds = cfg.Playback.SegmentRetentionSeconds
|
||||
}
|
||||
|
||||
h := &PlaybackHandler{
|
||||
cfg: cfg,
|
||||
content: content,
|
||||
codec: codec,
|
||||
deviceProfiles: deviceProfiles,
|
||||
playbackStore: playbackStore,
|
||||
sessionMgr: sessionMgr,
|
||||
fileResolver: fileResolver,
|
||||
storeProvider: storeProvider,
|
||||
FFmpegPath: ffmpegPath,
|
||||
HWAccel: hwAccel,
|
||||
TranscodeDir: transcodeDir,
|
||||
tm: playback.NewTranscodeManager(),
|
||||
cfg: cfg,
|
||||
content: content,
|
||||
codec: codec,
|
||||
deviceProfiles: deviceProfiles,
|
||||
playbackStore: playbackStore,
|
||||
sessionMgr: sessionMgr,
|
||||
fileResolver: fileResolver,
|
||||
storeProvider: storeProvider,
|
||||
FFmpegPath: ffmpegPath,
|
||||
HWAccel: hwAccel,
|
||||
TranscodeDir: transcodeDir,
|
||||
SegmentRetentionSeconds: func() int { return segmentRetentionSeconds },
|
||||
tm: playback.NewTranscodeManager(),
|
||||
}
|
||||
// Wire the shared transcode manager with closures so it reads the handler's
|
||||
// (late-set) JWTSecret lazily, matching the native handler.
|
||||
h.tm.JWTSecretFn = func() string { return h.JWTSecret }
|
||||
h.tm.Config = func() playback.TranscodeRuntimeConfig {
|
||||
return playback.TranscodeRuntimeConfig{
|
||||
TranscodeDir: h.TranscodeDir,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
TranscodeDir: h.TranscodeDir,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
SegmentRetentionSeconds: h.segmentRetentionSeconds(),
|
||||
}
|
||||
}
|
||||
if reg, ok := sessionMgr.(interface {
|
||||
@@ -337,6 +342,13 @@ func NewPlaybackHandler(
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) segmentRetentionSeconds() int {
|
||||
if h.SegmentRetentionSeconds != nil {
|
||||
return h.SegmentRetentionSeconds()
|
||||
}
|
||||
return 600
|
||||
}
|
||||
|
||||
// CleanupOrphanedTranscodes removes stale per-session transcode dirs, sparing
|
||||
// those whose recipe card still exists. Delegates to the shared manager.
|
||||
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
@@ -370,6 +382,7 @@ func (h *PlaybackHandler) buildProxyRedirectURL(
|
||||
PlayMethod: method,
|
||||
TranscodeAudio: source.TranscodeAudio,
|
||||
AudioTrackIndex: audioTrackIndex,
|
||||
AudioOnly: file.IsAudioOnly(),
|
||||
TranscodeNode: transcodeNodeURL,
|
||||
DVProfile: file.PrimaryDVProfile(),
|
||||
}
|
||||
@@ -438,18 +451,22 @@ func (h *PlaybackHandler) startRemoteTranscode(
|
||||
if initialSeekSeconds > 0 && segmentDuration > 0 {
|
||||
startSegmentNumber = int(initialSeekSeconds / float64(segmentDuration))
|
||||
}
|
||||
sourceVideoCodec, sourceVideoProfile, sourceVideoBitDepth := playback.SourceVideoTranscodeFacts(file)
|
||||
|
||||
reqBody := transcodenode.TranscodeStartRequest{
|
||||
SessionID: upstreamSessionID,
|
||||
InputPath: file.FilePath,
|
||||
SeekSeconds: initialSeekSeconds,
|
||||
StartSegmentNumber: startSegmentNumber,
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: segmentDuration,
|
||||
HWAccel: h.HWAccel,
|
||||
AudioTrackIndex: compatAudioTrackIndexOrDefault(source),
|
||||
TotalDuration: float64(source.Version.Duration),
|
||||
SessionID: upstreamSessionID,
|
||||
InputPath: file.FilePath,
|
||||
SourceVideoCodec: sourceVideoCodec,
|
||||
SourceVideoProfile: sourceVideoProfile,
|
||||
SourceVideoBitDepth: sourceVideoBitDepth,
|
||||
SeekSeconds: initialSeekSeconds,
|
||||
StartSegmentNumber: startSegmentNumber,
|
||||
TargetCodecVideo: compatTargetVideoCodec,
|
||||
TargetCodecAudio: compatTargetAudioCodec,
|
||||
SegmentDuration: segmentDuration,
|
||||
HWAccel: h.HWAccel,
|
||||
AudioTrackIndex: compatAudioTrackIndexOrDefault(source),
|
||||
TotalDuration: float64(source.Version.Duration),
|
||||
}
|
||||
if source.TranscodeAudio {
|
||||
reqBody.TargetCodecVideo = "copy"
|
||||
@@ -486,16 +503,19 @@ func (h *PlaybackHandler) startRemoteTranscode(
|
||||
// token of their own, so without a persisted recipe a node or central restart
|
||||
// cannot rebuild ffmpeg and segment serves 404.
|
||||
opts := playback.TranscodeOpts{
|
||||
SessionID: upstreamSessionID,
|
||||
InputPath: reqBody.InputPath,
|
||||
SeekSeconds: reqBody.SeekSeconds,
|
||||
StartSegmentNumber: reqBody.StartSegmentNumber,
|
||||
TargetCodecVideo: reqBody.TargetCodecVideo,
|
||||
TargetCodecAudio: reqBody.TargetCodecAudio,
|
||||
SegmentDuration: reqBody.SegmentDuration,
|
||||
HWAccel: reqBody.HWAccel,
|
||||
AudioTrackIndex: reqBody.AudioTrackIndex,
|
||||
TotalDuration: reqBody.TotalDuration,
|
||||
SessionID: upstreamSessionID,
|
||||
InputPath: reqBody.InputPath,
|
||||
SourceVideoCodec: reqBody.SourceVideoCodec,
|
||||
SourceVideoProfile: reqBody.SourceVideoProfile,
|
||||
SourceVideoBitDepth: reqBody.SourceVideoBitDepth,
|
||||
SeekSeconds: reqBody.SeekSeconds,
|
||||
StartSegmentNumber: reqBody.StartSegmentNumber,
|
||||
TargetCodecVideo: reqBody.TargetCodecVideo,
|
||||
TargetCodecAudio: reqBody.TargetCodecAudio,
|
||||
SegmentDuration: reqBody.SegmentDuration,
|
||||
HWAccel: reqBody.HWAccel,
|
||||
AudioTrackIndex: reqBody.AudioTrackIndex,
|
||||
TotalDuration: reqBody.TotalDuration,
|
||||
}
|
||||
if source.TranscodeAudio {
|
||||
opts.TargetCodecVideo = "copy"
|
||||
@@ -625,10 +645,10 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid playback request")
|
||||
return
|
||||
}
|
||||
if req.UserID != "" && req.UserID != session.PseudoUserID.String() {
|
||||
writeError(w, http.StatusNotFound, "NotFound", "User not found")
|
||||
return
|
||||
}
|
||||
// PlaybackInfo is authorized by the token-derived session. Some clients
|
||||
// retain a previous UserId in their request body while moving to the next
|
||||
// item; that advisory value must not turn an otherwise authorized playback
|
||||
// negotiation into a 404.
|
||||
|
||||
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
|
||||
if err != nil {
|
||||
@@ -646,6 +666,24 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
sourceDTOs := make([]mediaSourceDTO, 0, len(detail.Versions))
|
||||
|
||||
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
|
||||
if req.MediaSourceID != "" {
|
||||
matched := false
|
||||
for _, version := range detail.Versions {
|
||||
candidate := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
if mediaSourceIDsEqual(candidate.ID, req.MediaSourceID) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
// Continue Watching and autoplay clients can carry the previous
|
||||
// episode's MediaSourceId into the next PlaybackInfo request. The
|
||||
// authenticated item route remains authoritative; fall back to its
|
||||
// available versions instead of returning a misleading 404.
|
||||
slog.InfoContext(r.Context(), "jellycompat ignored stale playback media source", "component", "jellycompat")
|
||||
req.MediaSourceID = ""
|
||||
}
|
||||
}
|
||||
for _, version := range detail.Versions {
|
||||
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
||||
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
|
||||
@@ -722,6 +760,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
firstMediaBrowserAuthorizationValue(r, "DeviceId"),
|
||||
newCaseInsensitiveQuery(r.URL.Query()).Get("DeviceId"),
|
||||
)
|
||||
clientDeviceID = stripCompatNUL(clientDeviceID)
|
||||
h.playbackStore.PutNegotiated(PlaybackSession{
|
||||
ID: playSessionID,
|
||||
CompatToken: session.Token,
|
||||
@@ -739,6 +778,14 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ
|
||||
})
|
||||
}
|
||||
|
||||
// stripCompatNUL removes the only code point PostgreSQL rejects in JSONB text.
|
||||
// DeviceId and static PlaySessionId come from clients, then become fields in a
|
||||
// durable PlaybackSession. Without this boundary check one malformed value
|
||||
// leaves the session cache-only; its first revalidation drops playback.
|
||||
func stripCompatNUL(value string) string {
|
||||
return strings.ReplaceAll(value, "\x00", "")
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) parsePlaybackRequest(r *http.Request, compatToken string) (playbackInfoRequest, DeviceProfile, error) {
|
||||
var req playbackInfoRequest
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
)
|
||||
|
||||
func TestPlaybackHandlerReadsLiveSegmentRetention(t *testing.T) {
|
||||
cfg := &config.Config{Playback: config.PlaybackConfig{SegmentRetentionSeconds: 300}}
|
||||
handler := NewPlaybackHandler(cfg, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if got := handler.tm.Config().SegmentRetentionSeconds; got != 300 {
|
||||
t.Fatalf("configured segment retention = %d, want 300", got)
|
||||
}
|
||||
liveRetention := 300
|
||||
handler.SegmentRetentionSeconds = func() int { return liveRetention }
|
||||
liveRetention = 120
|
||||
if got := handler.tm.Config().SegmentRetentionSeconds; got != 120 {
|
||||
t.Fatalf("reloaded segment retention = %d, want 120", got)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,57 @@ func TestHandlePlaybackInfoReplacesDuplicateJellyfinWebNegotiation(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlaybackInfoStripsNULFromClientDeviceID(t *testing.T) {
|
||||
handler, routeID := newSubtitleSelectionHandler(t)
|
||||
result := postPlaybackInfoForDevice(t, handler, routeID, "vidhub\x00device")
|
||||
|
||||
store := handler.playbackStore.(*PlaybackSessionStore)
|
||||
stored, ok := store.Get(result.PlaySessionID)
|
||||
if !ok {
|
||||
t.Fatal("negotiated play session was not stored")
|
||||
}
|
||||
if stored.ClientDeviceID != "vidhubdevice" {
|
||||
t.Fatalf("ClientDeviceID = %q, want NUL-free identifier", stored.ClientDeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlaybackInfoIgnoresStaleRequestUserID(t *testing.T) {
|
||||
handler, routeID := newSubtitleSelectionHandler(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/Items/"+routeID+"/PlaybackInfo", strings.NewReader(`{"UserId":"stale-client-user"}`))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", routeID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.HandlePlaybackInfo(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlaybackInfoFallsBackFromStaleMediaSourceID(t *testing.T) {
|
||||
handler, routeID := newSubtitleSelectionHandler(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/Items/"+routeID+"/PlaybackInfo", strings.NewReader(`{"MediaSourceId":"previous-episode-source"}`))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", routeID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.HandlePlaybackInfo(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response playbackInfoResponseDTO
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if len(response.MediaSources) == 0 {
|
||||
t.Fatal("stale media source fallback returned no playable sources")
|
||||
}
|
||||
}
|
||||
|
||||
func postPlaybackInfoForDevice(
|
||||
t *testing.T,
|
||||
handler *PlaybackHandler,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -39,6 +40,36 @@ var (
|
||||
_ CompatPlaybackStore = (*DurableCompatPlaybackStore)(nil)
|
||||
)
|
||||
|
||||
var jsonNULCodePoint = []byte(`\u0000`)
|
||||
|
||||
// marshalPlaybackSession removes NUL code points from every nested string in
|
||||
// the JSON document. PostgreSQL JSONB rejects U+0000 even when Go's encoder
|
||||
// represents it as \u0000. Escaped literal text (\\u0000) remains unchanged.
|
||||
func marshalPlaybackSession(session PlaybackSession) ([]byte, error) {
|
||||
data, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cleaned := make([]byte, 0, len(data))
|
||||
for i := 0; i < len(data); i++ {
|
||||
if data[i] != '\\' {
|
||||
cleaned = append(cleaned, data[i])
|
||||
continue
|
||||
}
|
||||
if i+1 < len(data) && data[i+1] == '\\' {
|
||||
cleaned = append(cleaned, data[i], data[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+len(jsonNULCodePoint) <= len(data) && bytes.Equal(data[i:i+len(jsonNULCodePoint)], jsonNULCodePoint) {
|
||||
i += len(jsonNULCodePoint) - 1
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, data[i])
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// DurableCompatPlaybackStore is a CompatPlaybackStore that persists compat
|
||||
// playback sessions to Postgres so the PlaySessionId -> upstream-session mapping
|
||||
// (and the negotiated media sources) survives a server restart. The in-memory
|
||||
@@ -220,7 +251,7 @@ func (d *DurableCompatPlaybackStore) replaceUnstartedNegotiation(
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
data, err := json.Marshal(session)
|
||||
data, err := marshalPlaybackSession(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -459,7 +490,7 @@ func (d *DurableCompatPlaybackStore) stageTerminalDB(
|
||||
}
|
||||
session.Terminal = true
|
||||
session.UpdatedAt = d.now()
|
||||
data, err := json.Marshal(session)
|
||||
data, err := marshalPlaybackSession(session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -806,7 +837,7 @@ func (d *DurableCompatPlaybackStore) updateDB(id string, fn func(*PlaybackSessio
|
||||
}
|
||||
session.UpdatedAt = d.now()
|
||||
|
||||
data, err := json.Marshal(session)
|
||||
data, err := marshalPlaybackSession(session)
|
||||
if err != nil {
|
||||
slog.Warn("marshal compat playback session for update failed", "error", err, "play_session_id", id)
|
||||
return nil, err
|
||||
@@ -1021,7 +1052,7 @@ func (d *DurableCompatPlaybackStore) upsert(ctx context.Context, session Playbac
|
||||
if d.pool == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(session)
|
||||
data, err := marshalPlaybackSession(session)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "marshal compat playback session failed", "component", "jellycompat", "error", err, "play_session_id", session.ID)
|
||||
return err
|
||||
@@ -1040,7 +1071,7 @@ func (d *DurableCompatPlaybackStore) upsert(ctx context.Context, session Playbac
|
||||
// insertIfAbsent repairs a session whose initial upsert failed without reviving
|
||||
// a row that another process created or terminalized in the meantime.
|
||||
func (d *DurableCompatPlaybackStore) insertIfAbsent(session *PlaybackSession) (bool, error) {
|
||||
data, err := json.Marshal(session)
|
||||
data, err := marshalPlaybackSession(*session)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -2,15 +2,43 @@ package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestMarshalPlaybackSessionStripsNestedNUL(t *testing.T) {
|
||||
wantLiteral := `literal\u0000text`
|
||||
data, err := marshalPlaybackSession(PlaybackSession{
|
||||
ClientDeviceID: "device\x00id",
|
||||
MediaSources: []PlaybackMediaSource{{
|
||||
Version: catalog.FileVersion{
|
||||
FileName: "episode\x00name.mkv",
|
||||
EditionRaw: wantLiteral,
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal playback session: %v", err)
|
||||
}
|
||||
var got PlaybackSession
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal sanitized playback session: %v", err)
|
||||
}
|
||||
if got.ClientDeviceID != "deviceid" || got.MediaSources[0].Version.FileName != "episodename.mkv" {
|
||||
t.Fatalf("nested NUL was retained: %#v", got)
|
||||
}
|
||||
if got.MediaSources[0].Version.EditionRaw != wantLiteral {
|
||||
t.Fatalf("literal escape changed: %q", got.MediaSources[0].Version.EditionRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func newCompatTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
||||
|
||||
@@ -103,6 +103,13 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
|
||||
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
|
||||
startupSegmentRetention := playbackHandler.SegmentRetentionSeconds
|
||||
playbackHandler.SegmentRetentionSeconds = func() int {
|
||||
if cfg := deps.CurrentConfig(); cfg != nil {
|
||||
return cfg.Playback.SegmentRetentionSeconds
|
||||
}
|
||||
return startupSegmentRetention()
|
||||
}
|
||||
if deps.DB != nil {
|
||||
playbackHandler.profileStaler = recommendations.NewRepo(deps.DB)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user