Compare commits
87
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 | ||
|
|
fa1d7ba2b4 | ||
|
|
40a9de7f26 | ||
|
|
31a26b2554 | ||
|
|
8bde6f1156 | ||
|
|
567cdd1a15 | ||
|
|
3bdfc58512 | ||
|
|
4cd51d8f6c | ||
|
|
b0418a4430 | ||
|
|
c1cac4ece9 | ||
|
|
2dedf3ff26 | ||
|
|
97669d52d4 | ||
|
|
7aee29be7b | ||
|
|
c6383aa4dd | ||
|
|
a28960819d | ||
|
|
9aab2ead57 | ||
|
|
90f5d117c8 | ||
|
|
73488d1bfa | ||
|
|
d70b291bb8 | ||
|
|
b1b093007c | ||
|
|
dc4b9a0909 | ||
|
|
4504d7c68e | ||
|
|
4b665add64 | ||
|
|
bcea996172 | ||
|
|
616d262e99 | ||
|
|
251282186e | ||
|
|
a7601028d9 | ||
|
|
9281ae61d9 | ||
|
|
f894b150e1 | ||
|
|
ad122c38c2 | ||
|
|
bfa5baec86 | ||
|
|
7907e0c28c | ||
|
|
824d7bdf43 | ||
|
|
9f4f9a3bd8 | ||
|
|
08035f3806 | ||
|
|
f0bc170113 | ||
|
|
7ab393fc3a | ||
|
|
c52ca7dd7a | ||
|
|
54cfc48ce3 | ||
|
|
4eab955ec1 | ||
|
|
95edf19389 | ||
|
|
63e18cf37f | ||
|
|
5d6f6323d5 | ||
|
|
315f843fa5 | ||
|
|
271a2e1741 | ||
|
|
54e184df85 | ||
|
|
1a784a1cdf | ||
|
|
f235524365 | ||
|
|
8dea4b9056 | ||
|
|
baa33768d6 | ||
|
|
172beb99ef | ||
|
|
148c9291c5 | ||
|
|
99d205676f | ||
|
|
02203d9e40 | ||
|
|
10394b0a05 | ||
|
|
482efdf140 | ||
|
|
9e7fe79590 | ||
|
|
355508f6e7 |
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
GOPRIVATE: github.com/Silo-Server/*
|
||||
GONOSUMDB: github.com/Silo-Server/*
|
||||
# Pinned so a lint gate cannot change its mind between two runs of the same
|
||||
# commit. Built from source below rather than downloaded: a released binary
|
||||
# refuses to run against a Go version newer than the one it was built with,
|
||||
# and go.mod tracks Go closely enough that this repo is regularly ahead.
|
||||
GOLANGCI_LINT_VERSION: v2.12.2
|
||||
|
||||
# The default token is read-write. Nothing here needs to write, and a token
|
||||
# that cannot push is one fewer thing a compromised dependency can reach.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
go:
|
||||
name: Go
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
# golangci-lint needs the merge base to tell this branch's lines from
|
||||
# the ones it inherited.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# github.com/h2non/bimg binds libvips through cgo and pkg-config, so
|
||||
# nothing under ./... compiles without the headers. The Dockerfile
|
||||
# installs the same package in its build stage.
|
||||
- name: Install libvips
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libvips-dev
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# cmd/silo embeds the built frontend, so nothing under ./... compiles
|
||||
# without web/dist. The Go jobs never serve it, so a placeholder is
|
||||
# enough; the Docker workflow builds the real bundle.
|
||||
- name: Stub the embedded frontend bundle
|
||||
run: make embed-stub
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "::error::gofmt is required on:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
# Scoped to the lines this branch touched. The repo does not pass a full
|
||||
# golangci-lint run today — there are a few hundred pre-existing findings,
|
||||
# which is why the Go half of `make lint` has never been enforced — and
|
||||
# blocking every PR on a cleanup nobody has done would just get the gate
|
||||
# removed again. New and changed lines have to be clean, so the count only
|
||||
# falls from here.
|
||||
- name: Install golangci-lint
|
||||
run: go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}"
|
||||
|
||||
- name: Lint changed lines
|
||||
env:
|
||||
# A PR carries its target branch; a push to main compares against
|
||||
# main's own history, which leaves the merge base at HEAD and lints
|
||||
# nothing new. Read through the environment rather than interpolated
|
||||
# into the script.
|
||||
BASE_REF: ${{ github.base_ref || github.event.repository.default_branch }}
|
||||
run: |
|
||||
git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
|
||||
golangci-lint run --new-from-merge-base="origin/${BASE_REF}" ./...
|
||||
|
||||
# A manifest change that does not regenerate leaves every client reading
|
||||
# stale keys, which the contract exists to prevent.
|
||||
- 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
|
||||
# defaults. Without this job those tests exist but never run.
|
||||
- name: Test
|
||||
run: make test-go
|
||||
|
||||
web:
|
||||
name: Web
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The pnpm version comes from web/package.json's packageManager field —
|
||||
# there is no package.json at the repo root, and `defaults.run` does not
|
||||
# apply to an action's own inputs.
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: web/package.json
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Format check
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Typecheck and build
|
||||
run: pnpm run build
|
||||
|
||||
# Includes the appearance-cache ownership tests, which are the regression
|
||||
# guard for cross-account leaks in the localStorage warm start.
|
||||
- name: Test
|
||||
working-directory: .
|
||||
run: make test-web
|
||||
|
||||
# The generated web binding is compared after prettier, so this half of
|
||||
# the bindings check lives here rather than in the Go job, which has no
|
||||
# pnpm. It needs Go to run the generator.
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Verify the generated web settings binding is current
|
||||
working-directory: .
|
||||
run: make verify-settings-bindings-web
|
||||
|
||||
docs:
|
||||
name: Docs hygiene
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify no local paths leaked into committed docs
|
||||
run: make verify-local-paths
|
||||
@@ -81,3 +81,5 @@ docker-compose.override.yml
|
||||
docker-compose.local.yml
|
||||
.playwright-cli/
|
||||
output/
|
||||
/settingsgen
|
||||
/playbackfixtures
|
||||
|
||||
+19
-14
@@ -59,19 +59,24 @@ linters:
|
||||
misspell:
|
||||
locale: US
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- web
|
||||
- migrations
|
||||
exclusions:
|
||||
# Anchored regexes, not directory names: `paths` matches anywhere in the
|
||||
# path, so a bare `web` also excluded internal/jellycompat/web_component.go,
|
||||
# internal/webhooksync/, internal/notifications/webhook*.go and every other
|
||||
# non-test file with "web" in its name — 14 files that were being linted
|
||||
# before.
|
||||
paths:
|
||||
- ^web/
|
||||
- ^migrations/
|
||||
|
||||
exclude-rules:
|
||||
# Allow repeated strings in test files
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- goconst
|
||||
rules:
|
||||
# Allow repeated strings in test files
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- goconst
|
||||
|
||||
# Allow unchecked errors in test cleanup/defer
|
||||
- path: _test\.go
|
||||
text: "Error return value is not checked"
|
||||
linters:
|
||||
- errcheck
|
||||
# Allow unchecked errors in test cleanup/defer
|
||||
- path: _test\.go
|
||||
text: "Error return value is not checked"
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
@@ -17,6 +17,17 @@ Put new code in the package that owns the behavior rather than in a catch-all he
|
||||
extracting shared logic over duplicating it, and prefer changing existing code over bolting a
|
||||
local workaround onto it.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Most of this codebase's scope is open; a short list is permanently closed. Read
|
||||
[docs/non-goals.md](docs/non-goals.md) before proposing or implementing in those areas.
|
||||
|
||||
**Live TV, OTA/DVB tuners, IPTV, EPG/XMLTV, DVR, and `.strm` remote-URL shortcuts will not be
|
||||
accepted** — not in core, not as a plugin, not in a client. The first-party clients ship on the
|
||||
Apple and Google stores, and a server that plays arbitrary remote stream URLs puts the whole
|
||||
client suite at risk. This is settled product direction, not a design problem to solve; do not
|
||||
write code for it, and say so plainly if asked.
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Migrations.** New DB changes are Goose SQL migrations in `migrations/sql/`, created with
|
||||
@@ -60,18 +71,30 @@ SDK, in the catalog, or in a specific plugin repo.
|
||||
|
||||
## Building and verifying
|
||||
|
||||
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make migrate-status` /
|
||||
`make migrate-up` — read the `Makefile` for the rest. Local services:
|
||||
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make test`, `make migrate-status`
|
||||
/ `make migrate-up` — read the `Makefile` for the rest. Local services:
|
||||
`docker compose up -d postgres redis`.
|
||||
|
||||
`make test-go` runs the whole Go suite. A Go test that cannot pass yet carries a `t.Skip` and the
|
||||
reason in its own source, not an entry in a Makefile variable. `make test-web` still skips the
|
||||
files in `WEBTEST_KNOWN_FAILURES`, which predate the CI gate; that list may only shrink — delete an
|
||||
entry together with its fix, and never add to it to make a new change pass.
|
||||
|
||||
Before opening a merge request:
|
||||
|
||||
```bash
|
||||
make lint
|
||||
make test
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
`.github/workflows/ci.yml` runs these on every pull request, with one difference worth knowing:
|
||||
`make lint` runs `golangci-lint` over the whole tree, while CI runs it with `--new-from-merge-base`
|
||||
so only the lines a branch touched have to be clean. The repo does not pass a full run today, so
|
||||
expect local output to include findings that are not yours and that CI will not fail on. Do not add
|
||||
to them.
|
||||
|
||||
Go stays `gofmt`/`goimports` clean; the frontend follows `web/.prettierrc`.
|
||||
|
||||
## Skills
|
||||
@@ -95,6 +118,11 @@ Additive-only within `/api/v1`:
|
||||
- New features expose capability endpoints for feature detection rather than relying on version
|
||||
sniffing. Contract strategy and tooling: issue #135.
|
||||
|
||||
Treat this as binding. The one exception: `/api/v1` is not locked yet, so a removal taken before
|
||||
lock is in scope — but only when it is recorded in the pre-lock removals table in
|
||||
[docs/architecture/v1-scope.md](docs/architecture/v1-scope.md) and ships before the lock. Assume
|
||||
any removal not listed there is a mistake.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Conventional Commit subjects (`feat(playback): add realtime session hub`). One concern per PR.
|
||||
|
||||
+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
|
||||
|
||||
@@ -32,6 +32,10 @@ COPY --from=frontend_dist / web/dist
|
||||
COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY migrations/ migrations/
|
||||
# The settings contract is a Go package (contracts/settings/v1) that embeds the
|
||||
# manifest, so the binary carries the exact bytes it was built from. It lives
|
||||
# outside internal/ because clients vendor these files.
|
||||
COPY contracts/ contracts/
|
||||
ARG BUILD_REVISION
|
||||
ARG BUILD_DIRTY=false
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
|
||||
@@ -29,6 +29,9 @@ COPY --from=frontend /app/web/dist web/dist
|
||||
COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY migrations/ migrations/
|
||||
# See Dockerfile: the settings contract is an embedded Go package outside
|
||||
# internal/, so the build fails without it.
|
||||
COPY contracts/ contracts/
|
||||
|
||||
# Stage 3: Build Go binary for dev using a local plugin SDK checkout passed via
|
||||
# BuildKit named context `silo_plugin_sdk`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
|
||||
.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)/..))
|
||||
@@ -54,6 +54,126 @@ lint:
|
||||
golangci-lint run
|
||||
cd web && pnpm run lint
|
||||
|
||||
# Frontend test files that fail on main today. This list is shrink-only: delete
|
||||
# an entry along with its fix, and never extend it to land a change. The Go
|
||||
# suite has no equivalent — a Go test that cannot pass yet carries a t.Skip and
|
||||
# its reason in the source, where whoever reads the test finds it.
|
||||
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/setup-wizard/steps/ServerStorageStep.test.tsx \
|
||||
--exclude src/player/hooks/useASSSubtitles.test.tsx
|
||||
|
||||
# The Go binary embeds the built frontend, so every Go build and test needs
|
||||
# web/dist to exist. Tests never serve it, so a placeholder is enough; `make
|
||||
# build` still builds the real bundle.
|
||||
embed-stub:
|
||||
@mkdir -p web/dist
|
||||
@[ -e web/dist/index.html ] || printf '<!doctype html>\n' > web/dist/index.html
|
||||
|
||||
# Run the Go and frontend test suites.
|
||||
test: test-go test-web
|
||||
|
||||
test-go: embed-stub
|
||||
go test ./...
|
||||
|
||||
test-web:
|
||||
cd web && pnpm exec vitest run $(WEBTEST_KNOWN_FAILURES)
|
||||
|
||||
# Regenerate the settings-contract bindings for every language.
|
||||
#
|
||||
# The client repos are siblings of this one (see CLAUDE.md); a missing checkout
|
||||
# is skipped rather than failing, so a server-only developer can still run this.
|
||||
#
|
||||
# The conformance fixture (contracts/settings/v1/conformance.json) travels with
|
||||
# the bindings: the vendored copy in web/src/lib is what the web runner reads.
|
||||
# The Kotlin and Swift copies land together with their runners in the client
|
||||
# repos, which will pick their own test-resource paths.
|
||||
SILO_ANDROID_DIR ?= $(abspath ../silo-android)
|
||||
SILO_APPLE_DIR ?= $(abspath ../silo-apple)
|
||||
|
||||
settings-bindings:
|
||||
@mkdir -p internal/settingskeys
|
||||
go run ./cmd/settingsgen -lang go -out internal/settingskeys/keys.go
|
||||
gofmt -w internal/settingskeys/keys.go
|
||||
go run ./cmd/settingsgen -lang ts -out web/src/lib/settingsContract.ts
|
||||
@cd web && pnpm exec prettier --write src/lib/settingsContract.ts >/dev/null
|
||||
cp contracts/settings/v1/conformance.json web/src/lib/settingsConformance.json
|
||||
@if [ -d "$(SILO_ANDROID_DIR)" ]; then \
|
||||
go run ./cmd/settingsgen -lang kotlin \
|
||||
-out "$(SILO_ANDROID_DIR)/shared/src/commonMain/kotlin/org/siloserver/silo/model/settings/SettingKeys.kt"; \
|
||||
echo "wrote Kotlin bindings to $(SILO_ANDROID_DIR)"; \
|
||||
else \
|
||||
echo "skipping Kotlin: $(SILO_ANDROID_DIR) not checked out"; \
|
||||
fi
|
||||
@if [ -d "$(SILO_APPLE_DIR)" ]; then \
|
||||
go run ./cmd/settingsgen -lang swift \
|
||||
-out "$(SILO_APPLE_DIR)/iosApp/iosApp/Networking/SettingKeys.generated.swift"; \
|
||||
echo "wrote Swift bindings to $(SILO_APPLE_DIR)"; \
|
||||
else \
|
||||
echo "skipping Swift: $(SILO_APPLE_DIR) not checked out"; \
|
||||
fi
|
||||
|
||||
# Fail when the committed bindings disagree with the manifest, so a manifest
|
||||
# change cannot merge without regenerating what every client reads.
|
||||
#
|
||||
# Split in two because the generated TypeScript is compared after prettier, and
|
||||
# only the Web CI job has pnpm: the Go job runs this target, the Web job runs
|
||||
# verify-settings-bindings-web. Locally, `verify-settings-bindings-all` is both.
|
||||
verify-settings-bindings:
|
||||
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
|
||||
go run ./cmd/settingsgen -lang go | gofmt > "$$CHECK_DIR/keys.go" && \
|
||||
diff -u internal/settingskeys/keys.go "$$CHECK_DIR/keys.go" \
|
||||
|| { echo "::error::internal/settingskeys/keys.go is stale; run make settings-bindings"; exit 1; }
|
||||
@diff -u web/src/lib/settingsConformance.json contracts/settings/v1/conformance.json \
|
||||
|| { echo "::error::web/src/lib/settingsConformance.json is stale; run make settings-bindings"; exit 1; }
|
||||
@echo "settings bindings are current"
|
||||
|
||||
# The half that needs pnpm: regenerate the web binding, format it the way the
|
||||
# bindings target does, and compare. Without this a manifest change could merge
|
||||
# with a stale settingsContract.ts, which is what every web control renders from.
|
||||
verify-settings-bindings-web:
|
||||
@CHECK_DIR=$$(mktemp -d) && trap 'rm -rf "$$CHECK_DIR"' EXIT && \
|
||||
go run ./cmd/settingsgen -lang ts -out "$$CHECK_DIR/settingsContract.ts" && \
|
||||
cd web && pnpm exec prettier --log-level silent --config .prettierrc \
|
||||
--write "$$CHECK_DIR/settingsContract.ts" && cd .. && \
|
||||
diff -u web/src/lib/settingsContract.ts "$$CHECK_DIR/settingsContract.ts" \
|
||||
|| { echo "::error::web/src/lib/settingsContract.ts is stale; run make settings-bindings"; exit 1; }
|
||||
@echo "web settings binding is current"
|
||||
|
||||
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
|
||||
@@ -71,6 +191,23 @@ migrate-validate:
|
||||
migrate-status:
|
||||
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-status
|
||||
|
||||
# Roll back every migration newer than VERSION (the version to KEEP).
|
||||
#
|
||||
# Not a routine operation: it discards data. It exists because some migrations
|
||||
# are Go rather than SQL — the settings backfill and the jellycompat
|
||||
# DisplayPreferences move — and those are registered in-process, so the goose
|
||||
# CLI above cannot see or reverse them.
|
||||
#
|
||||
# This is a RANGE, not a list: everything newer than VERSION comes off, including
|
||||
# migrations belonging to other features that happen to sort in between. Check
|
||||
# `make migrate-status` and read the down of each one you are about to revert.
|
||||
# Take a backup first regardless; the per-user SQLite stores have no down path.
|
||||
#
|
||||
# Usage: make migrate-down-to VERSION=<timestamp from migrate-status>
|
||||
migrate-down-to:
|
||||
@if [ -z "$(VERSION)" ]; then echo "usage: make migrate-down-to VERSION=<timestamp from make migrate-status>"; exit 1; fi
|
||||
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-down-to "$(VERSION)"
|
||||
|
||||
# Apply pending Goose migrations through Silo's bootstrapping runner.
|
||||
migrate-up:
|
||||
go run ./cmd/silo/ --env "$(ENV_FILE)" --migrate-only
|
||||
|
||||
@@ -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
|
||||
@@ -212,6 +247,10 @@ If you prefer running Silo without Docker:
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Client implementers can use the [Canonical Settings API guide](docs/settings-api.md)
|
||||
for contract discovery, contextual headers, remote scopes, effective reads, and
|
||||
the admin projection.
|
||||
|
||||
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
|
||||
|
||||
Please include:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
// Command settingsgen emits typed bindings for the settings contract.
|
||||
//
|
||||
// One generator for every language rather than one per repo: the whole point of
|
||||
// the contract is that four codebases agree on keys, types, scopes and
|
||||
// defaults, and four independently-written generators would be four chances to
|
||||
// disagree. Each client repo vendors the manifest and runs this to regenerate.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// settingsgen -lang go -out internal/settingskeys/keys.go
|
||||
// settingsgen -lang ts -out web/src/lib/settingsContract.ts
|
||||
// settingsgen -lang kotlin -out <path> -package org.siloserver.silo.model.settings
|
||||
// settingsgen -lang swift -out <path>
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
)
|
||||
|
||||
func main() {
|
||||
lang := flag.String("lang", "", "go, ts, kotlin or swift")
|
||||
out := flag.String("out", "", "file to write (default stdout)")
|
||||
pkg := flag.String("package", "", "package or namespace for the generated code")
|
||||
flag.Parse()
|
||||
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
fail("loading contract: %v", err)
|
||||
}
|
||||
|
||||
var body []byte
|
||||
switch *lang {
|
||||
case "go":
|
||||
body, err = generateGo(contract, defaultString(*pkg, "settingskeys"))
|
||||
case "ts":
|
||||
body, err = generateTypeScript(contract)
|
||||
case "kotlin":
|
||||
body, err = generateKotlin(contract,
|
||||
defaultString(*pkg, "org.siloserver.silo.model.settings"))
|
||||
case "swift":
|
||||
body, err = generateSwift(contract)
|
||||
default:
|
||||
fail("unknown -lang %q: want go, ts, kotlin or swift", *lang)
|
||||
}
|
||||
if err != nil {
|
||||
fail("generating %s: %v", *lang, err)
|
||||
}
|
||||
|
||||
if *out == "" {
|
||||
_, _ = os.Stdout.Write(body)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(*out, body, 0o644); err != nil { //nolint:gosec // generated source
|
||||
fail("writing %s: %v", *out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "settingsgen: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func defaultString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// remoteAndLocal returns every definition, sorted by key so the output is
|
||||
// stable: a generator whose output depends on manifest authoring order would
|
||||
// produce spurious diffs on every unrelated manifest edit.
|
||||
func sortedDefinitions(contract *settingscontract.Manifest) []*settingscontract.Definition {
|
||||
defs := make([]*settingscontract.Definition, 0, len(contract.Definitions))
|
||||
for i := range contract.Definitions {
|
||||
defs = append(defs, &contract.Definitions[i])
|
||||
}
|
||||
sort.Slice(defs, func(i, j int) bool { return defs[i].Key < defs[j].Key })
|
||||
return defs
|
||||
}
|
||||
|
||||
// identifierFor turns a dotted key into a language identifier:
|
||||
// playback.subtitle_language becomes PlaybackSubtitleLanguage.
|
||||
func identifierFor(key string) string {
|
||||
var out strings.Builder
|
||||
for _, part := range strings.FieldsFunc(key, func(r rune) bool {
|
||||
return r == '.' || r == '_' || r == '-'
|
||||
}) {
|
||||
out.WriteString(strings.ToUpper(part[:1]))
|
||||
out.WriteString(part[1:])
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// screamingCase turns a dotted key into PLAYBACK_SUBTITLE_LANGUAGE.
|
||||
func screamingCase(key string) string {
|
||||
replaced := strings.NewReplacer(".", "_", "-", "_").Replace(key)
|
||||
return strings.ToUpper(replaced)
|
||||
}
|
||||
|
||||
const generatedHeader = `Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT.
|
||||
|
||||
Regenerate with: make settings-bindings
|
||||
|
||||
Every key, type, scope and default here comes from the manifest, so a client
|
||||
cannot drift from the server's contract by editing a constant. Adding a setting
|
||||
is a manifest change plus a regeneration, never a hand-written key.`
|
||||
|
||||
func generateGo(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
for _, line := range strings.Split(generatedHeader, "\n") {
|
||||
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
|
||||
fmt.Fprintf(&out, "// Revision is the manifest revision these bindings were generated from.\nconst Revision = %d\n\n",
|
||||
contract.Revision)
|
||||
|
||||
out.WriteString("// Setting keys, one constant per definition.\nconst (\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
fmt.Fprintf(&out, "\t// %s\n", def.Label)
|
||||
fmt.Fprintf(&out, "\t%s = %q\n", identifierFor(def.Key), def.Key)
|
||||
}
|
||||
out.WriteString(")\n\n")
|
||||
|
||||
out.WriteString("// Remote lists every key the server stores.\nvar Remote = []string{\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if def.IsRemote() {
|
||||
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
|
||||
}
|
||||
}
|
||||
out.WriteString("}\n\n")
|
||||
|
||||
out.WriteString("// ClientLocal lists keys the contract defines but the server never stores.\n")
|
||||
out.WriteString("var ClientLocal = []string{\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if !def.IsRemote() {
|
||||
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
|
||||
}
|
||||
}
|
||||
out.WriteString("}\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
out.WriteString("/**\n")
|
||||
for _, line := range strings.Split(generatedHeader, "\n") {
|
||||
out.WriteString(strings.TrimRight(" * "+line, " ") + "\n")
|
||||
}
|
||||
out.WriteString(" */\n\n")
|
||||
|
||||
fmt.Fprintf(&out, "export const SETTINGS_API_VERSION = %d;\n", contract.APIVersion)
|
||||
fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision)
|
||||
|
||||
out.WriteString("export interface SettingSuggestedOption {\n")
|
||||
out.WriteString(" value: string;\n")
|
||||
out.WriteString(" introducedIn: number;\n")
|
||||
out.WriteString("}\n\n")
|
||||
out.WriteString("export interface SettingOptionSet {\n")
|
||||
out.WriteString(" type: string;\n")
|
||||
out.WriteString(" options: readonly SettingSuggestedOption[];\n")
|
||||
out.WriteString("}\n\n")
|
||||
out.WriteString("export const SETTING_OPTION_SETS = {\n")
|
||||
for _, name := range sortedOptionSetNames(contract) {
|
||||
optionSet := contract.OptionSets[name]
|
||||
fmt.Fprintf(&out, " %s: {\n", name)
|
||||
fmt.Fprintf(&out, " type: %q,\n", optionSet.Type)
|
||||
out.WriteString(" options: [\n")
|
||||
for _, option := range optionSet.Options {
|
||||
fmt.Fprintf(&out, " { value: %q, introducedIn: %d },\n",
|
||||
option.Value, option.IntroducedIn)
|
||||
}
|
||||
out.WriteString(" ],\n")
|
||||
out.WriteString(" },\n")
|
||||
}
|
||||
out.WriteString("} as const satisfies Record<string, SettingOptionSet>;\n\n")
|
||||
out.WriteString("export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS;\n\n")
|
||||
|
||||
out.WriteString("export const SETTING_KEYS = {\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
fmt.Fprintf(&out, " /** %s */\n", def.Label)
|
||||
fmt.Fprintf(&out, " %s: %q,\n", screamingCase(def.Key), def.Key)
|
||||
}
|
||||
out.WriteString("} as const;\n\n")
|
||||
out.WriteString("export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];\n\n")
|
||||
|
||||
// The full definition table, so the UI can render controls from the
|
||||
// contract rather than a hand-kept parallel manifest.
|
||||
out.WriteString("export interface SettingDefinition {\n")
|
||||
out.WriteString(" key: SettingKey;\n")
|
||||
out.WriteString(" type: string;\n")
|
||||
out.WriteString(" nullable: boolean;\n")
|
||||
out.WriteString(" persistence: \"remote\" | \"client_local\";\n")
|
||||
out.WriteString(" /** The manifest revision this definition first appeared in. A client\n")
|
||||
out.WriteString(" * pinned to a newer contract than the server's advertised revision must\n")
|
||||
out.WriteString(" * hide definitions, scopes, enum members and widened bounds introduced\n")
|
||||
out.WriteString(" * after that revision — the server would reject them. */\n")
|
||||
out.WriteString(" introducedIn: number;\n")
|
||||
out.WriteString(" scopes: readonly string[];\n")
|
||||
out.WriteString(" /** Revision each scope became writable at, aligned with scopes. */\n")
|
||||
out.WriteString(" scopeIntroducedIn: readonly number[];\n")
|
||||
out.WriteString(" resolutionOrder: readonly string[];\n")
|
||||
out.WriteString(" defaultValue: unknown;\n")
|
||||
out.WriteString(" label: string;\n")
|
||||
out.WriteString(" description: string;\n")
|
||||
out.WriteString(" category: string;\n")
|
||||
out.WriteString(" control?: string;\n")
|
||||
out.WriteString(" unit?: string;\n")
|
||||
out.WriteString(" /** Advisory: platforms this setting is expected to matter on. Absent\n")
|
||||
out.WriteString(" * means everywhere. A client hides settings for platforms it is not,\n")
|
||||
out.WriteString(" * rather than disabling them without explanation. */\n")
|
||||
out.WriteString(" platforms?: readonly string[];\n")
|
||||
out.WriteString(" suggestedOptions?: SettingOptionSetId;\n")
|
||||
out.WriteString(" unsetLabel?: string;\n")
|
||||
out.WriteString(" values?: readonly { value: unknown; label: string; introducedIn: number }[];\n")
|
||||
out.WriteString(" /** Present on enums whose members are ranked, so a ceiling or floor has a direction. */\n")
|
||||
out.WriteString(" ordered?: boolean;\n")
|
||||
out.WriteString(" minimum?: number;\n")
|
||||
out.WriteString(" maximum?: number;\n")
|
||||
out.WriteString(" /** Bound history, oldest first, when a bound was widened after revision 1;\n")
|
||||
out.WriteString(" * a client filtering to an older server revision applies the newest entry\n")
|
||||
out.WriteString(" * whose introducedIn does not exceed it. */\n")
|
||||
out.WriteString(" minimumHistory?: readonly { value: number; introducedIn: number }[];\n")
|
||||
out.WriteString(" maximumHistory?: readonly { value: number; introducedIn: number }[];\n")
|
||||
out.WriteString(" step?: number;\n")
|
||||
out.WriteString(" /** The policy input that narrows this setting, when the manifest binds one. */\n")
|
||||
out.WriteString(" constrainedBy?: {\n")
|
||||
out.WriteString(" policyInput: string;\n")
|
||||
out.WriteString(" constraint: \"ceiling\" | \"floor\" | \"allowlist\" | \"locked\";\n")
|
||||
out.WriteString(" };\n")
|
||||
out.WriteString("}\n\n")
|
||||
|
||||
out.WriteString("export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
fmt.Fprintf(&out, " %q: {\n", def.Key)
|
||||
fmt.Fprintf(&out, " key: %q,\n", def.Key)
|
||||
fmt.Fprintf(&out, " type: %q,\n", def.ValueSchema.Type)
|
||||
fmt.Fprintf(&out, " nullable: %t,\n", def.ValueSchema.Nullable)
|
||||
fmt.Fprintf(&out, " persistence: %q,\n", def.Persistence)
|
||||
fmt.Fprintf(&out, " introducedIn: %d,\n", def.IntroducedIn)
|
||||
fmt.Fprintf(&out, " scopes: [%s],\n", quotedScopes(def))
|
||||
fmt.Fprintf(&out, " scopeIntroducedIn: [%s],\n", scopeRevisions(def))
|
||||
fmt.Fprintf(&out, " resolutionOrder: [%s],\n", quotedResolution(def))
|
||||
fmt.Fprintf(&out, " defaultValue: %s,\n", defaultLiteral(def))
|
||||
fmt.Fprintf(&out, " label: %s,\n", jsString(def.Label))
|
||||
fmt.Fprintf(&out, " description: %s,\n", jsString(def.Description))
|
||||
fmt.Fprintf(&out, " category: %q,\n", def.Category)
|
||||
if def.Control != "" {
|
||||
fmt.Fprintf(&out, " control: %q,\n", def.Control)
|
||||
}
|
||||
if def.Unit != "" {
|
||||
fmt.Fprintf(&out, " unit: %q,\n", def.Unit)
|
||||
}
|
||||
if len(def.Platforms) > 0 {
|
||||
fmt.Fprintf(&out, " platforms: [%s],\n", quotedStrings(def.Platforms))
|
||||
}
|
||||
if def.SuggestedOptions != "" {
|
||||
fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions)
|
||||
}
|
||||
if def.UnsetLabel != "" {
|
||||
fmt.Fprintf(&out, " unsetLabel: %s,\n", jsString(def.UnsetLabel))
|
||||
}
|
||||
if len(def.ValueSchema.Values) > 0 {
|
||||
out.WriteString(" values: [\n")
|
||||
for _, member := range def.ValueSchema.Values {
|
||||
encoded, err := json.Marshal(member.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(&out, " { value: %s, label: %s, introducedIn: %d },\n",
|
||||
encoded, jsString(member.Label), memberRevision(def, member))
|
||||
}
|
||||
out.WriteString(" ],\n")
|
||||
}
|
||||
if def.ValueSchema.Ordered {
|
||||
out.WriteString(" ordered: true,\n")
|
||||
}
|
||||
if minimum, ok := def.ValueSchema.Minimum.Current(); ok {
|
||||
fmt.Fprintf(&out, " minimum: %s,\n", trimFloat(minimum))
|
||||
if history := boundHistory(def, def.ValueSchema.Minimum); history != "" {
|
||||
fmt.Fprintf(&out, " minimumHistory: [%s],\n", history)
|
||||
}
|
||||
}
|
||||
if maximum, ok := def.ValueSchema.Maximum.Current(); ok {
|
||||
fmt.Fprintf(&out, " maximum: %s,\n", trimFloat(maximum))
|
||||
if history := boundHistory(def, def.ValueSchema.Maximum); history != "" {
|
||||
fmt.Fprintf(&out, " maximumHistory: [%s],\n", history)
|
||||
}
|
||||
}
|
||||
if def.ValueSchema.Step != nil {
|
||||
fmt.Fprintf(&out, " step: %s,\n", trimFloat(*def.ValueSchema.Step))
|
||||
}
|
||||
if def.ConstrainedBy != nil {
|
||||
fmt.Fprintf(&out, " constrainedBy: { policyInput: %q, constraint: %q },\n",
|
||||
def.ConstrainedBy.PolicyInput, def.ConstrainedBy.Constraint)
|
||||
}
|
||||
out.WriteString(" },\n")
|
||||
}
|
||||
out.WriteString("};\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func generateKotlin(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
for _, line := range strings.Split(generatedHeader, "\n") {
|
||||
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
|
||||
}
|
||||
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
|
||||
|
||||
out.WriteString("data class SettingSuggestedOption(\n")
|
||||
out.WriteString(" val value: String,\n")
|
||||
out.WriteString(" val introducedIn: Int,\n")
|
||||
out.WriteString(")\n\n")
|
||||
out.WriteString("data class SettingOptionSet(\n")
|
||||
out.WriteString(" val type: String,\n")
|
||||
out.WriteString(" val options: List<SettingSuggestedOption>,\n")
|
||||
out.WriteString(")\n\n")
|
||||
out.WriteString("data class SettingPresentation(\n")
|
||||
out.WriteString(" val suggestedOptions: String? = null,\n")
|
||||
out.WriteString(" val unsetLabel: String? = null,\n")
|
||||
out.WriteString(")\n\n")
|
||||
|
||||
out.WriteString("object SettingKeys {\n")
|
||||
fmt.Fprintf(&out, " const val REVISION = %d\n\n", contract.Revision)
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
fmt.Fprintf(&out, " /** %s */\n", def.Label)
|
||||
fmt.Fprintf(&out, " const val %s = %q\n", screamingCase(def.Key), def.Key)
|
||||
}
|
||||
|
||||
// The allowlist Android maintained by hand, generated instead. The whole
|
||||
// class of "wrote a local key to the server" bug is a manifest question now.
|
||||
out.WriteString("\n /** Every key the server stores. Safe to flush. */\n")
|
||||
out.WriteString(" val REMOTE: List<String> = listOf(\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if def.IsRemote() {
|
||||
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
|
||||
}
|
||||
}
|
||||
out.WriteString(" )\n\n")
|
||||
|
||||
out.WriteString(" /** Contract-known keys that never leave the device. */\n")
|
||||
out.WriteString(" val CLIENT_LOCAL: List<String> = listOf(\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if !def.IsRemote() {
|
||||
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
|
||||
}
|
||||
}
|
||||
out.WriteString(" )\n")
|
||||
|
||||
// Type classification, which Android kept as a second hand-maintained table
|
||||
// that had to agree with the first.
|
||||
for _, group := range []struct {
|
||||
name string
|
||||
types []settingscontract.ValueType
|
||||
}{
|
||||
{"BOOLEAN_KEYS", []settingscontract.ValueType{settingscontract.TypeBoolean}},
|
||||
{"INT_KEYS", []settingscontract.ValueType{settingscontract.TypeInteger}},
|
||||
{"DOUBLE_KEYS", []settingscontract.ValueType{settingscontract.TypeNumber}},
|
||||
} {
|
||||
// Remote only: these tables drive how a value read back from the
|
||||
// server is parsed, and a client_local key never comes back from the
|
||||
// server at all. Listing one would describe a wire format that has no
|
||||
// wire.
|
||||
fmt.Fprintf(&out, "\n val %s: Set<String> = setOf(\n", group.name)
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if !def.IsRemote() {
|
||||
continue
|
||||
}
|
||||
for _, want := range group.types {
|
||||
if def.ValueSchema.Type == want {
|
||||
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
|
||||
}
|
||||
}
|
||||
}
|
||||
out.WriteString(" )\n")
|
||||
}
|
||||
|
||||
out.WriteString("}\n\n")
|
||||
|
||||
out.WriteString("object SettingPresentationMetadata {\n")
|
||||
out.WriteString(" val OPTION_SETS: Map<String, SettingOptionSet> = mapOf(\n")
|
||||
for _, name := range sortedOptionSetNames(contract) {
|
||||
optionSet := contract.OptionSets[name]
|
||||
fmt.Fprintf(&out, " %q to SettingOptionSet(\n", name)
|
||||
fmt.Fprintf(&out, " type = %q,\n", optionSet.Type)
|
||||
out.WriteString(" options = listOf(\n")
|
||||
for _, option := range optionSet.Options {
|
||||
fmt.Fprintf(&out, " SettingSuggestedOption(%q, %d),\n",
|
||||
option.Value, option.IntroducedIn)
|
||||
}
|
||||
out.WriteString(" ),\n")
|
||||
out.WriteString(" ),\n")
|
||||
}
|
||||
out.WriteString(" )\n\n")
|
||||
|
||||
out.WriteString(" val DEFINITIONS: Map<String, SettingPresentation> = mapOf(\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if def.SuggestedOptions == "" && def.UnsetLabel == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&out, " SettingKeys.%s to SettingPresentation(\n", screamingCase(def.Key))
|
||||
if def.SuggestedOptions != "" {
|
||||
fmt.Fprintf(&out, " suggestedOptions = %q,\n", def.SuggestedOptions)
|
||||
}
|
||||
if def.UnsetLabel != "" {
|
||||
fmt.Fprintf(&out, " unsetLabel = %q,\n", def.UnsetLabel)
|
||||
}
|
||||
out.WriteString(" ),\n")
|
||||
}
|
||||
out.WriteString(" )\n\n")
|
||||
out.WriteString(" fun suggestedValues(key: String, revision: Int = SettingKeys.REVISION): List<String> {\n")
|
||||
out.WriteString(" val setId = DEFINITIONS[key]?.suggestedOptions ?: return emptyList()\n")
|
||||
out.WriteString(" return OPTION_SETS[setId]?.options\n")
|
||||
out.WriteString(" ?.filter { it.introducedIn <= revision }\n")
|
||||
out.WriteString(" ?.map { it.value }\n")
|
||||
out.WriteString(" .orEmpty()\n")
|
||||
out.WriteString(" }\n")
|
||||
out.WriteString("}\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func generateSwift(contract *settingscontract.Manifest) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
for _, line := range strings.Split(generatedHeader, "\n") {
|
||||
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
|
||||
}
|
||||
out.WriteString("\nimport Foundation\n\n")
|
||||
|
||||
out.WriteString("public struct SettingSuggestedOption: Hashable, Sendable {\n")
|
||||
out.WriteString(" public let value: String\n")
|
||||
out.WriteString(" public let introducedIn: Int\n")
|
||||
out.WriteString("}\n\n")
|
||||
out.WriteString("public struct SettingOptionSet: Hashable, Sendable {\n")
|
||||
out.WriteString(" public let type: String\n")
|
||||
out.WriteString(" public let options: [SettingSuggestedOption]\n")
|
||||
out.WriteString("}\n\n")
|
||||
out.WriteString("public struct SettingPresentation: Hashable, Sendable {\n")
|
||||
out.WriteString(" public let suggestedOptions: String?\n")
|
||||
out.WriteString(" public let unsetLabel: String?\n")
|
||||
out.WriteString("}\n\n")
|
||||
|
||||
out.WriteString("/// Every setting the contract defines.\n")
|
||||
out.WriteString("public enum SettingKey: String, CaseIterable, Sendable {\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
fmt.Fprintf(&out, " /// %s\n", def.Label)
|
||||
fmt.Fprintf(&out, " case %s = %q\n", lowerFirst(identifierFor(def.Key)), def.Key)
|
||||
}
|
||||
out.WriteString("}\n\n")
|
||||
|
||||
out.WriteString("public extension SettingKey {\n")
|
||||
fmt.Fprintf(&out, " static let revision = %d\n\n", contract.Revision)
|
||||
|
||||
out.WriteString(" /// Keys the server stores. The rest never leave the device.\n")
|
||||
out.WriteString(" static let remote: [SettingKey] = [\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if def.IsRemote() {
|
||||
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
|
||||
}
|
||||
}
|
||||
out.WriteString(" ]\n\n")
|
||||
|
||||
out.WriteString(" static let clientLocal: [SettingKey] = [\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if !def.IsRemote() {
|
||||
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
|
||||
}
|
||||
}
|
||||
out.WriteString(" ]\n")
|
||||
out.WriteString("}\n\n")
|
||||
|
||||
out.WriteString("public enum SettingPresentationMetadata {\n")
|
||||
out.WriteString(" public static let optionSets: [String: SettingOptionSet] = [\n")
|
||||
for _, name := range sortedOptionSetNames(contract) {
|
||||
optionSet := contract.OptionSets[name]
|
||||
fmt.Fprintf(&out, " %q: SettingOptionSet(\n", name)
|
||||
fmt.Fprintf(&out, " type: %q,\n", optionSet.Type)
|
||||
out.WriteString(" options: [\n")
|
||||
for _, option := range optionSet.Options {
|
||||
fmt.Fprintf(&out, " SettingSuggestedOption(value: %q, introducedIn: %d),\n",
|
||||
option.Value, option.IntroducedIn)
|
||||
}
|
||||
out.WriteString(" ]\n")
|
||||
out.WriteString(" ),\n")
|
||||
}
|
||||
out.WriteString(" ]\n\n")
|
||||
|
||||
out.WriteString(" public static let definitions: [SettingKey: SettingPresentation] = [\n")
|
||||
for _, def := range sortedDefinitions(contract) {
|
||||
if def.SuggestedOptions == "" && def.UnsetLabel == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&out, " .%s: SettingPresentation(\n", lowerFirst(identifierFor(def.Key)))
|
||||
if def.SuggestedOptions != "" {
|
||||
fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions)
|
||||
} else {
|
||||
out.WriteString(" suggestedOptions: nil,\n")
|
||||
}
|
||||
if def.UnsetLabel != "" {
|
||||
fmt.Fprintf(&out, " unsetLabel: %q\n", def.UnsetLabel)
|
||||
} else {
|
||||
out.WriteString(" unsetLabel: nil\n")
|
||||
}
|
||||
out.WriteString(" ),\n")
|
||||
}
|
||||
out.WriteString(" ]\n\n")
|
||||
out.WriteString(" public static func suggestedValues(\n")
|
||||
out.WriteString(" for key: SettingKey,\n")
|
||||
out.WriteString(" revision: Int = SettingKey.revision\n")
|
||||
out.WriteString(" ) -> [String] {\n")
|
||||
out.WriteString(" guard let setID = definitions[key]?.suggestedOptions,\n")
|
||||
out.WriteString(" let optionSet = optionSets[setID] else { return [] }\n")
|
||||
out.WriteString(" return optionSet.options\n")
|
||||
out.WriteString(" .filter { $0.introducedIn <= revision }\n")
|
||||
out.WriteString(" .map(\\.value)\n")
|
||||
out.WriteString(" }\n")
|
||||
out.WriteString("}\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func lowerFirst(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
return strings.ToLower(value[:1]) + value[1:]
|
||||
}
|
||||
|
||||
func sortedOptionSetNames(contract *settingscontract.Manifest) []string {
|
||||
names := make([]string, 0, len(contract.OptionSets))
|
||||
for name := range contract.OptionSets {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func quotedStrings(values []string) string {
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, fmt.Sprintf("%q", value))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func quotedScopes(def *settingscontract.Definition) string {
|
||||
parts := make([]string, 0, len(def.AllowedScopes))
|
||||
for _, entry := range def.AllowedScopes {
|
||||
parts = append(parts, fmt.Sprintf("%q", entry.Scope))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// scopeRevisions emits each scope's introduction revision, aligned with
|
||||
// quotedScopes. A scope entry with no explicit tag has held since the
|
||||
// definition itself appeared.
|
||||
func scopeRevisions(def *settingscontract.Definition) string {
|
||||
parts := make([]string, 0, len(def.AllowedScopes))
|
||||
for _, entry := range def.AllowedScopes {
|
||||
revision := entry.IntroducedIn
|
||||
if revision == 0 {
|
||||
revision = def.IntroducedIn
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d", revision))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// memberRevision is the revision an enum member became a legal value at; an
|
||||
// untagged member has existed since its definition.
|
||||
func memberRevision(def *settingscontract.Definition, member settingscontract.EnumMember) int {
|
||||
if member.IntroducedIn != 0 {
|
||||
return member.IntroducedIn
|
||||
}
|
||||
return def.IntroducedIn
|
||||
}
|
||||
|
||||
// boundHistory renders a widened bound's full history so an ahead-of-server
|
||||
// client can recover the bound in force at an older revision. Empty when the
|
||||
// bound never changed — the flattened minimum/maximum already carries it.
|
||||
func boundHistory(def *settingscontract.Definition, bound *settingscontract.Bound) string {
|
||||
if bound == nil || len(bound.History) < 2 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(bound.History))
|
||||
for _, entry := range bound.History {
|
||||
revision := entry.IntroducedIn
|
||||
if revision == 0 {
|
||||
revision = def.IntroducedIn
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("{ value: %s, introducedIn: %d }",
|
||||
trimFloat(entry.Value), revision))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func quotedResolution(def *settingscontract.Definition) string {
|
||||
parts := make([]string, 0, len(def.ResolutionOrder))
|
||||
for _, scope := range def.ResolutionOrder {
|
||||
parts = append(parts, fmt.Sprintf("%q", scope))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func defaultLiteral(def *settingscontract.Definition) string {
|
||||
if len(def.DefaultValue) == 0 {
|
||||
return "null"
|
||||
}
|
||||
return string(bytes.TrimSpace(def.DefaultValue))
|
||||
}
|
||||
|
||||
func jsString(value string) string {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return `""`
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func trimFloat(value float64) string {
|
||||
return strings.TrimSuffix(fmt.Sprintf("%g", value), ".0")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
)
|
||||
|
||||
func TestPresentationMetadataIsGeneratedForEveryClientLanguage(t *testing.T) {
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("loading contract: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
generate func() ([]byte, error)
|
||||
markers []string
|
||||
}{
|
||||
{
|
||||
name: "typescript",
|
||||
generate: func() ([]byte, error) { return generateTypeScript(contract) },
|
||||
markers: []string{
|
||||
"export const SETTING_OPTION_SETS",
|
||||
`catalog_metadata_languages`,
|
||||
`suggestedOptions: "playback_subtitle_languages"`,
|
||||
`unsetLabel: "None"`,
|
||||
// Advisory platform tags reach the web UI so it can hide
|
||||
// settings that do not apply to the device being edited.
|
||||
`platforms: ["web"]`,
|
||||
`platforms: ["ios", "android"]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "kotlin",
|
||||
generate: func() ([]byte, error) {
|
||||
return generateKotlin(contract, "org.siloserver.silo.model.settings")
|
||||
},
|
||||
markers: []string{
|
||||
"object SettingPresentationMetadata",
|
||||
`"playback_audio_languages" to SettingOptionSet`,
|
||||
`suggestedOptions = "catalog_metadata_languages"`,
|
||||
`unsetLabel = "Library default"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "swift",
|
||||
generate: func() ([]byte, error) { return generateSwift(contract) },
|
||||
markers: []string{
|
||||
"public enum SettingPresentationMetadata",
|
||||
`"playback_subtitle_languages": SettingOptionSet`,
|
||||
`suggestedOptions: "playback_audio_languages"`,
|
||||
`unsetLabel: "No preference"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
generated, err := tc.generate()
|
||||
if err != nil {
|
||||
t.Fatalf("generating: %v", err)
|
||||
}
|
||||
for _, marker := range tc.markers {
|
||||
if !strings.Contains(string(generated), marker) {
|
||||
t.Errorf("generated output omitted %q", marker)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedOptionOrderMatchesTheManifest(t *testing.T) {
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("loading contract: %v", err)
|
||||
}
|
||||
generated, err := generateTypeScript(contract)
|
||||
if err != nil {
|
||||
t.Fatalf("generating TypeScript: %v", err)
|
||||
}
|
||||
|
||||
text := string(generated)
|
||||
arabic := strings.Index(text, `{ value: "ar", introducedIn: 1 }`)
|
||||
bengali := strings.Index(text, `{ value: "bn", introducedIn: 1 }`)
|
||||
if arabic < 0 || bengali < 0 || arabic >= bengali {
|
||||
t.Fatalf("generated option order does not preserve the manifest: ar=%d bn=%d", arabic, bengali)
|
||||
}
|
||||
}
|
||||
+176
-12
@@ -19,6 +19,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -95,6 +96,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/server"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
||||
taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository"
|
||||
@@ -307,6 +309,16 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
// still reads via the read-path pass-through, so a backfill error must never
|
||||
// block boot. The sensitive-settings pass runs first so the arr
|
||||
// resolve-then-encrypt pass sees consistent referenced settings.
|
||||
// librarySettingsCleaner wires the per-user canonical settings cleanup the
|
||||
// library delete job runs, or nil when the user store is unavailable — the
|
||||
// executor treats a nil cleaner as "skip".
|
||||
func librarySettingsCleaner(pool *pgxpool.Pool, stores userstore.UserStoreProvider) adminjob.LibrarySettingsCleaner {
|
||||
if pool == nil || stores == nil {
|
||||
return nil
|
||||
}
|
||||
return userstore.NewSettingValuesCleaner(auth.NewUserRepository(pool), stores)
|
||||
}
|
||||
|
||||
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
|
||||
settingsN, err := settings.BackfillSensitiveSettings(ctx)
|
||||
if err != nil {
|
||||
@@ -391,10 +403,30 @@ func main() {
|
||||
envFile := flag.String("env", ".env", "path to .env bootstrap file")
|
||||
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
|
||||
migrateStatus := flag.Bool("migrate-status", false, "show database migration status and exit")
|
||||
migrateDownTo := flag.Int64("migrate-down-to", -1,
|
||||
"roll back every migration newer than this version and exit (the version to KEEP)")
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 0: Validate the embedded settings contract before anything can
|
||||
// depend on it. A malformed or self-inconsistent manifest is a build defect,
|
||||
// not a runtime condition, so failing here — loudly, before the first
|
||||
// request — is the whole point: the alternative is shipping an image whose
|
||||
// contract disagrees with the clients that vendored it.
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("settings contract: %v", err)
|
||||
}
|
||||
contractETag, err := settingscontract.ETag()
|
||||
if err != nil {
|
||||
log.Fatalf("settings contract: %v", err)
|
||||
}
|
||||
slog.Info("settings contract loaded",
|
||||
"revision", contract.Revision,
|
||||
"definitions", len(contract.Definitions),
|
||||
"etag", contractETag)
|
||||
|
||||
// Step 1: Bootstrap from .env
|
||||
bc, err := config.LoadBootstrap(*envFile)
|
||||
if err != nil {
|
||||
@@ -444,6 +476,21 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if *migrateDownTo >= 0 {
|
||||
// Deliberately its own flag rather than a mode of --migrate-only: this
|
||||
// discards data, and several of the migrations it reverses are Go ones
|
||||
// the goose CLI cannot reach, so it is the only way to undo them
|
||||
// short of restoring a backup.
|
||||
migCtx, migCancel := database.MigrationContext(ctx)
|
||||
migErr := database.MigrateDownTo(migCtx, pool, migrations.FS, "sql", *migrateDownTo)
|
||||
migCancel()
|
||||
if migErr != nil {
|
||||
log.Fatalf("failed to roll back migrations: %v", migErr)
|
||||
}
|
||||
slog.Info("database migrations rolled back", "kept_through_version", *migrateDownTo)
|
||||
return
|
||||
}
|
||||
|
||||
if *migrateOnly {
|
||||
migCtx, migCancel := database.MigrationContext(ctx)
|
||||
migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql")
|
||||
@@ -847,8 +894,10 @@ func main() {
|
||||
)
|
||||
}
|
||||
var watchProviderService *watchsync.Service
|
||||
var watchProviderRegistry *watchsync.Registry
|
||||
var watchProviderRepo *watchsync.PostgresRepository
|
||||
if deps.DB != nil {
|
||||
watchProviderRegistry := watchsync.NewRegistry()
|
||||
watchProviderRegistry = watchsync.NewRegistry()
|
||||
if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
@@ -858,10 +907,8 @@ func main() {
|
||||
if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
watchProviderService = watchsync.NewService(
|
||||
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
|
||||
watchProviderRegistry,
|
||||
)
|
||||
watchProviderRepo = watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher)
|
||||
watchProviderService = watchsync.NewService(watchProviderRepo, watchProviderRegistry)
|
||||
deps.WatchProviderService = watchProviderService
|
||||
}
|
||||
|
||||
@@ -929,7 +976,7 @@ func main() {
|
||||
s.SetLiteraryWorkLinker(literaryWorkService)
|
||||
s.SetEbookEnrichmentQueue(ebooks.NewEnrichmentQueue(deps.DB))
|
||||
deps.Scanner = s
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, cfg.Playback.FFmpegPath, 10*time.Second)
|
||||
slog.Info("scanner initialized")
|
||||
}
|
||||
|
||||
@@ -1073,6 +1120,15 @@ func main() {
|
||||
installer,
|
||||
plugins.NewHostAdapter(pluginHost),
|
||||
)
|
||||
if watchProviderRegistry != nil {
|
||||
reloadWatchProviders := func(ctx context.Context) {
|
||||
if err := reloadWatchSyncPluginProviders(ctx, watchProviderRegistry, installationStore, pluginService, watchProviderRepo); err != nil {
|
||||
slog.WarnContext(ctx, "failed to reload watch sync plugin providers", "component", "app", "error", err)
|
||||
}
|
||||
}
|
||||
pluginService.AddLifecycleHook(reloadWatchProviders)
|
||||
reloadWatchProviders(appCtx)
|
||||
}
|
||||
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
|
||||
markerPluginResolver := markers.NewPluginResolverAdapter(pluginService)
|
||||
pluginService.AddLifecycleHook(func(ctx context.Context) {
|
||||
@@ -2040,6 +2096,11 @@ func main() {
|
||||
taskMgr.Register(tasks.NewRebuildReleaseInterestTask(notificationSystem))
|
||||
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationSystem))
|
||||
}
|
||||
if userStoreProvider != nil {
|
||||
taskMgr.Register(tasks.NewSettingMutationsRetentionTask(userstore.NewSettingMutationSweeper(
|
||||
auth.NewUserRepository(deps.DB), userStoreProvider,
|
||||
)))
|
||||
}
|
||||
if matchWorker != nil {
|
||||
taskMgr.Register(tasks.NewMatchMediaTask(matchWorker))
|
||||
}
|
||||
@@ -2426,7 +2487,8 @@ func main() {
|
||||
deps.S3Private,
|
||||
itemRefreshExecutor,
|
||||
libraryRefreshExecutor,
|
||||
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo),
|
||||
adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo,
|
||||
librarySettingsCleaner(deps.DB, userStoreProvider)),
|
||||
adminjob.NewImageCacheCleanupExecutor(deps.S3Public),
|
||||
templateBundleApplyExecutor,
|
||||
deps.RealtimeHub,
|
||||
@@ -3036,11 +3098,99 @@ func metadataInt(value any) int {
|
||||
}
|
||||
}
|
||||
|
||||
var watchSyncPluginReloadMu sync.Mutex
|
||||
|
||||
type markerPluginCapabilityStore interface {
|
||||
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
|
||||
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
|
||||
}
|
||||
|
||||
func reloadWatchSyncPluginProviders(
|
||||
ctx context.Context,
|
||||
registry *watchsync.Registry,
|
||||
store markerPluginCapabilityStore,
|
||||
service *plugins.Service,
|
||||
repository watchsync.PluginCredentialRepository,
|
||||
) error {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
watchSyncPluginReloadMu.Lock()
|
||||
defer watchSyncPluginReloadMu.Unlock()
|
||||
|
||||
var providers []watchsync.Provider
|
||||
if store == nil || service == nil {
|
||||
return registry.ReplacePluginProviders(providers)
|
||||
}
|
||||
installations, err := store.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list enabled watch sync plugin installations: %w", err)
|
||||
}
|
||||
sort.Slice(installations, func(i, j int) bool {
|
||||
if installations[i] == nil {
|
||||
return false
|
||||
}
|
||||
if installations[j] == nil {
|
||||
return true
|
||||
}
|
||||
return installations[i].ID < installations[j].ID
|
||||
})
|
||||
for _, installation := range installations {
|
||||
if installation == nil || installation.IsBuiltin() {
|
||||
continue
|
||||
}
|
||||
capabilities, err := store.ListCapabilities(ctx, installation.ID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip watch sync plugin with unreadable capabilities",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil || capability.Type != sdkcapability.WatchSyncProvider {
|
||||
continue
|
||||
}
|
||||
descriptor, err := plugins.DecodeCapability(capability)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip invalid watch sync plugin capability",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
InstallationID: installation.ID,
|
||||
ProviderKey: fmt.Sprintf("plugin:%d:%s", installation.ID, capability.ID),
|
||||
CapabilityID: capability.ID,
|
||||
DisplayName: descriptor.GetDisplayName(),
|
||||
Descriptor: descriptor.GetWatchSyncProvider(),
|
||||
ResolveClient: func(callCtx context.Context, installationID int, capabilityID string) (watchsync.WatchSyncPluginClient, error) {
|
||||
return service.WatchSyncProviderClient(callCtx, installationID, capabilityID)
|
||||
},
|
||||
ResolveConfig: func(callCtx context.Context, installationID int) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
return service.WatchSyncProviderConfig(callCtx, installationID)
|
||||
},
|
||||
Repository: repository,
|
||||
})
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip unsupported watch sync plugin capability",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
return registry.ReplacePluginProviders(providers)
|
||||
}
|
||||
|
||||
type markerPluginRuntimeConfigStore interface {
|
||||
ListGlobalConfigs(ctx context.Context, installationID int) ([]*plugins.RuntimeConfig, error)
|
||||
PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error
|
||||
@@ -3256,17 +3406,31 @@ type scopeEntitlementResolver struct {
|
||||
}
|
||||
|
||||
func (r scopeEntitlementResolver) MaxPlaybackQuality(ctx context.Context, userID int, profileID string) (string, error) {
|
||||
scope, err := r.resolver.Resolve(ctx, access.ResolveInput{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
SkipPINVerification: true,
|
||||
})
|
||||
scope, err := r.resolveScope(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return scope.MaxPlaybackQuality, nil
|
||||
}
|
||||
|
||||
// MaxContentRating implements mediarequests.ContentRatingResolver so request
|
||||
// discovery honors the profile's parental rating ceiling.
|
||||
func (r scopeEntitlementResolver) MaxContentRating(ctx context.Context, userID int, profileID string) (string, error) {
|
||||
scope, err := r.resolveScope(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return scope.MaxContentRating, nil
|
||||
}
|
||||
|
||||
func (r scopeEntitlementResolver) resolveScope(ctx context.Context, userID int, profileID string) (access.Scope, error) {
|
||||
return r.resolver.Resolve(ctx, access.ResolveInput{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
SkipPINVerification: true,
|
||||
})
|
||||
}
|
||||
|
||||
// audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which
|
||||
// exposes Get) to the audiobooks.SettingsReader interface (which
|
||||
// requires GetString). The two signatures are identical modulo name.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -8,9 +10,12 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"github.com/Silo-Server/silo-server/internal/api"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
)
|
||||
|
||||
func TestConfigureS3Clients_SetsCORSOnPublicAssetsBucket(t *testing.T) {
|
||||
@@ -182,3 +187,44 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failingWatchSyncCapabilityStore struct{}
|
||||
|
||||
func (failingWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
|
||||
return []*plugins.Installation{{ID: 2, Enabled: true, Kind: plugins.KindPlugin}}, nil
|
||||
}
|
||||
|
||||
func (failingWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
|
||||
func TestReloadWatchSyncPluginProvidersDropsStaleProvidersOnCapabilityReadFailure(t *testing.T) {
|
||||
registry := watchsync.NewRegistry()
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
InstallationID: 1,
|
||||
ProviderKey: "plugin:1:tracker",
|
||||
CapabilityID: "tracker",
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{
|
||||
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
|
||||
ExportWatched: true,
|
||||
},
|
||||
ResolveClient: func(context.Context, int, string) (watchsync.WatchSyncPluginClient, error) {
|
||||
return nil, errors.New("not used")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Register(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := reloadWatchSyncPluginProviders(
|
||||
context.Background(), registry, failingWatchSyncCapabilityStore{}, &plugins.Service{}, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := registry.Get(provider.Key()); ok {
|
||||
t.Fatalf("stale provider %q remained registered", provider.Key())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,6 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
|
||||
PositionSeconds: s.Position,
|
||||
IsPaused: s.IsPaused,
|
||||
HasWebSocket: s.HasWebSocket,
|
||||
IsJellyfinCompat: s.IsJellyfinCompat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"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": [
|
||||
{
|
||||
"name": "profile_client_sits_between_device_and_profile",
|
||||
"description": "Presentation values roam across like clients, while an exact-device override remains more specific and a profile value remains the cross-family fallback.",
|
||||
"keys": ["ui.card_presentation"],
|
||||
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "living-room" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "poster_size": "standard", "caption": "title" }
|
||||
},
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"scope": "profile_client",
|
||||
"profile_id": "p1",
|
||||
"client_family": "tv",
|
||||
"value": { "poster_size": "large", "caption": "artwork" }
|
||||
},
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "living-room",
|
||||
"value": { "poster_size": "compact", "caption": "title_metadata" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"value": { "poster_size": "compact", "caption": "title_metadata" },
|
||||
"source": "profile_device"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "profile_client_roams_only_within_its_family",
|
||||
"description": "A television menu applies to another television identity but not to mobile; the family is explicit resolution context rather than inferred from device metadata.",
|
||||
"keys": ["nav.primary_menu"],
|
||||
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "bedroom-tv" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "nav.primary_menu",
|
||||
"scope": "profile_client",
|
||||
"profile_id": "p1",
|
||||
"client_family": "mobile",
|
||||
"value": { "items": [{ "type": "builtin", "destination": "home" }] }
|
||||
},
|
||||
{
|
||||
"key": "nav.primary_menu",
|
||||
"scope": "profile_client",
|
||||
"profile_id": "p1",
|
||||
"client_family": "tv",
|
||||
"value": {
|
||||
"items": [
|
||||
{ "type": "builtin", "destination": "home" },
|
||||
{ "type": "builtin", "destination": "movies" }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "nav.primary_menu",
|
||||
"value": {
|
||||
"items": [
|
||||
{ "type": "builtin", "destination": "home" },
|
||||
{ "type": "builtin", "destination": "movies" }
|
||||
]
|
||||
},
|
||||
"source": "profile_client"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_series_wins",
|
||||
"description": "The full ladder: with values stored at every scope a content key allows, profile_series beats profile_library, profile_device, and profile.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_library",
|
||||
"profile_id": "p1",
|
||||
"library_id": 7,
|
||||
"value": "fr"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_series",
|
||||
"profile_id": "p1",
|
||||
"series_id": "s-101",
|
||||
"value": "ja"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_language", "value": "ja", "source": "profile_series" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_library_beats_device",
|
||||
"description": "Without a series row, the library row wins over the device and profile rows.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_library",
|
||||
"profile_id": "p1",
|
||||
"library_id": 7,
|
||||
"value": "fr"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_language", "value": "fr", "source": "profile_library" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_device_beats_profile",
|
||||
"description": "Without content rows, the device override wins over the profile fallback even though the context names a library and a series.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_language", "value": "de", "source": "profile_device" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_profile_alone",
|
||||
"description": "A profile row alone resolves at profile scope.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }
|
||||
],
|
||||
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "device_override_beats_profile_for_quality",
|
||||
"description": "playback.preferred_quality has no content scopes; its device override wins over the profile value.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "720p"
|
||||
},
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "1080p"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "missing_device_identity_drops_device_scope",
|
||||
"description": "A caller with no device identity must not see a device override; the profile row answers instead. This is the anonymous jellycompat seed: a device row leaking here hands one device's settings to every client.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "de"
|
||||
}
|
||||
],
|
||||
"expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "foreign_identity_rows_never_resolve",
|
||||
"description": "Rows for another profile, another device, or another series must not resolve just because a batched read returned them; the answer falls to the contract default.",
|
||||
"keys": ["playback.subtitle_language"],
|
||||
"context": { "profile_id": "p1", "device_id": "d1", "series_ids": ["s-101"] },
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p2", "value": "xx" },
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d2",
|
||||
"value": "yy"
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_language",
|
||||
"scope": "profile_series",
|
||||
"profile_id": "p1",
|
||||
"series_id": "s-other",
|
||||
"value": "zz"
|
||||
}
|
||||
],
|
||||
"expected": [{ "key": "playback.subtitle_language", "value": null, "source": "default" }]
|
||||
},
|
||||
{
|
||||
"name": "absent_values_resolve_to_contract_defaults",
|
||||
"description": "Nothing stored resolves to each definition's default_value with source \"default\": enum, boolean, integer, and nullable language tag.",
|
||||
"keys": [
|
||||
"playback.subtitle_mode",
|
||||
"playback.show_forced_subtitles",
|
||||
"playback.next_up_prompt_seconds",
|
||||
"playback.audio_language"
|
||||
],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"expected": [
|
||||
{ "key": "playback.subtitle_mode", "value": "auto", "source": "default" },
|
||||
{ "key": "playback.show_forced_subtitles", "value": true, "source": "default" },
|
||||
{ "key": "playback.next_up_prompt_seconds", "value": 30, "source": "default" },
|
||||
{ "key": "playback.audio_language", "value": null, "source": "default" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "batch_resolves_each_key_independently",
|
||||
"description": "One batch, three keys, three different sources: a device override, a profile value, and a default.",
|
||||
"keys": [
|
||||
"playback.preferred_quality",
|
||||
"playback.subtitle_mode",
|
||||
"playback.audio_language"
|
||||
],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "1080p"
|
||||
},
|
||||
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "always" }
|
||||
],
|
||||
"expected": [
|
||||
{ "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" },
|
||||
{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" },
|
||||
{ "key": "playback.audio_language", "value": null, "source": "default" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_caps_stored_quality_and_reports_the_stored_value",
|
||||
"description": "The manifest binds playback.preferred_quality to the max_playback_quality ceiling. A stored 2160p over a 1080p cap resolves to 1080p while the authored value survives, reported as stored_value with constrained:true.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "2160p"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "1080p" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"value": "1080p",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "2160p",
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_leaves_quality_under_the_cap_alone",
|
||||
"description": "A value at or under the cap passes through untouched and is not reported as constrained.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "720p"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "1080p" },
|
||||
"expected": [{ "key": "playback.preferred_quality", "value": "720p", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_ranks_auto_below_every_cap",
|
||||
"description": "The ordered enum lists \"auto\" first because it never exceeds a cap: even the lowest cap leaves it alone.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "auto"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "480p" },
|
||||
"expected": [{ "key": "playback.preferred_quality", "value": "auto", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "ceiling_caps_original_as_the_highest_member",
|
||||
"description": "\"original\" is the uncapped source and ranks above every resolution, so any cap brings it down.",
|
||||
"keys": ["playback.preferred_quality"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "original"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_playback_quality": "2160p" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.preferred_quality",
|
||||
"value": "2160p",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "original",
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "null_bitrate_is_unbounded_and_a_ceiling_caps_it",
|
||||
"description": "null on the nullable integer playback.max_bitrate_kbps means \"no cap of my own\", which is unbounded above. It has no numeric rank, so a resolver that compares it as equal lets the one value that most needs capping slip past; a ceiling must bring it down to the limit.",
|
||||
"keys": ["playback.max_bitrate_kbps"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"policy_input": "max_bitrate_kbps",
|
||||
"constraint": "ceiling"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_bitrate_kbps": 8000 },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"value": 8000,
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": null,
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "default_null_bitrate_is_capped_by_a_ceiling",
|
||||
"description": "The contract default for playback.max_bitrate_kbps is null, so even with nothing stored a ceiling caps the resolved default; source stays \"default\" and the null is reported as stored_value.",
|
||||
"keys": ["playback.max_bitrate_kbps"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"policy_input": "max_bitrate_kbps",
|
||||
"constraint": "ceiling"
|
||||
}
|
||||
],
|
||||
"constraints": { "max_bitrate_kbps": 8000 },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"value": 8000,
|
||||
"source": "default",
|
||||
"constrained": true,
|
||||
"stored_value": null,
|
||||
"constraint_kind": "ceiling"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "floor_leaves_an_unbounded_bitrate_alone",
|
||||
"description": "The mirror rule: unbounded already satisfies any floor, so a floor must not touch a null numeric.",
|
||||
"keys": ["playback.max_bitrate_kbps"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.max_bitrate_kbps",
|
||||
"policy_input": "min_bitrate_kbps",
|
||||
"constraint": "floor"
|
||||
}
|
||||
],
|
||||
"constraints": { "min_bitrate_kbps": 8000 },
|
||||
"expected": [{ "key": "playback.max_bitrate_kbps", "value": null, "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "allowlist_falls_back_when_the_default_is_outside_the_list",
|
||||
"description": "With nothing stored, catalog.metadata_language resolves to its default null, which is outside the allowlist. The fallback is the first allowed member — not the definition default, which is exactly the value the policy forbids.",
|
||||
"keys": ["catalog.metadata_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"policy_input": "allowed_metadata_languages",
|
||||
"constraint": "allowlist"
|
||||
}
|
||||
],
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"value": "en",
|
||||
"source": "default",
|
||||
"constrained": true,
|
||||
"stored_value": null,
|
||||
"constraint_kind": "allowlist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "allowlist_replaces_a_forbidden_choice",
|
||||
"description": "A stored value outside the allowlist is replaced by the first allowed member, with the authored choice preserved as stored_value.",
|
||||
"keys": ["catalog.metadata_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "ja" }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"policy_input": "allowed_metadata_languages",
|
||||
"constraint": "allowlist"
|
||||
}
|
||||
],
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"value": "en",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "ja",
|
||||
"constraint_kind": "allowlist"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "allowlist_passes_a_permitted_choice",
|
||||
"description": "A stored value inside the allowlist passes through untouched.",
|
||||
"keys": ["catalog.metadata_language"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "fr" }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "catalog.metadata_language",
|
||||
"policy_input": "allowed_metadata_languages",
|
||||
"constraint": "allowlist"
|
||||
}
|
||||
],
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "metadata_language_exceptions_resolve_as_one_object",
|
||||
"description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.",
|
||||
"keys": ["catalog.metadata_language_overrides"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "ja": "en", "no": "x-silo-original" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"value": { "ja": "en", "no": "x-silo-original" },
|
||||
"source": "profile"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "locked_replaces_a_differing_choice",
|
||||
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
|
||||
"keys": ["playback.subtitle_mode"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{ "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "off" }
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"policy_input": "forced_subtitle_mode",
|
||||
"constraint": "locked"
|
||||
}
|
||||
],
|
||||
"constraints": { "forced_subtitle_mode": "always" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"value": "always",
|
||||
"source": "profile",
|
||||
"constrained": true,
|
||||
"stored_value": "off",
|
||||
"constraint_kind": "locked"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "locked_leaves_an_equal_value_unconstrained",
|
||||
"description": "A stored value already equal to the lock is not a narrowing: it passes through with no constrained flag, so clients do not tell the user their own choice was overridden.",
|
||||
"keys": ["playback.subtitle_mode"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "always"
|
||||
}
|
||||
],
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"policy_input": "forced_subtitle_mode",
|
||||
"constraint": "locked"
|
||||
}
|
||||
],
|
||||
"constraints": { "forced_subtitle_mode": "always" },
|
||||
"expected": [{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "locked_replaces_the_contract_default",
|
||||
"description": "With nothing stored, the lock replaces even the contract default: source stays \"default\" and the default is reported as stored_value, exactly like a capped default.",
|
||||
"keys": ["playback.subtitle_mode"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"constraint_bindings": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"policy_input": "forced_subtitle_mode",
|
||||
"constraint": "locked"
|
||||
}
|
||||
],
|
||||
"constraints": { "forced_subtitle_mode": "always" },
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_mode",
|
||||
"value": "always",
|
||||
"source": "default",
|
||||
"constrained": true,
|
||||
"stored_value": "auto",
|
||||
"constraint_kind": "locked"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "subtitle_appearance_ignores_content_scopes",
|
||||
"description": "playback.subtitle_appearance resolves profile_device then profile only. With a library and a series in the context, the device row still wins — and the sparse device object replaces the profile object outright rather than merging with it.",
|
||||
"keys": ["playback.subtitle_appearance"],
|
||||
"context": {
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"library_ids": [7],
|
||||
"series_ids": ["s-101"]
|
||||
},
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": { "fontSize": "xxlarge", "position": "top" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"value": { "fontSize": "xxlarge", "position": "top" },
|
||||
"source": "profile_device"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "subtitle_appearance_falls_to_profile_without_device",
|
||||
"description": "Without a device identity the profile's appearance object answers, unmerged.",
|
||||
"keys": ["playback.subtitle_appearance"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "fontSize": "medium", "fontColor": "#ffcc00" }
|
||||
},
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": { "fontSize": "xxlarge", "position": "top" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.subtitle_appearance",
|
||||
"value": { "fontSize": "medium", "fontColor": "#ffcc00" },
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package settingsv1 embeds the canonical cross-platform user settings
|
||||
// contract so the server binary carries the exact bytes it was built from.
|
||||
//
|
||||
// This package deliberately contains nothing but the embed directive. The
|
||||
// contract files are the artifact clients vendor and generate bindings from, so
|
||||
// they live at this stable path rather than inside an internal package; the
|
||||
// embed has to sit beside them because go:embed cannot reach outside its own
|
||||
// directory.
|
||||
//
|
||||
// Loading, validation, and lookup live in internal/settingscontract.
|
||||
package settingsv1
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds manifest.json, manifest.schema.json, and schemas/.
|
||||
//
|
||||
//go:embed manifest.json manifest.schema.json schemas
|
||||
var FS embed.FS
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/manifest.schema.json",
|
||||
"title": "Silo cross-platform user settings manifest",
|
||||
"description": "Canonical contract for every production, user-facing setting. See docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["api_version", "revision", "definitions"],
|
||||
"properties": {
|
||||
"api_version": {
|
||||
"description": "Settings protocol version. Changes only for a change no revision rule can express.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"revision": {
|
||||
"description": "Monotonically increasing integer bumped by every manifest PR.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"option_sets": {
|
||||
"description": "Advisory ordered vocabularies for open-value controls. They suggest values but never constrain the corresponding value schema.",
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/$defs/optionSetName" },
|
||||
"additionalProperties": { "$ref": "#/$defs/optionSet" }
|
||||
},
|
||||
"definitions": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/definition" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"settingKey": {
|
||||
"description": "Lowercase dot-separated identifier. Canonical names do not encode a platform.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$",
|
||||
"maxLength": 128
|
||||
},
|
||||
"revisionRef": {
|
||||
"description": "Manifest revision in which this element was introduced.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"optionSetName": {
|
||||
"description": "Stable snake_case identifier for a reusable presentation vocabulary.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"maxLength": 128
|
||||
},
|
||||
"suggestedOption": {
|
||||
"description": "One advisory value. Labels are localized by clients from the stable wire value.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["value", "introduced_in"],
|
||||
"properties": {
|
||||
"value": { "type": "string", "minLength": 1 },
|
||||
"introduced_in": { "$ref": "#/$defs/revisionRef" }
|
||||
}
|
||||
},
|
||||
"optionSet": {
|
||||
"description": "An ordered advisory value floor for an open setting. The server validates that its type matches every referring definition.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "options"],
|
||||
"properties": {
|
||||
"type": { "const": "language_tag" },
|
||||
"options": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/suggestedOption" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"scopeName": {
|
||||
"description": "Storage identity a value attaches to. Whether a given scope is legal for a definition depends on its persistence class, which internal/settingscontract enforces.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"account",
|
||||
"profile",
|
||||
"profile_client",
|
||||
"profile_device",
|
||||
"profile_library",
|
||||
"profile_series",
|
||||
"client_local"
|
||||
]
|
||||
},
|
||||
"scopeEntry": {
|
||||
"description": "A scope, optionally tagged with the revision that added it to this definition.",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/scopeName" },
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["scope"],
|
||||
"properties": {
|
||||
"scope": { "$ref": "#/$defs/scopeName" },
|
||||
"introduced_in": { "$ref": "#/$defs/revisionRef" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"integerBound": {
|
||||
"description": "A numeric bound. Write a bare number for a bound that has never been widened. Widening replaces it with the full history, oldest first, so a client can recover the bound an older server still enforces; the bare form alone would discard it.",
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": { "type": "integer" },
|
||||
"introduced_in": { "$ref": "#/$defs/revisionRef" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"numberBound": {
|
||||
"description": "A numeric bound. Write a bare number for a bound that has never been widened. Widening replaces it with the full history, oldest first, so a client can recover the bound an older server still enforces; the bare form alone would discard it.",
|
||||
"oneOf": [
|
||||
{ "type": "number" },
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": { "type": "number" },
|
||||
"introduced_in": { "$ref": "#/$defs/revisionRef" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"enumMember": {
|
||||
"description": "Enum members are objects so members added later can carry their own revision.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": { "type": ["string", "integer", "boolean"] },
|
||||
"label": { "type": "string" },
|
||||
"introduced_in": { "$ref": "#/$defs/revisionRef" },
|
||||
"deprecated": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"valueSchema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": { "const": "boolean" },
|
||||
"nullable": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "minimum", "maximum"],
|
||||
"properties": {
|
||||
"type": { "const": "integer" },
|
||||
"minimum": { "$ref": "#/$defs/integerBound" },
|
||||
"maximum": { "$ref": "#/$defs/integerBound" },
|
||||
"step": { "type": "integer", "exclusiveMinimum": 0 },
|
||||
"nullable": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "minimum", "maximum"],
|
||||
"properties": {
|
||||
"type": { "const": "number" },
|
||||
"minimum": { "$ref": "#/$defs/numberBound" },
|
||||
"maximum": { "$ref": "#/$defs/numberBound" },
|
||||
"step": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"nullable": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "max_length"],
|
||||
"properties": {
|
||||
"type": { "const": "string" },
|
||||
"min_length": { "type": "integer", "minimum": 0, "default": 0 },
|
||||
"max_length": { "type": "integer", "minimum": 1 },
|
||||
"pattern": { "type": "string", "format": "regex" },
|
||||
"nullable": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "values"],
|
||||
"properties": {
|
||||
"type": { "const": "enum" },
|
||||
"values": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/enumMember" }
|
||||
},
|
||||
"ordered": {
|
||||
"description": "Members form a meaningful progression. Required for ceiling/floor constraints.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"nullable": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": { "const": "language_tag" },
|
||||
"nullable": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "schema_ref"],
|
||||
"properties": {
|
||||
"type": { "const": "object" },
|
||||
"schema_ref": {
|
||||
"description": "Filename under contracts/settings/v1/schemas/.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+\\.json$"
|
||||
},
|
||||
"nullable": { "type": "boolean", "default": false }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"constraint": {
|
||||
"description": "Binding to a policy input that constrains this setting at resolution time.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["policy_input", "constraint"],
|
||||
"properties": {
|
||||
"policy_input": {
|
||||
"description": "Field name produced by internal/policy.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"constraint": {
|
||||
"type": "string",
|
||||
"enum": ["ceiling", "floor", "allowlist", "locked"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"definition": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"key",
|
||||
"introduced_in",
|
||||
"persistence",
|
||||
"allowed_scopes",
|
||||
"resolution_order",
|
||||
"value_schema",
|
||||
"default_value",
|
||||
"category",
|
||||
"label",
|
||||
"description"
|
||||
],
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/settingKey" },
|
||||
"introduced_in": { "$ref": "#/$defs/revisionRef" },
|
||||
"persistence": {
|
||||
"type": "string",
|
||||
"enum": ["remote", "client_local"]
|
||||
},
|
||||
"allowed_scopes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/scopeEntry" }
|
||||
},
|
||||
"resolution_order": {
|
||||
"description": "Most specific first. Must end with \"default\".",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"account",
|
||||
"profile",
|
||||
"profile_client",
|
||||
"profile_device",
|
||||
"profile_library",
|
||||
"profile_series",
|
||||
"client_local",
|
||||
"default"
|
||||
]
|
||||
}
|
||||
},
|
||||
"value_schema": { "$ref": "#/$defs/valueSchema" },
|
||||
"default_value": {},
|
||||
"constrained_by": { "$ref": "#/$defs/constraint" },
|
||||
"platforms": {
|
||||
"description": "Advisory UI metadata. Absent means \"expected everywhere\". Never server-enforced.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["web", "ios", "tvos", "macos", "android", "android_tv"]
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string", "minLength": 1 },
|
||||
"unit": { "type": "string" },
|
||||
"recommended_control": {
|
||||
"type": "string",
|
||||
"enum": ["switch", "select", "slider", "stepper", "text", "color", "panel"]
|
||||
},
|
||||
"suggested_options": {
|
||||
"description": "Advisory option-set reference for an open-value control. It does not turn the value schema into an enum.",
|
||||
"$ref": "#/$defs/optionSetName"
|
||||
},
|
||||
"unset_label": {
|
||||
"description": "Context-specific label for the nullable/unset row in a generated control.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"deprecated": { "type": "boolean", "default": false },
|
||||
"notes": {
|
||||
"description": "Maintainer commentary. Not served in the public manifest.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-overlays.json",
|
||||
"title": "Card overlay preferences",
|
||||
"description": "Badges painted on poster cards. Mirrors CardOverlayPrefs in web/src/lib/overlays/types.ts.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "preset", "order", "items"],
|
||||
"properties": {
|
||||
"version": { "const": 2 },
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"enum": ["minimal", "classic", "vibrant", "pill", "square"]
|
||||
},
|
||||
"order": {
|
||||
"description": "Explicit render order. Empty means use the registry's own order.",
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/overlayId" }
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"propertyNames": { "$ref": "#/$defs/overlayId" },
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "position"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"position": {
|
||||
"type": "string",
|
||||
"enum": ["top-left", "top-right", "bottom-left", "bottom-right"]
|
||||
},
|
||||
"accentColor": {
|
||||
"description": "Hex colour. Absent means the overlay's own default accent.",
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
},
|
||||
"showIcon": {
|
||||
"description": "Absent means inherit from the preset.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"overlayId": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"resolution",
|
||||
"hdr",
|
||||
"resolution_hdr",
|
||||
"audio",
|
||||
"audio_channels",
|
||||
"video_codec",
|
||||
"container",
|
||||
"aspect_ratio",
|
||||
"release_type",
|
||||
"edition",
|
||||
"multi_audio",
|
||||
"multi_sub",
|
||||
"rating_imdb",
|
||||
"rating_tmdb",
|
||||
"rating_rt",
|
||||
"rating_rt_audience",
|
||||
"content_rating",
|
||||
"year",
|
||||
"runtime",
|
||||
"original_language",
|
||||
"studio",
|
||||
"network",
|
||||
"show_status",
|
||||
"imdb_top_250",
|
||||
"rt_certified_fresh"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-presentation.json",
|
||||
"title": "Media card presentation",
|
||||
"description": "Semantic density and caption presets that each client family maps to platform-appropriate card dimensions.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["poster_size", "caption"],
|
||||
"properties": {
|
||||
"poster_size": {
|
||||
"type": "string",
|
||||
"enum": ["compact", "standard", "large"]
|
||||
},
|
||||
"caption": {
|
||||
"type": "string",
|
||||
"enum": ["title_metadata", "title", "artwork"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/library-id-list.json",
|
||||
"title": "Library id list",
|
||||
"description": "An ordered, duplicate-free list of library ids. Backs both ui.disabled_library_ids and ui.library_order; the web client normalizes with normalizeLibraryIDs in web/src/hooks/queries/libraries.ts, which drops non-integers and anything below 1.",
|
||||
"type": "array",
|
||||
"maxItems": 512,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/library-page-state.json",
|
||||
"title": "Library page state",
|
||||
"description": "Remembered per-library browse state. Mirrors web/src/hooks/queries/libraryPageState.ts.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "libraries"],
|
||||
"properties": {
|
||||
"version": { "const": 1 },
|
||||
"libraries": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["search"],
|
||||
"properties": {
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Serialized URLSearchParams from serializeLibraryPageSearchParams. An advanced view encodes each filter rule as three groups[i][rules][j][...] keys, so the length grows about 150 characters per rule: measured at 216 for one rule, 518 for three, 820 for five. The bound has to clear what the current unvalidated endpoint already stores, or these rows fail the migration.",
|
||||
"maxLength": 4096
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxProperties": 512
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/metadata-language-overrides.json",
|
||||
"title": "Metadata language exceptions",
|
||||
"description": "Maps a media item's canonical original-language code to the metadata language a profile wants for that item. x-silo-original means use the source language itself.",
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"description": "Canonical ISO 639 language code as stored in media_items.original_language.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z]{2,3}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"description": "A BCP 47 target language or the x-silo-original private-use sentinel.",
|
||||
"type": "string",
|
||||
"minLength": 2,
|
||||
"maxLength": 255,
|
||||
"pattern": "^([a-zA-Z]{2,3}(-[a-zA-Z]{3}){0,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-([0-9a-zA-Z]{5,8}|[0-9][0-9a-zA-Z]{3}))*(-[0-9a-wy-zA-WY-Z](-[0-9a-zA-Z]{2,8})+)*(-[xX](-[0-9a-zA-Z]{1,8})+)?|[xX](-[0-9a-zA-Z]{1,8})+)$"
|
||||
},
|
||||
"maxProperties": 256
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/navigation-shortcuts.json",
|
||||
"title": "Navigation shortcuts",
|
||||
"description": "Profile-wide catalog of pinned libraries, sections, and collections that client-family menus may surface.",
|
||||
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["items"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"description": "Pinned targets. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/library" },
|
||||
{ "$ref": "#/$defs/section" },
|
||||
{ "$ref": "#/$defs/collection" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
|
||||
"libraryId": { "type": "integer", "minimum": 1 },
|
||||
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
|
||||
"library": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "library" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "section_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "section" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"section_id": { "$ref": "#/$defs/targetId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "collection_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "collection" },
|
||||
"collection_id": { "$ref": "#/$defs/targetId" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/primary-menu.json",
|
||||
"title": "Primary navigation menu",
|
||||
"description": "Ordered visible destinations for one client family. Search and profile controls are fixed client utilities and are intentionally absent.",
|
||||
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: builtin(destination), library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["items"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"description": "Ordered destinations. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 64,
|
||||
"uniqueItems": true,
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": ["type", "destination"],
|
||||
"properties": {
|
||||
"type": { "const": "builtin" },
|
||||
"destination": { "const": "home" }
|
||||
}
|
||||
},
|
||||
"minContains": 1,
|
||||
"maxContains": 1,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/builtin" },
|
||||
{ "$ref": "#/$defs/library" },
|
||||
{ "$ref": "#/$defs/section" },
|
||||
{ "$ref": "#/$defs/collection" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
|
||||
"libraryId": { "type": "integer", "minimum": 1 },
|
||||
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
|
||||
"builtin": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "destination"],
|
||||
"properties": {
|
||||
"type": { "const": "builtin" },
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"enum": ["home", "movies", "series", "music", "audiobooks", "for_you", "calendar"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "library" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "section_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "section" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"section_id": { "$ref": "#/$defs/targetId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "collection_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "collection" },
|
||||
"collection_id": { "$ref": "#/$defs/targetId" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/sidebar-pins.json",
|
||||
"title": "Sidebar pins",
|
||||
"description": "Sections and collections pinned into the sidebar, grouped by the library they belong to. Mirrors SidebarPins in web/src/api/types.ts.",
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"description": "The group the pins sit under — a library id, or a well-known group name.",
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
},
|
||||
"maxProperties": 512,
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "id", "label"],
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["section", "collection"] },
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"label": { "type": "string", "maxLength": 256 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/subtitle-appearance.json",
|
||||
"title": "Subtitle appearance",
|
||||
"description": "Rendering appearance for subtitle tracks. Shared by the web, Apple and Android players; where they disagree the wider vocabulary wins, because a value a shipped client can already produce must stay storable. A stored value is a sparse override: every property is optional, and a consumer merges what is present over this definition's default_value, which is complete. Requiring all nine would invalidate the partial objects the current API already stores and round-trips, so the migration would have to quarantine real user preferences. Resolution across scopes is unchanged and still first-wins — a device override replaces the profile's object rather than merging with it — because a device override means \"draw subtitles this way on this screen\", not \"amend the profile\".",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"fontSize": {
|
||||
"type": "string",
|
||||
"enum": ["small", "medium", "large", "xlarge", "xxlarge"]
|
||||
},
|
||||
"fontFamily": {
|
||||
"description": "A font family name. Not an enum: the Apple clients offer every family CTFontManagerCopyAvailableFontFamilyNames reports and store the chosen name verbatim, so restricting this to the web's three generic families would invalidate the stored appearance of every user who picked a real font. Family names are not ASCII — ヒラギノ角ゴ ProN is a stock macOS family — so the pattern excludes rather than allowlists: no control characters, quotes, separators, parentheses or braces, which keeps a value safe to interpolate into CSS or a platform font lookup while accepting any real family name.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[^\\x00-\\x1f\"'(){};:,\\\\/ ][^\\x00-\\x1f\"'(){};:,\\\\/]*$"
|
||||
},
|
||||
"fontColor": { "$ref": "#/$defs/hexColor" },
|
||||
"backgroundColor": { "$ref": "#/$defs/hexColor" },
|
||||
"backgroundStyle": {
|
||||
"type": "string",
|
||||
"enum": ["box", "shadow", "outline", "none"]
|
||||
},
|
||||
"backgroundOpacity": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"textOutline": { "type": "boolean" },
|
||||
"textOutlineColor": { "$ref": "#/$defs/hexColor" },
|
||||
"position": {
|
||||
"type": "string",
|
||||
"enum": ["bottom", "lower-third", "top"]
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"hexColor": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/theme-var-overrides.json",
|
||||
"title": "Theme variable overrides",
|
||||
"description": "Sparse map of theme token to CSS value. Token names mirror web/src/lib/themeTokens.ts; values are bounded to keep this from becoming an untyped blob. The per-value bound is sized to what the web importer already accepts and stores: computed multi-stop gradients routinely pass 128 characters, so a tighter bound would invalidate themes users already imported.",
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$",
|
||||
"maxLength": 64
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"maxProperties": 256
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,27 @@ When the scope locks, this file becomes the source of truth and will contain:
|
||||
Until lock: treat any capability not tracked as `Proposed`/`Locked` on the project as out of scope
|
||||
for feature PRs (see the scope gate in `CLAUDE.md`).
|
||||
|
||||
## Breaking removals taken before lock
|
||||
|
||||
The additive-only rule in item 2 binds at lock. Before then a removal is in scope, and there is no
|
||||
amendment to write because the amendment process in item 3 does not exist yet. `CLAUDE.md` states
|
||||
the rule without that qualifier, which reads as a contradiction — it is not, but a removal taken
|
||||
now has to be recorded here so a reader after lock can tell a deliberate decision from a violation.
|
||||
|
||||
Each entry names what goes, why waiting is worse, and the design that decided it. **Every removal
|
||||
listed here must have shipped before the scope locks.** One still outstanding at lock loses its
|
||||
justification and falls back to the Deprecation/Sunset flow like anything else.
|
||||
|
||||
| Removed | Release | Rationale |
|
||||
|---|---|---|
|
||||
| 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
|
||||
`GET /api/v1/libraries/provider-defaults` rather than version sniffing. New capabilities
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,53 @@
|
||||
# Non-goals
|
||||
|
||||
Silo is an early WIP and most of its scope is open. This document is the short list of things
|
||||
that are **not** open — features that will not be accepted, in core, in a plugin, or in a client,
|
||||
regardless of implementation quality.
|
||||
|
||||
These are product-direction decisions, not "not yet" or "needs a better design." A well-written
|
||||
proposal or a working PR does not change the answer. Please read this before opening an issue or
|
||||
writing code in these areas, so nobody's time is wasted.
|
||||
|
||||
## Live TV, OTA tuners, IPTV, and remote stream shortcuts
|
||||
|
||||
**Out of scope permanently.** This covers, at minimum:
|
||||
|
||||
- Live TV of any kind, including OTA tuners (HDHomeRun and equivalents) and DVB devices.
|
||||
- IPTV: M3U/M3U8 playlist ingestion, provider portals, Xtream-style APIs, stream relays.
|
||||
- EPG/guide ingestion, XMLTV import, guide-provider sync, and channel lineup management.
|
||||
- DVR: scheduled recordings, series rules, tuner arbitration.
|
||||
- `.strm` files and any equivalent shortcut format whose contents are a remote media URL to be
|
||||
fetched, proxied, redirected to, or transcoded by the server.
|
||||
|
||||
### Why
|
||||
|
||||
**App store distribution risk.** Silo's first-party clients ship on the Apple and Google stores.
|
||||
Review of media apps in these categories is strict, and an app whose server plays arbitrary
|
||||
remote stream URLs or IPTV playlists is read as a conduit for unlicensed content no matter what
|
||||
the intended use is. The exposure is not scoped to the feature — a rejection or a takedown lands
|
||||
on the entire client suite, for every user, including those who only ever play files they own.
|
||||
No single feature is worth that trade.
|
||||
|
||||
**Product scope.** Silo indexes and plays media that exists on disk in a library the operator
|
||||
controls. Live TV is a different product with its own always-on reliability burden: tuner
|
||||
allocation, guide-provider contracts and merge semantics, schedule conflict resolution, and
|
||||
recording lifecycle. Building it competes directly with making the core playback path fast and
|
||||
correct, which is the stated priority.
|
||||
|
||||
**`.strm` is the same feature with a smaller diff.** A `.strm` file is a text file containing a
|
||||
remote URL. Supporting it turns the server into a fetcher/transcoder/redirector for arbitrary
|
||||
remote streams. The small change footprint is exactly what makes it worth naming explicitly here:
|
||||
it is the capability without the label.
|
||||
|
||||
### Not available as a plugin either
|
||||
|
||||
The plugin runtime is not an escape hatch for this. Anything in this list needs first-party
|
||||
client UI to be usable, which puts it back in the app store listings and reproduces the exact
|
||||
problem above. Not in core, not in a plugin, not in a client.
|
||||
|
||||
### What to do instead
|
||||
|
||||
Silo does not assume it is the only media server on the box. Run Jellyfin, Plex, or a dedicated
|
||||
tuner/IPTV backend alongside it for live TV, and use Silo for the on-disk library. That
|
||||
separation also keeps the risk where it belongs, on software whose distribution model can absorb
|
||||
it.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,198 @@
|
||||
# Canonical Settings API
|
||||
|
||||
The canonical settings API stores typed user preferences from the shared
|
||||
settings contract. Client implementations should discover the server contract
|
||||
before rendering controls or writing a value; do not keep a separate list of
|
||||
keys, scopes, enum members, or defaults.
|
||||
|
||||
All paths below are relative to `/api/v1`.
|
||||
|
||||
## Contract discovery
|
||||
|
||||
| Method and path | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /settings/manifest` | Public client projection of the current manifest. Supports `If-None-Match`. |
|
||||
| `GET /settings/capability` | Contract API version, revision, remote scopes, supported client families, and batch/write capabilities. |
|
||||
|
||||
`/settings/contract` and `/settings/contract/capabilities` are equivalent
|
||||
aliases. A client whose vendored contract is newer than the advertised server
|
||||
revision must hide definitions and features introduced after that revision.
|
||||
Navigation shortcut mutation controls additionally require
|
||||
`supports_atomic_shortcuts: true`. Revision-5 customization also requires
|
||||
`supports_batched_effective: true` and `supports_idempotent_writes: true` so
|
||||
batched resolution and replayed writes have the semantics the clients depend
|
||||
on. Any missing flag fails closed.
|
||||
|
||||
## Request identity headers
|
||||
|
||||
Authenticated settings routes use the active account and profile from the
|
||||
normal Silo session. The contextual headers below identify which client is
|
||||
resolving or writing an override.
|
||||
|
||||
| Header | When required | Meaning |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `X-Profile-Id` | All `/settings/values` routes | Active household profile. |
|
||||
| `X-Silo-Device-Id` | A `profile_device` explicit request, or an effective request containing a key that permits `profile_device` | Stable exact-device identity. |
|
||||
| `X-Silo-Client-Family` | Required for a `profile_client` explicit request; optional on effective reads | Closed like-client identity: `tv`, `mobile`, `tablet`, `desktop`, or `web`. A valid value includes the like-client layer; absence skips it. |
|
||||
| `X-Silo-Mutation-Id` | Optional on `PUT` | Idempotency key for safe retries. Reusing it for a different identity or value returns a conflict. |
|
||||
|
||||
Client family is intentionally independent of the free-form
|
||||
`X-Silo-Device-Platform` display metadata. The server never guesses one from
|
||||
the other. Send the exact lower-case family:
|
||||
|
||||
| Client | Family |
|
||||
| ----------------------- | --------- |
|
||||
| tvOS or Android TV | `tv` |
|
||||
| iPhone or Android phone | `mobile` |
|
||||
| iPad or Android tablet | `tablet` |
|
||||
| macOS | `desktop` |
|
||||
| Browser | `web` |
|
||||
|
||||
## Remote scopes
|
||||
|
||||
Every stored value has exactly one identity. Context fields not named by the
|
||||
selected scope must be absent.
|
||||
|
||||
| Scope | Identity after account |
|
||||
| ----------------- | ----------------------------- |
|
||||
| `account` | none |
|
||||
| `profile` | `profile_id` |
|
||||
| `profile_client` | `profile_id`, `client_family` |
|
||||
| `profile_device` | `profile_id`, `device_id` |
|
||||
| `profile_library` | `profile_id`, `library_id` |
|
||||
| `profile_series` | `profile_id`, `series_id` |
|
||||
|
||||
The manifest's `allowed_scopes` decides where each key may be written, and its
|
||||
`resolution_order` decides precedence. `profile_client` values roam only among
|
||||
like clients. For example, a TV value applies to tvOS and Android TV but not to
|
||||
a phone or browser.
|
||||
|
||||
## Explicit values
|
||||
|
||||
Use explicit endpoints to edit or clear one scope, without resolving inherited
|
||||
values:
|
||||
|
||||
- `GET /settings/values?keys=<csv>&scope=<scope>` returns every requested key
|
||||
with `is_set`; unset rows remain in the response.
|
||||
- `GET /settings/values/{key}?scope=<scope>` returns one stored value or `404`.
|
||||
- `PUT /settings/values/{key}?scope=<scope>` accepts `{"value": <typed JSON>}`.
|
||||
- `DELETE /settings/values/{key}?scope=<scope>` removes the row so inheritance
|
||||
applies again.
|
||||
|
||||
`library_id` and `series_id` are query parameters for their matching scopes.
|
||||
An explicitly managed device may be named with `device_id`, subject to the
|
||||
profile/device authorization checks. The self-service `profile_client` scope
|
||||
takes its family only from `X-Silo-Client-Family`.
|
||||
|
||||
Example: share a TV menu between tvOS and Android TV clients.
|
||||
|
||||
```http
|
||||
PUT /api/v1/settings/values/nav.primary_menu?scope=profile_client
|
||||
Authorization: Bearer <token>
|
||||
X-Profile-Id: <profile-id>
|
||||
X-Silo-Client-Family: tv
|
||||
Content-Type: application/json
|
||||
|
||||
{"value":{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":"Movies"}]}}
|
||||
```
|
||||
|
||||
Stored-value responses include `client_family` when the source or explicit row
|
||||
is at `profile_client`.
|
||||
|
||||
### Atomic navigation shortcuts
|
||||
|
||||
`nav.shortcuts` is a profile-wide catalog shared by TV, mobile, desktop, and
|
||||
web clients. Self-service clients must mutate one destination at a time instead
|
||||
of replacing that shared document:
|
||||
|
||||
```http
|
||||
PUT /api/v1/settings/values/nav.shortcuts/item
|
||||
Authorization: Bearer <token>
|
||||
X-Profile-Id: <profile-id>
|
||||
X-Silo-Mutation-Id: <stable-uuid-for-this-intent>
|
||||
Content-Type: application/json
|
||||
|
||||
{"item":{"type":"section","library_id":7,"section_id":"recent","label":"Recently Added"},"present":true}
|
||||
```
|
||||
|
||||
The item is one member of `navigation-shortcuts.json`: a library, section, or
|
||||
collection (whose `collection_id` is a string and whose `library_id` is
|
||||
optional). Identity is exactly the schema's semantic identity:
|
||||
|
||||
- library: `type + library_id`
|
||||
- section: `type + library_id + section_id`
|
||||
- collection: `type + optional library_id + collection_id`
|
||||
|
||||
`present: true` appends an absent item or refreshes the label of an existing
|
||||
identity in place without reordering it. `present: false` removes that identity;
|
||||
the supplied label is validated but ignored for matching. The server atomically
|
||||
rebases on concurrent edits, enforces the full schema and 256-item cap, and
|
||||
returns the normal stored-value object containing the complete resulting
|
||||
`value`, row `revision`, and `updated_at`. An already-satisfied operation is a
|
||||
successful no-op and does not increment the revision. Removing from a catalog
|
||||
that has never been stored returns `{"items":[]}` at revision `0` with no
|
||||
`updated_at`.
|
||||
|
||||
Retry with the same `X-Silo-Mutation-Id`. A recorded retry returns the original
|
||||
response with `X-Silo-Idempotent-Replay: true`; reusing the id for a different
|
||||
semantic operation returns `409 mutation_id_conflict`. The mutation ID is
|
||||
serialized before the setting write, and the setting plus its replay receipt
|
||||
commit in one database transaction, so concurrent reuse cannot apply twice and
|
||||
a crash cannot persist only one half. A rare exhausted
|
||||
contention loop returns retryable `409 setting_update_conflict`. Malformed
|
||||
envelopes or unknown envelope fields return `400 bad_request`, while item
|
||||
schema failures (including unknown item fields) and cap failures return
|
||||
`400 invalid_value`.
|
||||
|
||||
Ordinary session `PUT` and `DELETE` at
|
||||
`/settings/values/nav.shortcuts?scope=profile` are rejected with
|
||||
`400 atomic_update_required` so a whole-document mutation cannot erase
|
||||
concurrent item edits or reset the row revision. The admin endpoint retains
|
||||
whole-document `PUT` for explicit repair work, but rejects physical `DELETE`
|
||||
for the same revision-history reason. An admin clears the catalog with
|
||||
`{"value":{"items":[]}}`, which advances the row revision, or replaces it with
|
||||
another validated document.
|
||||
|
||||
## Effective values
|
||||
|
||||
- `GET /settings/values/effective?keys=<csv>` resolves several keys for the
|
||||
request profile, device, and client family. Omitting `keys` resolves all
|
||||
remote definitions.
|
||||
- `POST /settings/values/effective` resolves a bounded list of content contexts
|
||||
in one store read. Use it for grids or lists rather than issuing one request
|
||||
per item.
|
||||
|
||||
An effective request must include the device header when any requested
|
||||
definition permits an exact-device override. The family header is optional for
|
||||
backward compatibility: absence skips the `profile_client` layer, while a
|
||||
non-empty invalid family is rejected. First-party clients should send their
|
||||
family so like-device preferences participate in resolution. Each response
|
||||
includes its source scope and source context; `client_family` is included for a
|
||||
family-scoped winner.
|
||||
|
||||
## Admin projection
|
||||
|
||||
Admin routes are mounted behind the normal acting-admin authorization:
|
||||
|
||||
- `GET /admin/users/{id}/settings/values` lists every stored row across all
|
||||
scopes.
|
||||
- `PUT /admin/users/{id}/settings/values/{key}?scope=<scope>` writes through the
|
||||
same contract validation and normalization as the self-service endpoint.
|
||||
- `DELETE /admin/users/{id}/settings/values/{key}?scope=<scope>` clears the
|
||||
selected row.
|
||||
|
||||
Admin requests name profile/context identity with query parameters because the
|
||||
target user is not the admin's active session. In particular,
|
||||
`scope=profile_client` requires both `profile_id` and `client_family` query
|
||||
parameters; it does not use `X-Silo-Client-Family`.
|
||||
|
||||
```http
|
||||
PUT /api/v1/admin/users/42/settings/values/ui.card_presentation?scope=profile_client&profile_id=main&client_family=tv
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"value":{"poster_size":"large","caption":"artwork"}}
|
||||
```
|
||||
|
||||
The five accepted `client_family` values are also returned by the capability
|
||||
endpoint so admin tooling does not need to invent them.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Implementation Plan: Emailed Invitations & Server-Driven Onboarding
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md`
|
||||
**Mockups:** `docs/design/invite-onboarding.html`
|
||||
**Date:** 2026-07-27
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Shape of the work
|
||||
|
||||
Two features that share a journey but not a dependency. **Part 1
|
||||
(invitations) and Part 2 (onboarding) are independently shippable** — build
|
||||
and merge them in that order, but neither blocks the other if priorities
|
||||
shift.
|
||||
|
||||
Phases 1–4 are silo-server and ship as one PR each. Phases 5–6 are the client
|
||||
repos and follow after the server contract is merged and stable.
|
||||
|
||||
Resolve the spec's three open questions before starting Phase 1 — expiry
|
||||
configurability, replay entry point, and `tour_id` re-prompt semantics all
|
||||
affect the schema.
|
||||
|
||||
---
|
||||
|
||||
# Part 1 — Invitations (silo-server)
|
||||
|
||||
## Phase 1 — Migration
|
||||
|
||||
`make migrate-create NAME=invitations` — one timestamped Goose migration, no
|
||||
hand-numbering, no paired up/down files.
|
||||
|
||||
Up: the `invitations` table exactly as specced, plus the two indexes. The
|
||||
partial unique index is the load-bearing one:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX invitations_one_pending_idx ON public.invitations (email)
|
||||
WHERE accepted_at IS NULL AND revoked_at IS NULL;
|
||||
```
|
||||
|
||||
It is what makes "resend supersedes" true at the database level rather than
|
||||
only in application code.
|
||||
|
||||
Down: `DROP TABLE public.invitations;`.
|
||||
|
||||
Verify with `make migrate-up && make migrate-status`.
|
||||
|
||||
## Phase 2 — Backend: model, repo, mail, service
|
||||
|
||||
New package `internal/invitations`. It owns the whole feature; nothing goes in
|
||||
a catch-all helper.
|
||||
|
||||
**`internal/models/invitation.go`** — `Invitation`, `CreateInvitationInput`,
|
||||
and a derived `Status()` method returning `pending|accepted|expired|revoked`
|
||||
computed from the timestamps. No status column.
|
||||
|
||||
**`internal/invitations/repository.go`** — CRUD plus:
|
||||
- `Create` — mints the token, stores only the SHA-256 hex. Returns the raw
|
||||
token to the caller once and never again.
|
||||
- `GetByTokenHash` — the claim lookup.
|
||||
- `Accept(ctx, tokenHash, userID)` — `UPDATE ... WHERE token_hash = $1 AND
|
||||
accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now()`. Zero
|
||||
rows affected means already-used/expired/revoked; the caller distinguishes
|
||||
by re-reading. This is the concurrency guard for double-submit.
|
||||
- `Revoke`, `List`.
|
||||
|
||||
Mirror the token helpers in `internal/notifications/email_address.go`
|
||||
(`newEmailToken` / `hashEmailToken`) rather than reinventing them — consider
|
||||
lifting them to a shared spot if the duplication grates, but do not
|
||||
restructure `notifications` as a side effect of this work.
|
||||
|
||||
**`internal/invitations/email.go`** — `composeInvitationEmail`, built from
|
||||
`mail.RenderLayout` / `EmailParagraph` / `EmailButton`
|
||||
(`internal/mail/layout.go:88`). Escape the admin note. Link base resolution
|
||||
reuses the `notifications.email.external_url` → public URL precedence.
|
||||
|
||||
**`internal/invitations/service.go`** — orchestration:
|
||||
- `Send` — validates the address, supersedes any pending invite for it,
|
||||
creates, sends. On `mail.ErrNotConfigured`, returns the row plus the claim
|
||||
URL and `emailSent=false` rather than erroring. Rejects a `role: admin`
|
||||
invitation from a non-admin inviter (enforce at the service, not the UI).
|
||||
- `Lookup` — claim-screen projection only: inviter display name, email,
|
||||
expiry. Nothing else leaves the server.
|
||||
- `Accept` — one transaction: `auth.AccountProvisioner.CreateAccount`
|
||||
(`internal/auth/account_provisioner.go`, which already rolls back the user if
|
||||
profile creation fails) with the pre-bound role/group/libraries, then
|
||||
`repository.Accept`, then log in via the existing session path so the
|
||||
response is a normal `TokenPair`.
|
||||
|
||||
**Login fallback** — `internal/auth/provider.go:56`. `LocalProvider.Authenticate`
|
||||
currently calls `GetByUsername` only. Add: on not-found, if the input parses as
|
||||
an email address (`net/mail.ParseAddress`), retry `GetByEmail`. Keep the
|
||||
password comparison path identical so timing does not diverge between the two
|
||||
lookups.
|
||||
|
||||
Tests:
|
||||
- Accept twice concurrently → exactly one account, one 4xx.
|
||||
- Accept an expired invitation → refused, no user row.
|
||||
- Resend invalidates the prior token.
|
||||
- Email login fallback resolves; username login is unchanged.
|
||||
- Non-admin inviter cannot mint an admin invitation.
|
||||
|
||||
## Phase 3 — Backend: HTTP
|
||||
|
||||
**`internal/api/handlers/admin_invitations.go`** — list/create/resend/revoke,
|
||||
following `admin_invite_codes.go` for shape and error mapping.
|
||||
|
||||
**`internal/api/handlers/invitations.go`** — the two public endpoints.
|
||||
`GET /invitations/{token}` returns an identical 404 for unknown, expired,
|
||||
revoked, and accepted; do not leak which.
|
||||
|
||||
**`internal/api/router.go`**:
|
||||
- Admin routes beside `/invite-codes` (~line 2891), same admin gate.
|
||||
- Public routes beside `/auth`, wrapped in
|
||||
`deps.RateLimitMW.AuthEndpointHandler("invitation")` where the limiter is
|
||||
present, matching the `login`/`signup` treatment at lines 1708–1716.
|
||||
|
||||
`accept` returns `buildLoginResponse` — the same shape as signup — so no
|
||||
client grows a parallel auth path.
|
||||
|
||||
## Phase 4 — Web: admin tab + claim page
|
||||
|
||||
**`web/src/pages/admin-settings/InvitationsTab.tsx`** — table plus composer
|
||||
dialog, per the mockup. Follow `InviteCodesTab.tsx` for structure and the
|
||||
`useAdminInviteCodes` hooks for the query layer. Register the tab in
|
||||
`AdminUsers.tsx` beside the existing `invite-codes` tab.
|
||||
|
||||
When the create response carries `email_sent: false`, swap the success toast
|
||||
for a copyable link and say plainly that email is not configured.
|
||||
|
||||
**`web/src/pages/InviteClaim.tsx`** — public route `/invite/:token`, added to
|
||||
`App.tsx` beside `/signup` (line 373). Reuse `auth-shell`, `AuthBackground`,
|
||||
and the `PasswordInput` component. Expired/used/revoked renders an explanatory
|
||||
card with a sign-in link, never a bare 404.
|
||||
|
||||
On success, store the returned tokens through the existing auth hook and route
|
||||
onward — do not bounce the user back to `/login`.
|
||||
|
||||
**`web/src/pages/HouseholdSetup.tsx`** — the "Who's watching?" step, routed to
|
||||
directly after a successful accept. **No new backend**: each tile posts through
|
||||
the existing `POST /profiles` mutation, whose request already carries name,
|
||||
avatar, PIN, `is_child`, `max_content_rating`, and per-profile library
|
||||
restrictions (`createProfileRequest`, `internal/api/handlers/profiles.go:50`).
|
||||
|
||||
- The first tile is the invitee's own profile, pre-named from the email local
|
||||
part, renameable inline.
|
||||
- The add-profile dialog per the mockup: kids toggle reveals rating ceiling
|
||||
(default PG) + library picker; PIN toggle carries the "pin the adults, not
|
||||
the kids" copy.
|
||||
- "Just me for now" exits without creating anything.
|
||||
- Reuse the avatar preset picker and PIN dialog pieces from
|
||||
`ProfilesSettings.tsx` rather than duplicating them — extract shared
|
||||
components if needed.
|
||||
|
||||
Add `Invitation` types to `web/src/api/types.ts` and hooks under
|
||||
`web/src/hooks/queries/admin/invitations.ts`.
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Onboarding (silo-server, then clients)
|
||||
|
||||
## Phase 5 — Backend: manifest + state
|
||||
|
||||
**userdb schema** — add `profile_onboarding` keyed `(profile_id, tour_id)`
|
||||
with `last_step`, `completed_at`, `skipped_at`. This needs a
|
||||
`schemaVersion` bump in `internal/userdb/migrate.go` (currently 13) plus the
|
||||
matching migration step; follow how the existing per-profile tables
|
||||
(`subtitle_preferences`, `series_playback_preferences`) were added. Do **not**
|
||||
use `user_settings` — it is keyed by `key` alone and therefore account-wide,
|
||||
so one profile finishing would silence the tour for the whole household.
|
||||
|
||||
**`internal/onboarding`** — new package:
|
||||
- `steps.go` — the manifest as Go data. Copy lives here.
|
||||
- `filter.go` — per-server and per-surface filtering. Query the real feature
|
||||
gates: `requests_enabled` via `internal/requests`, watch-together, whether
|
||||
`mail.Sender.Enabled()` or push is configured, recommendations. Drop
|
||||
`spotlight` and any text-entry step for `surface=tv`.
|
||||
- `state.go` — read/write via the userdb store.
|
||||
|
||||
**Handlers + routes** — `GET /onboarding/flow`, `GET /onboarding/state`,
|
||||
`POST /onboarding/progress`, all profile-scoped (`apimw.RequireProfile`, as the
|
||||
`/settings/effective` group does at router.go:2242).
|
||||
|
||||
The manifest must only emit a `setting_choice` the current profile may write —
|
||||
check child-profile restrictions before including a step, rather than letting
|
||||
the client discover the refusal.
|
||||
|
||||
Tests: a server with requests disabled omits the requests step; `surface=tv`
|
||||
omits spotlights; completing on one profile does not mark another complete.
|
||||
|
||||
## Phase 6 — Web: the tour
|
||||
|
||||
**`web/src/components/onboarding/`** — `TourHost` (the state machine plus
|
||||
progress POSTs) and one renderer per step kind. Unknown `kind` → skip, no
|
||||
error. This is the compatibility guarantee; test it explicitly with a
|
||||
fabricated future kind.
|
||||
|
||||
`setting_choice` dispatches on `target`:
|
||||
- `profile_field` → `PUT /profiles/{id}` via the existing update mutation
|
||||
(see `saveProfileField`, `web/src/pages/settings/PlaybackSettings.tsx:114`)
|
||||
- `setting` → the existing `useSetSetting`
|
||||
- `device_setting` → the existing device-setting mutation
|
||||
|
||||
Reusing those mutations means the tour writes the same rows the settings
|
||||
screens write and inherits their cache invalidation for free.
|
||||
|
||||
**Entry** — extend the existing `TasteSeedGate` in `App.tsx` (line 234) into an
|
||||
onboarding gate that checks server state first, runs the tour, then hands off
|
||||
to `/taste-seed` via the `handoff` step. The tour precedes taste-seed rather
|
||||
than replacing it.
|
||||
|
||||
**Replay** — an entry point in Settings → Personalize (pending the spec's open
|
||||
question 2).
|
||||
|
||||
## Phase 7 — Android (silo-android)
|
||||
|
||||
Separate repo, separate PR, after the server contract is merged.
|
||||
|
||||
- Models + client method for the three onboarding endpoints in
|
||||
`shared/src/commonMain/kotlin/.../model`, alongside the existing auth models.
|
||||
- Compose tour UI in `androidApp/.../ui/screens/onboarding/`: a full-screen
|
||||
`HorizontalPager`, one step per page, skip always reachable. Unknown kinds
|
||||
filtered out at parse time.
|
||||
- Invite deep link: add an `invite` host to the existing `silo` scheme
|
||||
`intent-filter` in `AndroidManifest.xml` (lines 49–60) plus an `https` App
|
||||
Link, and a claim screen beside `SignupScreen.kt`. Parse via the existing
|
||||
`ContentDeepLinkRoutes` machinery.
|
||||
- Household setup screen after claim: profile tiles + add-profile sheet,
|
||||
posting through the existing profiles endpoint. The kid preset mirrors the
|
||||
web dialog (rating ceiling + libraries).
|
||||
- TV (`androidTvApp`) requests `surface=tv` and renders the focus-driven
|
||||
variant — no keyboard entry.
|
||||
|
||||
## Phase 8 — Apple (silo-apple)
|
||||
|
||||
Separate repo, separate PR. **Build and validate on `mac-builder` through the
|
||||
`xcodebuildmcp` MCP server**, preserving the exact commit plus dirty and
|
||||
untracked state — do not validate against a stale remote checkout.
|
||||
|
||||
- Networking models beside `DeviceLoginModels.swift`.
|
||||
- `Screens/Onboarding/` — SwiftUI `TabView(.page)` tour reusing the Aurora
|
||||
design system (`AuroraScreen`, `AuroraPrimaryButtonStyle`). Unknown kinds
|
||||
filtered at decode.
|
||||
- Claim screen in `Screens/Auth/` beside `SignupView.swift`, reusing
|
||||
`AuroraJourneyProgress` (`DesignSystem/Aurora/AuroraStyle.swift:61`) with the
|
||||
invite path as step 2. Note `AuroraJourneyProgress` hardcodes
|
||||
`["Server", "Account", "Profile"]` — it needs a labels parameter for the
|
||||
invite journey (`Server → Password → Household`).
|
||||
- Household setup after claim, adjacent to the existing
|
||||
`Screens/Profiles/ProfileSelectionView.swift`: tiles + add-profile sheet
|
||||
posting through the existing profiles endpoint, kid preset matching web.
|
||||
- Deep link: add an `invite` case to the `onOpenURL` handler in `iOSApp.swift`
|
||||
(line 51), which already queues links until auth settles. Universal links
|
||||
additionally need the server to serve
|
||||
`/.well-known/apple-app-site-association` — a small server-side follow-up,
|
||||
not a blocker for the custom scheme.
|
||||
- tvOS requests `surface=tv`.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Per server PR:
|
||||
|
||||
```bash
|
||||
make lint
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
Plus `make migrate-up` / `make migrate-status` for Phases 1 and 5, and browser
|
||||
verification of the web flows via the `web-ui-testing` skill with screenshots
|
||||
for the PR.
|
||||
|
||||
## Commit sequence
|
||||
|
||||
One PR per phase, Conventional Commit subjects, one concern each:
|
||||
|
||||
| Phase | Repo | Subject |
|
||||
| --- | --- | --- |
|
||||
| 1–3 | silo-server | `feat(invitations): add emailed pre-provisioned invitations` |
|
||||
| 4 | silo-server | `feat(web): add invitation admin tab and claim page` |
|
||||
| 5 | silo-server | `feat(onboarding): add server-driven onboarding manifest` |
|
||||
| 6 | silo-server | `feat(web): add first-run feature tour` |
|
||||
| 7 | silo-android | `feat(onboarding): add invite claim and feature tour` |
|
||||
| 8 | silo-apple | `feat(onboarding): add invite claim and feature tour` |
|
||||
|
||||
Phases 1–3 are one PR: the migration, repo, service, and handlers are a single
|
||||
coherent unit and splitting them leaves a table nothing reads.
|
||||
|
||||
Each PR needs `Part of #NNN` against the capability epic, the AI-use disclosure
|
||||
block per `docs/ai-contributions.md`, and screenshots for the UI phases.
|
||||
|
||||
## Risks
|
||||
|
||||
**Login lookup change (Phase 2)** is the highest-risk item — it touches the
|
||||
path every existing user authenticates through. It is additive (fallback only
|
||||
on miss), but it warrants explicit regression tests for username login and a
|
||||
careful review.
|
||||
|
||||
**userdb schema bump (Phase 5)** changes per-user SQLite stores. Follow the
|
||||
existing migration test pattern (`internal/userdb/migrate_v13_test.go`) and
|
||||
verify an older store upgrades cleanly.
|
||||
|
||||
**Client contract drift** is bounded by "unknown kinds are skipped" — but that
|
||||
only holds if each client actually implements the skip. Test it on all three
|
||||
before the first post-launch manifest change, not after.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Transcode Resolution Clamp Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Prevent encoded-video transports from targeting a resolution above the final effective source file.
|
||||
|
||||
**Architecture:** Add one pure resolution-normalization helper in the playback API handler and invoke it exactly once after alternate-file selection. Mutate the request's target resolution at that boundary so remote transport, local transport, persistence, activity reporting, and reconstruction consume the same normalized recipe.
|
||||
|
||||
**Tech Stack:** Go, `net/http` handler tests, Silo playback session and transcode-node test doubles.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not apply the new clamp to video-copy requests; preserve their existing
|
||||
downstream recipe normalization.
|
||||
- Preserve empty or unrecognized requested/source resolutions unchanged.
|
||||
- Clamp only recognized encoded-video targets above the recognized effective source.
|
||||
- Do not alter bitrate, codecs, API shapes, status codes, settings, migrations, clients, or production configuration.
|
||||
- Prove the regression RED before writing production code.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Normalize the Effective Transcode Recipe
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/playback.go`
|
||||
- Test: `internal/api/handlers/playback_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `transcodeStartRequest.TargetResolution`, `transcodeStartRequest.TargetCodecVideo`, and `models.MediaFile.Resolution` after alternate-file selection.
|
||||
- Produces: `clampEncodedTargetResolution(requestedResolution, sourceResolution string) string`.
|
||||
- Produces: one normalized `req.TargetResolution` consumed unchanged by remote/local transport construction and `buildTranscodeSessionReplacement`.
|
||||
|
||||
- [ ] **Step 1: Strengthen the existing remote fallback test**
|
||||
|
||||
Change `TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback` to
|
||||
request 2160p at 10000 kbps. Assert that the captured
|
||||
`transcodenode.TranscodeStartRequest.TargetResolution` is `1080p`, while the
|
||||
effective alternate remains file 99 and the resulting playback session stores
|
||||
`TargetResolution == "1080p"`.
|
||||
|
||||
```go
|
||||
strings.NewReader(`{"session_id":"` + startResp.SessionID +
|
||||
`","seek_seconds":0,"target_resolution":"2160p",` +
|
||||
`"target_codec_video":"h264","target_codec_audio":"aac",` +
|
||||
`"target_bitrate_kbps":10000,"segment_duration":2,` +
|
||||
`"subtitle_track_index":-1,"subtitle_burn_in":false}`)
|
||||
|
||||
if remoteStartReq.TargetResolution != "1080p" {
|
||||
t.Fatalf("remote target resolution = %q, want 1080p", remoteStartReq.TargetResolution)
|
||||
}
|
||||
if session.TargetResolution != "1080p" {
|
||||
t.Fatalf("session target resolution = %q, want 1080p", session.TargetResolution)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the regression and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because the captured remote request still contains `2160p`.
|
||||
|
||||
- [ ] **Step 3: Add pure normalization table tests**
|
||||
|
||||
Add `TestClampEncodedTargetResolution` beside the handler tests. Cover:
|
||||
|
||||
```go
|
||||
tests := []struct {
|
||||
name, requested, source, want string
|
||||
}{
|
||||
{"clamps 2160p to 1080p", "2160p", "1080p", "1080p"},
|
||||
{"keeps lower target", "720p", "1080p", "720p"},
|
||||
{"keeps equal target", "1080p", "1080p", "1080p"},
|
||||
{"keeps empty target", "", "1080p", ""},
|
||||
{"keeps unknown target", "source", "1080p", "source"},
|
||||
{"keeps target for unknown source", "2160p", "native", "2160p"},
|
||||
{"supports low tiers", "480p", "420p", "420p"},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the minimal helper and post-selection call**
|
||||
|
||||
Add a package-level recognized-height map/helper near `resolutionRank`:
|
||||
|
||||
```go
|
||||
func clampEncodedTargetResolution(requestedResolution, sourceResolution string) string {
|
||||
requestedHeight, requestedKnown := transcodeResolutionHeight(requestedResolution)
|
||||
sourceHeight, sourceKnown := transcodeResolutionHeight(sourceResolution)
|
||||
if !requestedKnown || !sourceKnown || requestedHeight <= sourceHeight {
|
||||
return requestedResolution
|
||||
}
|
||||
return sourceResolution
|
||||
}
|
||||
```
|
||||
|
||||
After the 4K alternate block and before transport planning:
|
||||
|
||||
```go
|
||||
if !videoCopy {
|
||||
req.TargetResolution = clampEncodedTargetResolution(
|
||||
req.TargetResolution,
|
||||
file.Resolution,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Use an exhaustive switch for the six supported transcode tiers. Do not modify
|
||||
the FFmpeg filter builder or web client.
|
||||
|
||||
- [ ] **Step 5: Verify focused GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers \
|
||||
-run 'TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback|TestClampEncodedTargetResolution' \
|
||||
-count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Add copy and local-boundary regression coverage**
|
||||
|
||||
Add focused tests proving:
|
||||
|
||||
- a copy-video request bypasses the new clamp and retains its existing
|
||||
downstream empty-resolution recipe;
|
||||
- a local encoded-video request receives the clamped target in the persisted
|
||||
session and generated FFmpeg recipe boundary;
|
||||
- a requested 720p encode for a 1080p effective file remains 720p.
|
||||
|
||||
Use the existing local fake-FFmpeg and session-manager patterns in
|
||||
`playback_test.go`; do not introduce production test hooks.
|
||||
|
||||
- [ ] **Step 7: Run focused and package verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gofmt -w internal/api/handlers/playback.go internal/api/handlers/playback_test.go
|
||||
go test ./internal/api/handlers -count=1
|
||||
go test ./internal/playback -count=1
|
||||
go test ./internal/api/handlers ./internal/playback -race -count=1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: all commands exit 0.
|
||||
|
||||
- [ ] **Step 8: Commit implementation**
|
||||
|
||||
```bash
|
||||
git add internal/api/handlers/playback.go internal/api/handlers/playback_test.go
|
||||
git commit -m "fix(playback): prevent transcode resolution upscaling"
|
||||
```
|
||||
|
||||
### Task 2: Independent Review and Final Verification
|
||||
|
||||
**Files:**
|
||||
- Review: `docs/superpowers/specs/2026-07-28-transcode-resolution-clamp-design.md`
|
||||
- Review: `internal/api/handlers/playback.go`
|
||||
- Review: `internal/api/handlers/playback_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: committed spec and implementation diff from `upstream/main`.
|
||||
- Produces: reviewer verdict and a clean, verified branch suitable for a separate pull request.
|
||||
|
||||
- [ ] **Step 1: Request focused correctness/security review**
|
||||
|
||||
Review the change against these invariants:
|
||||
|
||||
- no encoded output exceeds the effective source tier;
|
||||
- copy and unknown-resolution compatibility is preserved;
|
||||
- local, offloaded, persisted, and reconstruction paths share one value;
|
||||
- no target-bitrate or API behavior was broadened;
|
||||
- tests fail without the production correction.
|
||||
|
||||
- [ ] **Step 2: Address Critical or Important findings test-first**
|
||||
|
||||
For each accepted behavioral finding, add or strengthen a failing test, observe
|
||||
RED, make the smallest production correction, and rerun focused GREEN. Do not
|
||||
bundle unrelated cleanup.
|
||||
|
||||
- [ ] **Step 3: Run final verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers ./internal/playback -count=1
|
||||
go test ./internal/api/handlers ./internal/playback -race -count=1
|
||||
go test ./... -count=1
|
||||
make verify-local-paths
|
||||
git diff --check
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
Expected: tests and repository policy checks exit 0; status contains no
|
||||
uncommitted implementation changes.
|
||||
|
||||
- [ ] **Step 4: Prepare handoff**
|
||||
|
||||
Summarize the production evidence, exact normalization boundary, RED/GREEN
|
||||
proof, focused/full verification, reviewer verdict, and deployment caveat.
|
||||
Do not deploy or mutate production as part of this plan.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,510 @@
|
||||
# User-Facing Device Settings Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
>
|
||||
> Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Let a person see and change the device settings for every device they watch on, from whichever device they are holding — and let the household parent do the same for everyone on the account.
|
||||
|
||||
**Architecture:** No new storage and no schema change. `user_devices` and `user_setting_values` are already keyed `(user_id, profile_id, device_id, …)` and both list queries are already account-wide, so the work is authorization plus routes plus UI. Two identity widenings on the existing canonical settings API, each behind a guard: a caller may name a `device_id` other than the request's own (checked against `user_devices`), and a household parent may name a `profile_id` other than their own (checked by the existing `canManageHouseholdProfiles`). One new self-service device registry endpoint, deliberately profile-filtered by default. One new settings page reusing `SettingsGroup`/`SettingRow`.
|
||||
|
||||
**Tech Stack:** Go, `net/http` handler tests, `internal/userstore/storetest` conformance suite, React 19 + react-router v7 + TanStack Query, Vitest + Testing Library, shadcn/ui primitives.
|
||||
|
||||
**Design source:** `docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md`. Mockups were reviewed out-of-band; the shipping shape is the "B2 + B3" pair — a searchable device list with an editable detail pane, plus a household scope switch for the primary profile.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Additive only.** No existing response field is renamed, retyped, or removed, and no status code is repurposed. New behavior arrives as new endpoints or new optional query parameters. See `CLAUDE.md` "v1 API rules".
|
||||
- **The header stays the default.** When `device_id` / `profile_id` are absent from the query, every existing route must behave exactly as it does today. Existing clients must not need a change.
|
||||
- **No new hand-written setting metadata.** Labels, descriptions, controls, options and bounds come from `contracts/settings/v1/manifest.json` via the generated `web/src/lib/settingsContract.ts`. A per-key table beside the generated one is exactly the drift the contract exists to remove.
|
||||
- **Never render raw setting keys** in user-facing UI.
|
||||
- **Scope wording is mandated** by the design spec: "this device, for your profile only". Do not invent alternatives such as "global", "default", or a bare "this device".
|
||||
- **Restrictions are not preferences.** Policy caps are explained with the permitted value and the reason; they are never rendered as a disabled control with no explanation, and this screen never authors a restriction.
|
||||
- Prove each authorization regression RED before writing production code.
|
||||
- Do not edit this plan file while implementing it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Server: identity widening and the device registry
|
||||
|
||||
### Task 1: Reject a device the caller does not own
|
||||
|
||||
`completeIdentity` validates an identity's *shape* but never that a `profile_device` identity names a device belonging to the caller. That is safe today only because `DeviceID` is taken from the request's own header. Task 2 removes that guarantee, so the check lands first.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/settings_values.go`
|
||||
- Modify: `internal/userstore/store.go`
|
||||
- Modify: `internal/userstore/pgstore/settings.go`
|
||||
- Modify: `internal/userdb/settings.go` (the per-user SQLite backend)
|
||||
- Test: `internal/api/handlers/settings_values_test.go`
|
||||
- Test: `internal/userstore/storetest/settingvalues.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `DeviceRegistry.DeviceExists(ctx, profileID, deviceID string) (bool, error)` — a targeted existence check rather than a full `ListDevices` scan on every write.
|
||||
- Produces: a device-ownership guard invoked from `completeIdentity` for `ScopeProfileDevice`.
|
||||
|
||||
- [ ] **Step 1: Write the failing ownership test**
|
||||
|
||||
Add `TestSetValue_RejectsDeviceNotOwnedByCaller` to `settings_values_test.go`: register device `dev-a` for the caller, then `PUT /settings/values/player.hdr_enabled?scope=profile_device&device_id=dev-someone-else`. Expect `404` with error code `not_found` — not `403`, which would confirm the device id exists.
|
||||
|
||||
Because Task 2 has not landed, the query parameter is ignored and the write silently succeeds against the header device. Assert on the *stored* row, so the test fails for the right reason:
|
||||
|
||||
```go
|
||||
if got := storedDeviceIDFor(t, store, "player.hdr_enabled"); got != "" {
|
||||
t.Fatalf("wrote a row for device %q; want no write", got)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and verify RED**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -run TestSetValue_RejectsDeviceNotOwnedByCaller -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `DeviceExists` to the store interface and both backends**
|
||||
|
||||
Postgres: `SELECT EXISTS(SELECT 1 FROM user_devices WHERE user_id = $1 AND profile_id = $2 AND device_id = $3)`. SQLite: the same without `user_id`, matching the existing `ListDevices` asymmetry (one DB per user). Add the case to the shared conformance suite in `internal/userstore/storetest/settingvalues.go` so both backends are held to it.
|
||||
|
||||
- [ ] **Step 4: Enforce it in `completeIdentity`**
|
||||
|
||||
For `ScopeProfileDevice`, when the device id did **not** come from the request header, verify it exists for `(profileID, deviceID)`. A device that is unknown returns 404 `not_found`. Registering-on-write stays the behavior for the caller's own header device, so a brand-new device can still store its first value.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ ./internal/userstore/... -run 'Device|SettingValue' -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Accept an explicit `device_id` on the self-service settings routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/settings_values.go`
|
||||
- Test: `internal/api/handlers/settings_values_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: optional `device_id` query parameter on `GET|PUT|DELETE /settings/values/{key}` and `GET /settings/values` when `scope=profile_device`.
|
||||
- Produces: `identityForSessionKey` resolving `DeviceID` from the query when present, else from `X-Silo-Device-Id` exactly as today.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Three cases in `settings_values_test.go`:
|
||||
- `TestSetValue_WritesNamedDevice` — register `dev-b`, `PUT …?scope=profile_device&device_id=dev-b` from a request whose header is `dev-a`; assert the stored row is on `dev-b` and `dev-a` has none.
|
||||
- `TestGetValues_ReadsNamedDevice` — same shape for the read path.
|
||||
- `TestSetValue_FallsBackToHeaderDevice` — no `device_id` in the query writes the header device. This is the regression guard for every existing client.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -run 'NamedDevice|FallsBackToHeaderDevice' -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `identityForSessionKey`, prefer a non-empty `device_id` from the query and fall through to `deviceMetadataFromRequest(r).DeviceID`. Update the comment at the profile assignment so it still describes reality: the profile remains session-derived here; only the device may be named. Keep the existing 400 when neither source yields a device id.
|
||||
|
||||
Do **not** call `registerWritingDevice` for a named device — registration is a statement that *this* device is in use, and a remote write is not that.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN, then run the whole handler package**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `GET /api/v1/devices` — list your own devices
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/api/handlers/devices.go`
|
||||
- Create: `internal/api/handlers/devices_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GET /api/v1/devices` → `{"devices":[{device_id, device_name, device_platform, last_seen_at, profile_id, profile_name, is_current_device, changed_count}]}`.
|
||||
- Consumes: `DeviceRegistry.ListDevices`, `ListAllSettingValues`, `X-Silo-Device-Id`.
|
||||
|
||||
**The trap this task exists to avoid:** `ListDevices` is account-wide in *both* backends — `WHERE user_id = $1` in `internal/userstore/pgstore/settings.go:115`, and no `WHERE` clause at all in `internal/userdb/settings.go:112` because there is one DB per user. `profile_id` is a selected column, never a predicate. A naive passthrough would show every household member's devices to everyone. The handler must filter.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
- `TestListDevices_FiltersToCallingProfile` — seed devices for profile A and profile B, call as A, assert only A's are returned. **This is the security test; write it first.**
|
||||
- `TestListDevices_CountsChangedSettings` — `changed_count` equals the number of `profile_device` rows for that `(profile, device)`.
|
||||
- `TestListDevices_MarksCurrentDevice` — the device matching `X-Silo-Device-Id` has `is_current_device: true`.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -run TestListDevices -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement the handler**
|
||||
|
||||
Filter `ListDevices` output to `apimw.GetProfileID(ctx)`. Derive `changed_count` from `ListAllSettingValues` filtered to `scope == profile_device` and the same profile — one store round trip, not one per device. `profile_name` comes from the existing `listProfileNamesByID` helper pattern in `internal/api/handlers/admin.go`.
|
||||
|
||||
- [ ] **Step 4: Register the route**
|
||||
|
||||
In `internal/api/router.go`, inside the authenticated group, `r.With(apimw.RequireProfile).Get("/devices", devicesHandler.HandleListDevices)`. Place it away from the `/devices/push/apple` line so the two device namespaces stay visibly distinct.
|
||||
|
||||
- [ ] **Step 5: Run and verify GREEN**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -run TestListDevices -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Forget a device, and bulk-clear one device
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/devices.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `internal/userstore/store.go` (if a targeted delete is missing)
|
||||
- Test: `internal/api/handlers/devices_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `DELETE /api/v1/devices/{device_id}` — forget: clears settings **and** the registry row.
|
||||
- Produces: `DELETE /api/v1/devices/{device_id}/settings` — clear overrides, keep the device.
|
||||
|
||||
The design spec requires Forget device (`…-design.md:416`) and lists it as outstanding (`:1202`). `DeleteAllDeviceSettings` exists and clears both storage generations but is reachable only through profile deletion today. Bulk clear also fixes the admin screen's 30-sequential-DELETE loop in `web/src/hooks/queries/admin/users.ts:451`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
- `TestForgetDevice_RemovesSettingsAndRegistryRow`
|
||||
- `TestForgetDevice_RejectsOtherProfilesDevice` → 404
|
||||
- `TestClearDeviceSettings_KeepsRegistryRow`
|
||||
- `TestForgetDevice_IsIdempotent` — a second call returns 204, not 500
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -run 'ForgetDevice|ClearDeviceSettings' -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement both routes**
|
||||
|
||||
Reuse `DeleteSettingValuesForDevice` (`internal/userstore/store.go:216`) and `DeleteAllDeviceSettings`. Publish `user_settings.changed` once per cleared key so other devices invalidate — or once for the device if a batch event shape is added; do not skip the event.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ ./internal/userstore/... -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Server: the household tier
|
||||
|
||||
### Task 5: Extract the household-parent guard
|
||||
|
||||
`canManageHouseholdProfiles` (`internal/api/handlers/profiles.go:146`) already encodes exactly the right rule — server admin, or an `is_primary` active profile, and when that profile has a PIN a verified `X-Profile-Token` so sending only `X-Profile-Id` cannot bypass the profile lock. It is a method on `ProfileHandler`, so `SettingValuesHandler` cannot call it.
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/api/handlers/household.go`
|
||||
- Modify: `internal/api/handlers/profiles.go`
|
||||
- Modify: `internal/api/handlers/settings_values.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Test: `internal/api/handlers/household_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `canManageHousehold(r *http.Request, store userstore.UserStore, tokens ProfileTokenValidator) (bool, error)` — a package-level function.
|
||||
- `ProfileHandler.canManageHouseholdProfiles` becomes a thin wrapper so its four existing call sites and their behavior are untouched.
|
||||
- `SettingValuesHandler` gains a `ProfileTokens` field, wired in `internal/api/router.go` next to `profileHandler.ProfileTokens = profileTokenService` (`router.go:812`).
|
||||
|
||||
- [ ] **Step 1: Characterization tests before moving anything**
|
||||
|
||||
In `household_test.go`, cover: admin → true; primary without PIN → true; primary with PIN and no token → `access.ErrProfileUnverified`; primary with PIN and valid token → true; non-primary → false; no active profile → false. Run them against the *existing* method first so the extraction is provably behavior-preserving.
|
||||
|
||||
- [ ] **Step 2: Extract, then re-run**
|
||||
|
||||
Move the body to the package-level function; leave the method delegating. Run the full profiles suite — those four call sites are the regression surface:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -run 'Profile|Household' -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire `ProfileTokens` into `SettingValuesHandler`**
|
||||
|
||||
Nil `ProfileTokens` must mean "no household widening", never "allow" — assert that in a test.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Accept an explicit `profile_id` for the household parent
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/settings_values.go`
|
||||
- Modify: `internal/api/handlers/devices.go`
|
||||
- Test: `internal/api/handlers/settings_values_test.go`
|
||||
- Test: `internal/api/handlers/devices_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: optional `profile_id` query parameter on the settings-value routes and on `GET /api/v1/devices`.
|
||||
- Produces: identity resolution that permits a non-own `profile_id` only when `canManageHousehold` passes.
|
||||
|
||||
- [ ] **Step 1: Write the three refusal tests first**
|
||||
|
||||
These are the security surface. All three must be RED before any production code:
|
||||
- `TestSetValue_NonPrimaryCannotNameSiblingProfile` → 403
|
||||
- `TestSetValue_PrimaryWithUnverifiedPINCannotNameSibling` → 403, code `forbidden`, message naming PIN verification
|
||||
- `TestSetValue_ProfileFromAnotherAccountIsNotFound` → 404
|
||||
|
||||
Then the positive cases: `TestSetValue_PrimaryWritesSiblingProfileDeviceSetting`, `TestListDevices_PrimarySeesHouseholdWhenRequested`.
|
||||
|
||||
- [ ] **Step 2: Run and verify RED**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -run 'NameSibling|AnotherAccount|PrimaryWrites|PrimarySees' -v
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
In `identityForSessionKey`: when `profile_id` is present and differs from the session profile, require `canManageHousehold`; on failure return 403 without disclosing whether the profile exists. Then resolve the profile through the caller's **own** store, exactly as `internal/access/resolver.go:73-86` does — a profile from another account is simply absent, which yields the 404 and preserves the cross-account boundary for free.
|
||||
|
||||
For `GET /api/v1/devices`, add `?scope=household` (default: own profile only). Do not make household the default; the plain screen must stay private by construction.
|
||||
|
||||
- [ ] **Step 4: Run and verify GREEN, then the full package**
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers/ -v
|
||||
```
|
||||
|
||||
**Record in the PR:** this widening does not create a new capability. The primary profile can already rewrite a sibling's canonical settings rows through `PUT /profiles/{id}` (`internal/api/handlers/profiles_settings_sync.go:218-231`), and `GET /profiles` (`profiles.go:263`) already returns every sibling's resolved preferences to any profile with no gate. All profiles share one login session, so `X-Profile-Id` is self-asserted for PIN-less profiles — stated in-repo at `internal/api/middleware/auth.go:180-184`. This task replaces an unlabelled path with a guarded, audited one.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Audit cross-profile and device settings mutations
|
||||
|
||||
`internal/activitylog` is only an HTTP request-log middleware mounted globally before auth (`internal/api/router.go:236-239`); no handler writes to it. Its entries carry method, path pattern, status, user and session but **no profile id and no body**, so a settings write is indistinguishable from any other `PUT`. Tolerable while every write is your own; not once one profile can change another's. The settings spec already requires admin clear/reset to be audited.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/activitylog/` (new entry type or a settings-audit sink)
|
||||
- Modify: `internal/api/handlers/settings_values.go`
|
||||
- Modify: `internal/api/handlers/settings_values_admin.go`
|
||||
- Modify: `internal/api/handlers/devices.go`
|
||||
- Test: `internal/api/handlers/settings_values_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
`TestSetValue_AuditsCrossProfileWrite` asserts an entry recording actor profile, target profile, device, key, and action. `TestSetValue_DoesNotAuditOwnWrite` keeps ordinary self-service writes out of the audit trail — otherwise volume makes it useless.
|
||||
|
||||
- [ ] **Step 2: Run RED, implement, verify GREEN**
|
||||
|
||||
Record the *identity* of what changed, never the value: `user_settings.changed` deliberately carries no value because admins receive other accounts' events (`internal/api/handlers/user_settings_events.go:8-13`). The same reasoning applies to a stored audit row. Also audit Forget device and bulk clear.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Web: the device settings screen
|
||||
|
||||
### Task 8: Query hooks for devices and cross-identity settings
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/queries/devices.ts`
|
||||
- Modify: `web/src/hooks/queries/settingValues.ts`
|
||||
- Modify: `web/src/hooks/queries/keys.ts`
|
||||
- Modify: `web/src/api/types.ts`
|
||||
- Test: `web/src/hooks/queries/devices.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `useMyDevices({ household? })`, `useForgetDevice()`, `useClearDeviceSettings()`.
|
||||
- Modifies: `SettingIdentity` gains optional `deviceId` and `profileId`; `identityQuery` (`settingValues.ts:60`) serializes them.
|
||||
|
||||
- [ ] **Step 1: Extend `SettingIdentity` and `identityQuery`**
|
||||
|
||||
Both fields optional, so every existing caller compiles and behaves identically.
|
||||
|
||||
- [ ] **Step 2: Fix the cache key — this is a real bug if skipped**
|
||||
|
||||
`effectiveSettingsQueryKey` (`settingValues.ts:76`) namespaces by `activeProfileId()`. Reading another profile's or another device's values through the same key would collide with the current device's cache and serve one device's settings as another's. Add `deviceId` and `profileId` to the key, and add a test that two devices' reads occupy distinct entries.
|
||||
|
||||
- [ ] **Step 3: Write the device hooks and tests**
|
||||
|
||||
Follow the existing `api()` + TanStack Query conventions. Invalidate `[...settingsKeys.all, "values"]` on every mutation, as `useSetSettingValue` does.
|
||||
|
||||
```bash
|
||||
cd web && pnpm vitest run src/hooks/queries/devices.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: The device list pane
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/settings/DeviceSettings.tsx`
|
||||
- Create: `web/src/components/settings/DeviceList.tsx`
|
||||
- Create: `web/src/components/settings/deviceDisplay.ts`
|
||||
- Modify: `web/src/pages/SettingsLayout.tsx`
|
||||
- Modify: `web/src/App.tsx`
|
||||
- Modify: `web/src/lib/documentTitle.ts`
|
||||
- Test: `web/src/components/settings/DeviceList.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a master-detail layout — searchable list on the left, selected device on the right, stacking to a single column below the `md` breakpoint.
|
||||
- Produces: `deviceDisplay.ts` — platform icon/label classification and relative-time formatting. Adapt the existing helpers in `web/src/components/admin/deviceOverrides.tsx:40-146` rather than duplicating them; move them here and have the admin page import from the shared module.
|
||||
|
||||
- [ ] **Step 1: Add the route and nav entry**
|
||||
|
||||
One `NavSection` item under "Account" in `SettingsLayout.tsx` (`NAV_SECTIONS`, from line 52) with `settings: settingIndex(...)` so the entries reach the settings search index, and one `<Route path="devices" …>` in `App.tsx` beside the other settings routes.
|
||||
|
||||
- [ ] **Step 2: Build the list with tests**
|
||||
|
||||
Rows are fixed height and carry name, last-used, and a changed-count pill; a device with nothing changed shows a dash rather than "0". Group by recency — Using now / This week / Earlier. Search filters by name and platform. Tests: grouping boundaries, the current device is marked, count pill renders a dash at zero, and search matches on platform as well as name.
|
||||
|
||||
```bash
|
||||
cd web && pnpm vitest run src/components/settings/DeviceList.test.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: The device detail pane
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/settings/DeviceSettingGroups.tsx`
|
||||
- Create: `web/src/lib/deviceSettingGroups.ts`
|
||||
- Test: `web/src/lib/deviceSettingGroups.test.ts`
|
||||
- Test: `web/src/components/settings/DeviceSettingGroups.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `groupDeviceSettings(keys)` → Picture / Sound / Subtitles / Episodes, derived from each definition's `category` plus a small key→group map for the cases `category` does not separate (`player.*` splits across Picture and Sound).
|
||||
- Consumes: `ALL_DEVICE_SETTING_KEYS` (`web/src/lib/settingsDisplay.ts:126`) and `RegistrySettingControl` (`web/src/components/settings/RegistrySettingControl.tsx`).
|
||||
|
||||
- [ ] **Step 1: Group mapping, with a completeness test**
|
||||
|
||||
`TestEveryDeviceKeyIsGrouped` — every key in `ALL_DEVICE_SETTING_KEYS` lands in exactly one group. This is what stops a newly added manifest key from silently vanishing from the UI.
|
||||
|
||||
- [ ] **Step 2: Render rows through the shared primitives**
|
||||
|
||||
Use `SettingRow` and `SettingsGroup`. No raw keys. "Changed here" badge when a `profile_device` row exists; "Use my setting" clears at `profile_device` — a DELETE, never a copy of the profile value into the device row. Sliders and steppers must round-trip as numbers; do not reuse the admin screen's string round-trip (`web/src/hooks/queries/admin/users.ts:106-145`).
|
||||
|
||||
- [ ] **Step 3: Policy-capped rows**
|
||||
|
||||
When the effective response carries `constrained_by`, render `permitted_values` only and state the limit and who set it. Never a disabled control with no reason. Test both a capped select and a `locked` constraint.
|
||||
|
||||
- [ ] **Step 4: Run**
|
||||
|
||||
```bash
|
||||
cd web && pnpm vitest run src/components/settings/ src/lib/deviceSettingGroups.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Remote-device editing and its copy
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
|
||||
- Modify: `web/src/components/settings/DeviceSettingGroups.tsx`
|
||||
- Test: `web/src/pages/settings/DeviceSettings.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write to the selected device**
|
||||
|
||||
Every mutation passes `deviceId` explicitly rather than relying on the header, so selecting a device and editing it writes that device. Test that editing a non-current device sends its id.
|
||||
|
||||
- [ ] **Step 2: Scope and sync copy**
|
||||
|
||||
Show the mandated scope sentence once per device — "this device, for your profile only" — not per row. For a non-current device, state that it picks the change up next time it is on. `useSettingValuesRealtime` (`settingValues.ts:245`) already invalidates on `user_settings.changed`, so no polling.
|
||||
|
||||
- [ ] **Step 3: Forget and bulk clear, with confirmation**
|
||||
|
||||
Both are destructive and both name their target: "Clear all N changes on this device", "Forget this device".
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Web: the household view
|
||||
|
||||
### Task 12: Household scope switch and person grouping
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
|
||||
- Modify: `web/src/components/settings/DeviceList.tsx`
|
||||
- Test: `web/src/pages/settings/DeviceSettings.household.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useIsActingAdmin`, `useCurrentProfile`, and `profile.is_primary` — the same rule as `RequirePrimaryOrAdmin` (`web/src/App.tsx:209`) and `isActingAdmin` (`web/src/lib/permissions.ts:19`). Do not write a fourth definition of this predicate.
|
||||
- Consumes: `GET /profiles` for names, avatars, `is_child`.
|
||||
|
||||
- [ ] **Step 1: The switch appears only for the household parent**
|
||||
|
||||
Test that a non-primary, non-admin profile never sees it, and that the page still works fully for them in "just mine" mode. The switch is additive; nothing is taken away from anyone.
|
||||
|
||||
- [ ] **Step 2: Group devices by person**
|
||||
|
||||
Person header with avatar, name, a "You" or "Kid" tag, and a device count; devices nested beneath. When two profiles have registered the same physical TV, say so — "Same TV as yours — separate settings per person" — because that is the single most confusable thing on this screen.
|
||||
|
||||
- [ ] **Step 3: Acting-on-behalf copy**
|
||||
|
||||
A persistent banner while a sibling's device is selected: "You're changing Robin's settings, not your own." Reset actions name the person: "Use Robin's setting". Test that the banner is absent for one's own devices.
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Household boundaries in the UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/settings/DeviceSettingGroups.tsx`
|
||||
- Modify: `web/src/pages/settings/DeviceSettings.tsx`
|
||||
- Test: `web/src/pages/settings/DeviceSettings.household.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Household limits read as limits, and link out**
|
||||
|
||||
A value capped by parental controls shows a lock pill naming who set it and links to the profiles screen. This screen never authors a restriction — settings answer "what does this user want", policy answers "what are they allowed to have" (design spec, "Preferences versus restrictions").
|
||||
|
||||
- [ ] **Step 2: State the privacy boundary**
|
||||
|
||||
A short block: this page shows how Silo is set up per device, not what anyone watched. Viewing history stays private per profile. Test that it renders in household mode.
|
||||
|
||||
- [ ] **Step 3: Full check**
|
||||
|
||||
```bash
|
||||
cd web && pnpm run lint && pnpm run format:check && pnpm vitest run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Verification
|
||||
|
||||
### Task 14: Cross-cutting checks
|
||||
|
||||
- [ ] **Step 1: Full suite**
|
||||
|
||||
```bash
|
||||
make lint
|
||||
make test
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
make verify-settings-bindings-all
|
||||
```
|
||||
|
||||
Four Go failures (auth, catalog, jellycompat, notifications) pre-exist on some local Postgres provisioning and are not caused by this work — verify against the branch base before chasing one. `make lint` runs `golangci-lint` over the whole tree while CI runs `--new-from-merge-base`, so expect pre-existing findings that CI will not fail on; do not add to them.
|
||||
|
||||
- [ ] **Step 2: Browser verification**
|
||||
|
||||
Use the `web-ui-testing` skill against a real backend. Capture, for the PR: the device list at ten or more devices, a device detail pane, a remote-device edit, the household switch, and a policy-capped row. UI changes need screenshots (`CLAUDE.md`, "Pull requests").
|
||||
|
||||
- [ ] **Step 3: Manual authorization pass**
|
||||
|
||||
With `curl` against a dev server, confirm each refusal returns the intended status and leaks nothing:
|
||||
- non-primary naming a sibling profile → 403
|
||||
- primary with an unverified PIN → 403
|
||||
- a profile id from another account → 404
|
||||
- a device id belonging to another profile → 404
|
||||
|
||||
- [ ] **Step 4: Cross-repo follow-up**
|
||||
|
||||
The two identity widenings are additive server capabilities that Apple and Android may adopt later; nothing in those clients breaks without a change. Note in the PR whether follow-up issues are wanted, per `CLAUDE.md` "Multi-repo".
|
||||
|
||||
---
|
||||
|
||||
## Out of scope, and why
|
||||
|
||||
- **Renaming a device.** `device_name` is client-reported and re-registration overwrites it, so a user-set name needs a separate column and a precedence rule. Worth doing; not part of this plan.
|
||||
- **"Copy my settings from another device".** Appealing, but it writes up to 30 keys in one request, which is the argument for the batch mutations endpoint the design spec names (`POST /api/v1/settings/mutations`) and which does not exist. Build that first.
|
||||
- **Transferring the primary designation.** `is_primary` is assigned implicitly to the first profile created (`internal/userstore/pgstore/profiles.go:60-73`) and cannot be moved. The household view makes that visible, so it likely needs to exist — as its own issue.
|
||||
- **Watch history in the household view.** A different privacy question that a settings screen should not quietly answer.
|
||||
- **Admin device screen rework.** Once users self-serve, `/admin/devices` can go on being a fleet console. Grouping its rows by what they affect and hiding raw keys behind a disclosure is worth doing separately.
|
||||
|
||||
## Adjacent gaps found while planning
|
||||
|
||||
Same shape as this work, but not blockers — each deserves its own issue:
|
||||
|
||||
- `PUT`/`DELETE /profiles/{id}/avatar` (`internal/api/handlers/profile_avatars.go:202`, `:290`) apply no household guard and no self-check: any profile can change or delete any sibling's avatar, including the primary's.
|
||||
- `GET /profiles` (`internal/api/handlers/profiles.go:263`) returns every sibling's resolved preferences, `has_pin`, `is_child`, content rating and library restrictions to any profile on the account.
|
||||
- `POST /profiles/{id}/verify-pin` (`internal/api/handlers/profiles.go:701`) is open to any authenticated user of the account for any profile id, with no rate limiting in the handler.
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
# Plugin-backed watch sync providers
|
||||
|
||||
## Status
|
||||
|
||||
Implemented design for the expanded `silo-plugin-sdk` v0.13
|
||||
`watch_sync_provider.v1` contract. The server uses the immutable SDK commit
|
||||
associated with the v0.13 pull request until that version is released. This
|
||||
specification does not introduce a second watch synchronization pipeline.
|
||||
Plugin-backed providers adapt the public plugin RPC contract to Silo's existing
|
||||
`watchsync` service, connection repository, history exports, scrobble sessions,
|
||||
rate-limit handling, list reconciliation, and scheduled reconciliation.
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow an enabled plugin capability to appear as a profile-scoped watch
|
||||
provider.
|
||||
- Reuse Silo's encrypted watch-provider connections for personal credentials.
|
||||
- Support API-key and device-code connection flows.
|
||||
- Import and export watched state, progress, favorites, and watchlists according
|
||||
to each plugin's advertised capabilities.
|
||||
- Deliver explicit watched/unwatched and list operations plus live playback
|
||||
start, pause, and stop events.
|
||||
- Preserve the existing durable history-export reconciliation path.
|
||||
- Preserve complete provider credential shapes, including token type, scopes,
|
||||
expiry, and opaque secret attributes.
|
||||
- Supply declared installation configuration to provider calls without mixing
|
||||
it with profile credentials.
|
||||
- Keep provider plugins stateless and prevent credentials from entering generic
|
||||
plugin configuration.
|
||||
- Avoid behavior changes for built-in Trakt, Simkl, and MDBList providers.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Authorization-code OAuth UI and callback routes.
|
||||
- A second outbox or worker implementation.
|
||||
- Allowing plugins to persist or own personal provider credentials.
|
||||
- Removing built-in Trakt, Simkl, or MDBList providers in the same change. They
|
||||
remain available while equivalent plugins are built and connections are
|
||||
migrated deliberately.
|
||||
|
||||
Plugins may implement any subset of the state families and operations. A
|
||||
provider such as Floppy can advertise watched import/export, progress import,
|
||||
and live scrobbling without claiming favorites, watchlists, or unwatch support.
|
||||
Silo exposes only executable capabilities from the descriptor.
|
||||
|
||||
## SDK boundary
|
||||
|
||||
Plugins advertise `watch_sync_provider.v1` with a typed descriptor containing:
|
||||
|
||||
- supported authentication methods;
|
||||
- import/export capabilities;
|
||||
- supported media types and external-ID namespaces;
|
||||
- maximum apply batch size.
|
||||
|
||||
The host uses `WatchSyncProvider` for the stable provider operations and a
|
||||
separate `WatchSyncDeviceAuthorizationService` for device-code start and poll.
|
||||
The split keeps direct Go implementations of the released v0.12 provider
|
||||
interface source-compatible. Together the services support:
|
||||
|
||||
- device authorization `Start` and `Poll`;
|
||||
- `ExchangeAPIKey`;
|
||||
- `RefreshCredentials`;
|
||||
- `GetAccount`;
|
||||
- `ApplyEvents` for watched, unwatched, favorite, watchlist, and live scrobble
|
||||
operations;
|
||||
- `ListRemoteState` for watched, progress, favorite, and watchlist state.
|
||||
|
||||
Authorization-code descriptors remain valid SDK contracts, but the host does
|
||||
not register a plugin that offers only authorization-code authentication until
|
||||
the public watch-provider callback flow exists.
|
||||
|
||||
Every provider RPC receives the complete current credentials and relevant
|
||||
installation configuration only for that call. Plugins must not persist or log
|
||||
credentials. Generic `Runtime.Configure` data is not a personal credential
|
||||
store. Returned credentials are an authoritative replacement, and the host
|
||||
persists them before interpreting results, pages, or faults from the same RPC.
|
||||
|
||||
## Discovery and lifecycle
|
||||
|
||||
At startup and after plugin lifecycle changes, Silo:
|
||||
|
||||
1. lists enabled, non-builtin plugin installations;
|
||||
2. selects `watch_sync_provider.v1` capabilities;
|
||||
3. decodes and validates their typed descriptors;
|
||||
4. constructs thin `watchsync.Provider` adapters;
|
||||
5. atomically replaces the registry's previous plugin-backed providers while
|
||||
preserving built-ins.
|
||||
|
||||
Provider keys bind persisted credentials to the installation and capability
|
||||
(`plugin:<installation-id>:<capability-id>`), while RPC routing continues to use
|
||||
the manifest capability ID. This prevents a subsequently installed plugin from
|
||||
inheriting credentials merely by reusing another plugin's capability ID.
|
||||
Provider keys must not shadow built-ins or other plugin providers. A conflicting
|
||||
lifecycle reload fails without partially replacing the registry. Invalid,
|
||||
unreadable, or currently unsupported descriptors are logged and omitted, and
|
||||
the successfully discovered set still replaces the previous plugin providers.
|
||||
This fail-closed behavior prevents a disabled or removed plugin from remaining
|
||||
registered because another installation could not be decoded.
|
||||
|
||||
## Existing watchsync interfaces
|
||||
|
||||
The adapter implements the existing interfaces corresponding to the
|
||||
descriptor's advertised capabilities:
|
||||
|
||||
- `Provider`;
|
||||
- `AuthProvider`, `APIKeyAuthProvider`, and device authorization;
|
||||
- watched import/export and unwatch export;
|
||||
- progress import;
|
||||
- favorite and watchlist import/export/removal, including ordered watchlists;
|
||||
- `Scrobbler` and `OrderedScrobbler` for start, pause, and stop delivery.
|
||||
|
||||
`LocalPlay` and `ScrobbleEvent` values become rich desired-state plugin events.
|
||||
The host supplies movie and series external IDs, season and episode numbers,
|
||||
local history identity, playback identity, timestamps, and completion values.
|
||||
The plugin never needs a Silo API key or a callback into catalog HTTP routes.
|
||||
|
||||
## Authentication and credentials
|
||||
|
||||
The existing watch-provider API-key connection route passes the entered token
|
||||
to `ExchangeAPIKey`. Device-code providers use the existing start and poll
|
||||
routes. The plugin validates or exchanges the credential and returns normalized
|
||||
credentials plus provider account identity.
|
||||
|
||||
Credential refresh remains host-initiated and serialized by the existing
|
||||
watchsync service. The host encrypts an authoritative credential bundle
|
||||
containing access token, refresh token, expiry, token type, scopes, and opaque
|
||||
secret attributes. Legacy access/refresh columns remain readable during the
|
||||
migration. Device codes are also encrypted at rest. A pending device poll may
|
||||
rotate its opaque provider state, interval, or expiry; the host validates and
|
||||
persists the replacement before another poll. Invalid-credential faults are
|
||||
persisted using only the plugin's safe message.
|
||||
|
||||
Authorization-code OAuth can be added separately to the existing watch-
|
||||
provider service after its client-secret storage and callback-state design are
|
||||
reviewed.
|
||||
|
||||
## Watched export flow
|
||||
|
||||
### Explicit mark watched
|
||||
|
||||
The existing manual watch path records leaf history entries and invokes
|
||||
`HandleLocalWatchEvent`. The existing history-export table is populated before
|
||||
provider I/O. The plugin adapter sends each pending `LocalPlay` as an absolute
|
||||
`MARK_WATCHED` desired-state event. The interactive path sends one bounded
|
||||
plugin batch; additional queued events are delivered by scheduled
|
||||
reconciliation.
|
||||
|
||||
### Playback completion
|
||||
|
||||
The existing playback path invokes `ScrobbleStop` with rich identity. For a
|
||||
completed event and a provider that exports watched state, Silo first upserts a
|
||||
pending history-export record, then dispatches the immediate stop operation.
|
||||
|
||||
On successful immediate delivery, the history export is marked
|
||||
`satisfied_by_scrobble`. If immediate delivery fails or the process exits, the
|
||||
existing scheduled history reconciliation retries the same local history. A
|
||||
compatibility playback path without a local history ID retains its own durable
|
||||
terminal event until the confirmed stop succeeds. The plugin operation is
|
||||
convergent, so an uncertain duplicate cannot lower progress or increment a play
|
||||
counter.
|
||||
|
||||
Start, pause, and incomplete stop calls are no-ops for providers that only
|
||||
advertise watched export. Providers advertising live scrobbling receive all
|
||||
three operations with playback position and duration.
|
||||
|
||||
If the remote stop succeeds but the local history-export transition fails, the
|
||||
stop is not dispatched again. The scrobble session records a durable pending
|
||||
history-reconciliation marker, and the sweeper retries only the local database
|
||||
transition.
|
||||
|
||||
## Remote state import and lists
|
||||
|
||||
The scheduled sync path requests one state family at a time. Each request
|
||||
contains the stable cursor for that family and an opaque page token. The host:
|
||||
|
||||
1. persists any credentials returned by the page;
|
||||
2. discards the page and retains its cursor on a top-level fault;
|
||||
3. applies valid items idempotently through existing watch-state and list
|
||||
services;
|
||||
4. advances the page token until the plugin reports no next page;
|
||||
5. commits the family cursor only after the traversal succeeds.
|
||||
|
||||
Incremental list pages do not imply deletion of absent items; they use explicit
|
||||
provider-key tombstones for removals. Complete snapshots may reconcile removals.
|
||||
Ordered watchlists preserve the plugin's list position and require complete
|
||||
snapshots because an incremental subset cannot define positions relative to
|
||||
omitted items. Traversals are bounded by both page and item count before their
|
||||
durable cursor advances.
|
||||
Progress items without a valid provider timestamp are rejected rather than
|
||||
being stamped with host time.
|
||||
|
||||
Imported history is tagged with the stable plugin provider key, not a generic
|
||||
import source. Echo suppression therefore filters only export back to the
|
||||
originating plugin; the same local event remains eligible for other connected
|
||||
providers.
|
||||
|
||||
## Provider configuration
|
||||
|
||||
Manifest-declared global configuration is resolved per installation before each
|
||||
provider call. Public fields are sent in `values`; secret and undeclared fields
|
||||
are sent in `secret_values`. Nested manifest fields use the stable
|
||||
`<config-key>.<field>` key form, such as `floppy.base_url`. Profile credentials
|
||||
remain separate and encrypted in watch-provider connection storage.
|
||||
|
||||
## Apply result handling
|
||||
|
||||
Each event has a stable ID. The adapter requires a matching result and maps it
|
||||
as follows:
|
||||
|
||||
- `APPLIED` and `NO_CHANGE`: export sent;
|
||||
- `REJECTED`: permanent not-found/unmappable export;
|
||||
- `RETRY` with a temporary fault: failed export eligible for existing retry handling;
|
||||
- top-level or per-event rate limit: existing `RateLimitedError` and account
|
||||
deferral, after committing any successfully applied events from the same RPC;
|
||||
- missing or unknown result: failed export eligible for retry, unless the same
|
||||
RPC reported a rate limit, in which case unmentioned events remain pending;
|
||||
- plugin transport, process availability, and top-level temporary faults:
|
||||
remain pending without consuming a per-event attempt.
|
||||
|
||||
A top-level invalid-credential fault records a typed safe connection error.
|
||||
A dedicated reconnect-required connection state is deferred as described
|
||||
above. Plugin messages persisted by Silo must be explicitly safe for operator
|
||||
display and must not contain response bodies or secrets. The host redacts the
|
||||
connection credentials, normalizes control characters and whitespace, and
|
||||
bounds the displayed message length; transport details are replaced with
|
||||
host-owned text.
|
||||
|
||||
## Ordering and batching
|
||||
|
||||
Immediate scrobbles are ordered by connection and stable series identity using
|
||||
the existing ordered-scrobble queue. History reconciliation is ordered by local
|
||||
watch timestamp.
|
||||
|
||||
Each synchronization run sends at most one plugin RPC, bounded by the
|
||||
descriptor's maximum batch size. The existing service commits every per-event
|
||||
result before a later run requests the next pending batch. Failures increment
|
||||
attempts only for events included in that RPC. This avoids partial-success loss,
|
||||
retry exhaustion within one run, and unbounded cumulative RPC time.
|
||||
|
||||
## Reliability boundaries
|
||||
|
||||
This design deliberately reuses the reliability guarantees currently accepted
|
||||
for built-in providers:
|
||||
|
||||
- encrypted profile-scoped connections;
|
||||
- durable local watch history;
|
||||
- durable history-export rows;
|
||||
- immediate scrobble sessions;
|
||||
- scheduled reconciliation;
|
||||
- bounded retry attempts and safe recorded errors;
|
||||
- provider/account rate-limit deferral.
|
||||
|
||||
It does not claim multi-node leased delivery or infinite retries. Those should
|
||||
be improved in the shared watchsync pipeline for built-in and plugin providers
|
||||
together rather than introduced only for plugins.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- Personal tokens never enter plugin runtime config or user settings.
|
||||
- Tokens are decrypted only at the provider invocation boundary.
|
||||
- RPC request values containing credentials are never logged.
|
||||
- Plugin-returned errors are sanitized before persistence and API display;
|
||||
transport errors are replaced by a generic message.
|
||||
- Account/profile scope remains enforced by the existing connection lookup.
|
||||
- Credential AAD and provider lookup include installation identity, preventing
|
||||
sequential capability-ID reuse from exposing an old token to a new install.
|
||||
- Disabled plugins disappear from provider discovery; existing connections are
|
||||
unavailable until that same installation and capability return.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The change is additive to `/api/v1`. Existing provider summaries and connection
|
||||
routes continue to work. A plugin provider appears through the same provider
|
||||
summary model and uses the existing API-key or device-code connection routes.
|
||||
No client changes are required because no existing fields or status codes
|
||||
change. SDK v0.12 plugins remain wire-compatible; descriptors only expose the
|
||||
capabilities the older contract can execute.
|
||||
|
||||
## Validation plan
|
||||
|
||||
- capability enforcement in the plugin host client;
|
||||
- atomic registry replacement and built-in collision tests;
|
||||
- API-key and device-code authentication, full credential replacement, and
|
||||
account identity conversion;
|
||||
- provider configuration routing and secret/public field classification;
|
||||
- rich movie and episode identity conversion;
|
||||
- watched, progress, favorite, and watchlist import pagination and cursor
|
||||
behavior;
|
||||
- unwatch, favorite, watchlist, and ordered-list event conversion;
|
||||
- provider-specific import source keys and cross-provider export behavior;
|
||||
- completed playback persistence before plugin I/O;
|
||||
- `satisfied_by_scrobble` transition after success;
|
||||
- durable retry of a failed local post-stop history transition without another
|
||||
remote stop;
|
||||
- immediate failure followed by scheduled history reconciliation;
|
||||
- typed rate-limit and invalid-credential handling;
|
||||
- complete credential encryption/decryption and legacy fallback;
|
||||
- plugin disable/re-enable lifecycle reload;
|
||||
- no regression in existing watchsync, pluginhost, and plugin service tests;
|
||||
- `go test`, targeted race tests, `go vet`, lint, formatting checks, and local
|
||||
path verification before proposing a merge request.
|
||||
@@ -0,0 +1,484 @@
|
||||
# Emailed Invitations & Server-Driven Onboarding — Design Spec
|
||||
|
||||
**Date:** 2026-07-27
|
||||
**Status:** Draft — for review
|
||||
**Mockups:** `docs/design/invite-onboarding.html`
|
||||
**Surfaces:** silo-server, silo-server/web, silo-android, silo-apple
|
||||
|
||||
## Problem
|
||||
|
||||
Two gaps, one journey.
|
||||
|
||||
**Getting in.** Today an admin creates a multi-use `invite_codes` row and hands
|
||||
the string to someone out-of-band. That person visits `/signup`, invents a
|
||||
username, retypes their email, retypes the code, and lands on an empty home
|
||||
screen. Nothing is scoped ahead of time — library access, group, and role are
|
||||
whatever the defaults are, so the admin edits the user afterward. There is no
|
||||
per-person revocation: disabling a code disables it for everyone who has it.
|
||||
|
||||
**Staying.** Silo has a set of features most self-hosted media servers do not
|
||||
ship at all — watch together, media requests, watchlists with arrival
|
||||
notifications, taste-based recommendations, a release calendar, per-library and
|
||||
per-series playback overrides, subtitle appearance that follows the profile.
|
||||
A new user discovers none of it. The only onboarding that exists is the
|
||||
taste-seed picker (`web/src/pages/TasteSeed.tsx`), which is web-only and
|
||||
covers exactly one feature.
|
||||
|
||||
This spec covers both: a personal, pre-provisioned invitation delivered by
|
||||
email, and a first-run tour whose content the server owns so all three client
|
||||
platforms stay in sync.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing `invite_codes`. Shareable codes stay exactly as they are for the
|
||||
"drop a code in Discord" case. This adds a second, complementary mechanism.
|
||||
- Self-service password reset. Adjacent and worth doing, but a separate
|
||||
concern with its own threat model. The token table here is deliberately
|
||||
shaped so a reset flow can reuse it later (see "Future work").
|
||||
- SSO/OAuth invitations. Plugin auth providers own their own account
|
||||
provisioning; an invitation targets local credentials only.
|
||||
- An admin-authorable tour editor. The manifest is server-owned but built in
|
||||
Go, not user-editable. Admins get on/off toggles, not a CMS.
|
||||
|
||||
---
|
||||
|
||||
# Part 1 — Invitations
|
||||
|
||||
## Model
|
||||
|
||||
One new table. An invitation is a **capability token bound to one email
|
||||
address**, carrying the access decisions the admin already made.
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.invitations (
|
||||
id bigserial PRIMARY KEY,
|
||||
email citext NOT NULL,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
-- Pre-bound access, applied verbatim at accept time.
|
||||
role text NOT NULL DEFAULT 'user',
|
||||
access_group_id bigint REFERENCES public.access_groups(id) ON DELETE SET NULL,
|
||||
library_ids integer[], -- NULL = all libraries
|
||||
create_profile boolean NOT NULL DEFAULT true,
|
||||
show_tour boolean NOT NULL DEFAULT true,
|
||||
note text NOT NULL DEFAULT '',
|
||||
-- Lifecycle.
|
||||
invited_by bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
accepted_at timestamptz,
|
||||
accepted_user_id bigint REFERENCES public.users(id) ON DELETE SET NULL,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX invitations_email_idx ON public.invitations (email);
|
||||
-- At most one live invitation per address. Re-inviting supersedes rather
|
||||
-- than accumulating parallel valid tokens for the same person.
|
||||
CREATE UNIQUE INDEX invitations_one_pending_idx ON public.invitations (email)
|
||||
WHERE accepted_at IS NULL AND revoked_at IS NULL;
|
||||
```
|
||||
|
||||
Token handling mirrors the email-verification flow that already exists in
|
||||
`internal/notifications/email_address.go`: 32 random bytes,
|
||||
`base64.RawURLEncoding` for the URL, **SHA-256 hex stored at rest**. The raw
|
||||
token exists in the sent email and nowhere else — a database dump does not
|
||||
yield usable invitation links.
|
||||
|
||||
### Why no user row until accept
|
||||
|
||||
An unaccepted invitation creates nothing in `users`. A typo'd address cannot
|
||||
squat a username, cannot appear in the user list as a permanent ghost, and
|
||||
cannot be counted against any seat/limit logic added later. The
|
||||
`invitations_one_pending_idx` partial unique index is what keeps "resend"
|
||||
honest: it supersedes, so a forwarded copy of the old link stops working.
|
||||
|
||||
### Email as username
|
||||
|
||||
The invitee sets only a password. `username` is set to the invitation's email
|
||||
address. This works today without a schema change: `users.username` and
|
||||
`users.email` are both `citext` with unique constraints (migration
|
||||
`165_case_insensitive_username_email.sql`), so `marco@example.com` is a legal
|
||||
username and matches case-insensitively.
|
||||
|
||||
One behavior change is required for this to feel right:
|
||||
|
||||
> **`LocalProvider.Authenticate` must accept an email address in the username
|
||||
> field.** `internal/auth/provider.go:56` calls `users.GetByUsername` only. It
|
||||
> gains an email fallback: if the username lookup misses **and** the input
|
||||
> parses as an email address, retry via `GetByEmail`.
|
||||
|
||||
This is strictly additive — existing username logins are unaffected, and there
|
||||
is no ambiguity risk because both columns are globally unique across the same
|
||||
identity space. Without it, someone invited by email who later types their
|
||||
address on the phone app gets "invalid credentials" and has no way to discover
|
||||
that their username happens to be identical to the thing they just typed.
|
||||
|
||||
Login rate limiting already covers this endpoint (`AuthEndpointHandler("login")`),
|
||||
so the extra lookup does not open an enumeration channel that the single lookup
|
||||
did not already have.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Admin, under the existing `/api/v1/admin` tree (admin-gated, same as
|
||||
`/admin/invite-codes`):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/admin/invitations` | List, newest first, with derived status. |
|
||||
| `POST` | `/admin/invitations` | Create + send. Returns the row and, when email is off, the raw claim URL. |
|
||||
| `POST` | `/admin/invitations/{id}/resend` | Supersede: revoke, mint fresh token, resend. |
|
||||
| `DELETE` | `/admin/invitations/{id}` | Revoke. Idempotent. |
|
||||
|
||||
Public, under `/api/v1`, unauthenticated, **rate-limited via the existing
|
||||
`deps.RateLimitMW.AuthEndpointHandler`** alongside login/signup:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/invitations/{token}` | Claim-screen data only: inviter display name, email, expiry, server name. |
|
||||
| `POST` | `/invitations/{token}/accept` | Create the account, redeem, return a session token pair. |
|
||||
|
||||
`accept` returns the **same `loginResponse` shape as signup**
|
||||
(`buildLoginResponse`), so every client reuses its existing session plumbing
|
||||
rather than growing a parallel auth path.
|
||||
|
||||
### Status is derived, not stored
|
||||
|
||||
`Pending | Accepted | Expired | Revoked` is computed from
|
||||
`accepted_at`/`revoked_at`/`expires_at` at read time. No status column, no
|
||||
background job to expire rows, no possibility of the column disagreeing with
|
||||
the timestamps.
|
||||
|
||||
### Accept is one transaction
|
||||
|
||||
Create user → apply pre-bound access → create the default profile → mark the
|
||||
invitation accepted. `auth.AccountProvisioner.CreateAccount` already handles
|
||||
user-plus-profile with rollback-on-profile-failure
|
||||
(`internal/auth/account_provisioner.go`), so this reuses it and adds the
|
||||
invitation update. A concurrent double-submit loses on the
|
||||
`accepted_at IS NULL` predicate and returns "already used".
|
||||
|
||||
## Email
|
||||
|
||||
Composed in a new `internal/invitations` package using `internal/mail`'s
|
||||
`RenderLayout`, `EmailParagraph`, and `EmailButton`, exactly as
|
||||
`composeVerificationEmail` does. Same SMTP settings, same diagnostics, same
|
||||
branded shell.
|
||||
|
||||
Link base resolution reuses the established precedence:
|
||||
`notifications.email.external_url` → server public URL. When neither is set,
|
||||
`POST /admin/invitations` still creates the row and returns the claim URL with
|
||||
a `email_sent: false` flag; the admin UI shows **"Copy invite link"** instead
|
||||
of pretending mail went out. Failing loudly beats a silent drop.
|
||||
|
||||
The email states the sign-in address explicitly. It is the one fact the person
|
||||
will need again when they install the phone app, and the one they would
|
||||
otherwise guess wrong.
|
||||
|
||||
## Deep links
|
||||
|
||||
The claim URL is `https://<server>/invite/<token>`.
|
||||
|
||||
- **Web** — a new public route, sibling to `/signup` and `/activate` in
|
||||
`web/src/App.tsx`.
|
||||
- **Android** — the `silo` scheme already routes `device`, `item`, `play`, and
|
||||
`downloads` hosts in `AndroidManifest.xml`. Add an `invite` host plus an
|
||||
`https` App Link on the server's domain, parsed by the existing
|
||||
`ContentDeepLinkRoutes` machinery.
|
||||
- **Apple** — `onOpenURL` already posts to a notification the root view
|
||||
consumes and queues until auth settles (`iOSApp.swift`). Add an `invite`
|
||||
case; universal links need the server to serve
|
||||
`/.well-known/apple-app-site-association`, which is new work.
|
||||
|
||||
Crucially, the deep link carries **both the server URL and the token**, so the
|
||||
app skips its "which server?" step entirely — that is most of the friction in
|
||||
onboarding someone onto a self-hosted service.
|
||||
|
||||
## Household setup ("Who's watching?")
|
||||
|
||||
Between the password and the tour sits a household step: profiles for
|
||||
everyone on the couch — a spouse, kids, grandparents. The journey indicator
|
||||
reads **Invite → Password → Household**.
|
||||
|
||||
**This step needs no new backend.** `POST /api/v1/profiles` already carries
|
||||
everything the screen sets: name, preset avatar, PIN, `is_child`,
|
||||
`max_content_rating`, and per-profile library restrictions
|
||||
(`createProfileRequest`, `internal/api/handlers/profiles.go:50`). The work is
|
||||
purely presentational — surfacing the existing profile model at the moment it
|
||||
matters instead of leaving it buried in Settings → Profiles.
|
||||
|
||||
Design decisions:
|
||||
|
||||
- **Profiles are not logins, and the screen says so.** One account, one
|
||||
password, many viewers — several profiles per `user_id` is the existing
|
||||
model. The copy heads off the "do I need to invite my wife separately?"
|
||||
question. (If she *should* have her own login — her own password, her own
|
||||
email — that's a second invitation, and the admin decides which they want.)
|
||||
- **The kids toggle is a preset, not a form.** Flipping `is_child` on reveals
|
||||
a rating ceiling (default PG) and the library picker. Off, they stay hidden.
|
||||
A grandparent profile is just a plain profile — no ceiling, no extra fields.
|
||||
- **PIN guidance over PIN plumbing.** The common mistake is pinning the kid's
|
||||
profile; the useful move is pinning the adults' so the kids can't wander in.
|
||||
One line of copy under the toggle: "Usually for the parents' profiles, not
|
||||
the kids' — it keeps the kids out of yours."
|
||||
- **"Just me for now" is a first-class exit.** It creates nothing, and the
|
||||
tour later includes a household `feature_card` reminding them profiles
|
||||
exist. No nagging.
|
||||
- **Restriction composition is unchanged.** A profile's rating ceiling and
|
||||
library restrictions layer under the account's invitation-bound access via
|
||||
the existing strictest-layer-wins resolver. The invitee cannot grant a
|
||||
profile more than the account received.
|
||||
- **Taste-seed stays per-profile.** Each created profile gets its own
|
||||
taste-seed state, and the picker already filters by the active profile's
|
||||
rating ceiling — Emma's picker shows kid-appropriate titles.
|
||||
|
||||
The invitation's `create_profile` flag becomes moot when this screen exists on
|
||||
a surface: the first tile ("You") *is* the default profile, pre-named from the
|
||||
email's local part and renameable inline. The flag stays in the schema for
|
||||
headless accepts (a client too old to render the household step still gets a
|
||||
usable account).
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Server-driven onboarding
|
||||
|
||||
## Why the server owns it
|
||||
|
||||
Three clients, three release cadences, and one of them has a multi-day review
|
||||
queue. If the tour lives in client code:
|
||||
|
||||
- Adding a stop for a new feature means three PRs and waiting on Apple.
|
||||
- A server with requests disabled still ships a requests stop, so the tour
|
||||
advertises a 404.
|
||||
- Fixing an awkward sentence is a store release.
|
||||
|
||||
With a server manifest, the tour is filtered per-server and per-profile before
|
||||
it reaches the client, and copy changes are a server deploy.
|
||||
|
||||
## Contract
|
||||
|
||||
```
|
||||
GET /api/v1/onboarding/flow?surface=web|phone|tv
|
||||
```
|
||||
|
||||
Returns an ordered step list for **this server** and **this profile**, with
|
||||
disabled features already filtered out:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"tour_id": "core-2026-07",
|
||||
"steps": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"kind": "welcome",
|
||||
"title": "Silo isn't quite like the others",
|
||||
"body": "You've probably used Plex or Jellyfin...",
|
||||
"illustration": "welcome"
|
||||
},
|
||||
{
|
||||
"id": "playback-quality",
|
||||
"kind": "setting_choice",
|
||||
"title": "Pick a quality ceiling now, change it anywhere",
|
||||
"body": "Silo won't burn your data plan guessing...",
|
||||
"setting": {
|
||||
"target": "profile_field",
|
||||
"key": "quality_preference",
|
||||
"control": "segmented",
|
||||
"options": [
|
||||
{"value": "auto", "label": "Auto"},
|
||||
{"value": "1080p", "label": "1080p"},
|
||||
{"value": "4k", "label": "4K"}
|
||||
],
|
||||
"default": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "watch-together",
|
||||
"kind": "feature_card",
|
||||
"title": "Same movie, different couches",
|
||||
"body": "Start a room, send the link...",
|
||||
"illustration": "watch-together",
|
||||
"action": {"label": "Show me", "route": "/rooms/join"}
|
||||
},
|
||||
{
|
||||
"id": "handoff-taste",
|
||||
"kind": "handoff",
|
||||
"title": "Pick a few favorites",
|
||||
"route": "/taste-seed"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Step kinds
|
||||
|
||||
| `kind` | Renders | Notes |
|
||||
| --- | --- | --- |
|
||||
| `welcome` | Framing card | Always first. |
|
||||
| `feature_card` | Title, body, illustration key, optional route action | The bulk of the tour. |
|
||||
| `setting_choice` | A control that writes a real setting | See below. |
|
||||
| `spotlight` | Highlight of a named UI anchor | Web/tablet only; phone and TV skip. |
|
||||
| `handoff` | Ends the tour by routing somewhere | Hands off to `/taste-seed`. |
|
||||
|
||||
**Unknown kinds are skipped silently.** This is the whole point of the
|
||||
versioned contract: the server may add a kind, and an older client drops it
|
||||
rather than crashing or rendering a blank card. Clients send their surface;
|
||||
the server omits steps that surface cannot render (spotlights on TV, text
|
||||
entry on TV).
|
||||
|
||||
**Illustrations ship with the client**, keyed by a string. The server never
|
||||
sends image URLs — assets stay native, sized correctly per platform, and work
|
||||
offline.
|
||||
|
||||
### `setting_choice` writes real settings
|
||||
|
||||
The tour is not a slideshow that ends with "now go configure it yourself" — by
|
||||
the last step the account is genuinely set up. It writes through the *existing*
|
||||
APIs, so there is no new persistence and no risk of the tour and the settings
|
||||
screens disagreeing.
|
||||
|
||||
Silo has three distinct write targets, and the step must say which one it
|
||||
means. This matters: playback quality is **not** a settings key — it is a
|
||||
column on the profile, written via `PUT /profiles/{id}` (see
|
||||
`saveProfileField` in `web/src/pages/settings/PlaybackSettings.tsx:114`).
|
||||
Collapsing all three into one "key" field would produce a contract that cannot
|
||||
express the most important step in the tour.
|
||||
|
||||
| `target` | Written via | Used for |
|
||||
| --- | --- | --- |
|
||||
| `profile_field` | `PUT /api/v1/profiles/{id}` | `quality_preference`, `subtitle_language`, `subtitle_mode`, `auto_skip_intro`, `auto_skip_credits`, `auto_skip_recap` |
|
||||
| `setting` | `PUT /api/v1/settings/{key}` | `playback.auto_play_next`, `subtitle_appearance`, overlay and home keys |
|
||||
| `device_setting` | `PUT /api/v1/settings/device/{key}` | Anything that should differ per device (a quality ceiling on cellular) |
|
||||
|
||||
The server only emits a `setting_choice` the current profile may actually
|
||||
write, so a child profile never gets a step it would be refused on.
|
||||
|
||||
### Progress
|
||||
|
||||
```
|
||||
GET /api/v1/onboarding/state → {"tour_id": "...", "completed_at": ..., "skipped_at": ..., "last_step": "..."}
|
||||
POST /api/v1/onboarding/progress → {"tour_id": "...", "last_step": "...", "completed": bool, "skipped": bool}
|
||||
```
|
||||
|
||||
State is stored **per profile, server-side**, in the existing per-user SQLite
|
||||
store (`internal/userdb`) — no new Postgres table, and no join from Postgres to
|
||||
profile storage (profiles may live in per-user SQLite, which is why the
|
||||
notification tables denormalize `user_id` rather than joining).
|
||||
|
||||
Note that `user_settings` is keyed by `key` alone and is therefore
|
||||
**account-wide, not per-profile** (`internal/userdb/schema.go:185`). Storing
|
||||
tour state there would mean one profile finishing the tour silences it for the
|
||||
whole household. Two options, in preference order:
|
||||
|
||||
1. **A dedicated `profile_onboarding` table** in the userdb schema — keyed
|
||||
`(profile_id, tour_id)` with `last_step`, `completed_at`, `skipped_at`.
|
||||
Explicit, queryable, and the right shape if contextual follow-up tours
|
||||
(multiple `tour_id`s) happen later. Requires a userdb schema version bump
|
||||
(`internal/userdb/migrate.go`).
|
||||
2. A profile-prefixed key in `user_settings` (`onboarding.<profile_id>.state`).
|
||||
No schema change, but it abuses a flat key space and makes "show me every
|
||||
profile that skipped" a `LIKE` scan.
|
||||
|
||||
**Recommendation: option 1.** The userdb schema already carries per-profile
|
||||
tables (`subtitle_preferences`, `series_playback_preferences`), so this follows
|
||||
the established pattern rather than working around it.
|
||||
|
||||
Two consequences worth stating plainly:
|
||||
|
||||
- Finishing the tour on the web means the phone does not ask again.
|
||||
- Skipping is honored everywhere, permanently, until the user replays it from
|
||||
Settings → Personalize.
|
||||
|
||||
The current taste-seed dismissal is a **localStorage** flag
|
||||
(`web/src/lib/tasteSeed.ts`), which is why it re-prompts on every new browser
|
||||
and never syncs to the apps. The tour deliberately does not repeat that
|
||||
mistake; taste-seed's own flag can migrate to the same mechanism later.
|
||||
|
||||
### Gating
|
||||
|
||||
Steps are filtered against what the server actually has on:
|
||||
|
||||
| Step | Gated on |
|
||||
| --- | --- |
|
||||
| Requests | `requests_enabled` (`internal/requests`) |
|
||||
| Watch together | watch-together enabled |
|
||||
| Notifications | `mail.Sender.Enabled()` or push configured |
|
||||
| Calendar | any library with scheduled content |
|
||||
| Recommendations | recommendations enabled |
|
||||
| Subtitles / quality / overlays | always (core playback) |
|
||||
|
||||
A server with nothing but a movie library gets a short, honest tour rather
|
||||
than a tour of things it does not do.
|
||||
|
||||
## Reach
|
||||
|
||||
The tour is **not invite-only**. Anyone whose profile has no completion record
|
||||
gets it: existing accounts, code-signup accounts, and additional profiles on
|
||||
an existing account. The invitation just makes the entrance nicer. The
|
||||
`show_tour` flag on an invitation only suppresses it for the deliberate case
|
||||
(re-inviting someone who already knows the product).
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- **Tokens at rest are hashed.** A DB dump yields no usable links.
|
||||
- **Enumeration.** `GET /invitations/{token}` returns identical 404s for
|
||||
unknown, expired, revoked, and accepted tokens. The claim screen distinguishes
|
||||
them only via the accept path's error codes, which are rate-limited. 32 bytes
|
||||
of entropy makes guessing irrelevant.
|
||||
- **Privilege ceiling.** An admin cannot mint an invitation granting more than
|
||||
they hold. Role `admin` on an invitation requires the inviter be an admin —
|
||||
enforced at the handler, not just hidden in the UI.
|
||||
- **Access composition is unchanged.** Pre-bound `library_ids` and
|
||||
`access_group_id` feed the existing restriction-only resolver
|
||||
(`docs/superpowers/specs/2026-07-02-access-groups-design.md`): the strictest
|
||||
layer still wins. An invitation is a convenience for setting the initial
|
||||
values, never a bypass.
|
||||
- **Mail as an oracle.** Invitation sending is admin-only, so it is not a
|
||||
spray vector the way unauthenticated verification sends would be. Resend
|
||||
inherits the same admin gate.
|
||||
- **Note field** is admin-authored and rendered escaped in HTML mail and raw in
|
||||
the text part — same handling as every other user-supplied value in
|
||||
`internal/mail`.
|
||||
|
||||
## Risks and tradeoffs
|
||||
|
||||
**Email-as-username changes the login lookup.** Mitigated by making it a
|
||||
fallback (username first, email only on miss and only when the input parses as
|
||||
an address), so existing behavior is untouched. Worth a test that asserts a
|
||||
user whose *username* is `a@b.com` and a different user whose *email* is
|
||||
`a@b.com` cannot both exist — the unique constraints already prevent it, but
|
||||
the test documents why the fallback is unambiguous.
|
||||
|
||||
**A server-driven tour couples client releases to a contract.** Mitigated by
|
||||
"unknown kinds are skipped" and an explicit `version`. The failure mode of a
|
||||
client that is too old is a shorter tour, not a broken one.
|
||||
|
||||
**Apple universal links need server-side association hosting.** The `silo://`
|
||||
custom scheme works immediately; universal links are a follow-up that improves
|
||||
the experience but does not gate the feature.
|
||||
|
||||
**Scope.** This is a three-repo change. The server plus web is independently
|
||||
shippable and useful on its own; the clients can follow without the server work
|
||||
being wasted in the meantime.
|
||||
|
||||
## Future work
|
||||
|
||||
- Password reset reusing the same token shape and mail plumbing.
|
||||
- Self-serve "invite a friend" for household primaries, bounded by an
|
||||
admin-set quota — the table already carries `invited_by`.
|
||||
- Migrating taste-seed's localStorage dismissal to server-side onboarding
|
||||
state.
|
||||
- Contextual follow-ups ("you've watched 5 things — did you know about
|
||||
watchlists?") reusing the same manifest with a different `tour_id`.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Invitation expiry** — 7 days assumed. Admin-configurable server setting,
|
||||
or fixed?
|
||||
2. **Tour replay entry point** — Settings → Personalize is assumed. Confirm
|
||||
that is the right home rather than a Help menu.
|
||||
3. **`tour_id` versioning** — when the tour changes materially, should users
|
||||
who completed `core-2026-07` be re-prompted for `core-2027-01`, or does
|
||||
completion mean completion forever?
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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
|
||||
disabled, the playback handler selects a lower-resolution alternate file.
|
||||
Today it continues using the target resolution supplied for the original file.
|
||||
That lets a 1080p alternate reach FFmpeg with a 2160p target, causing an
|
||||
unnecessary upscale and making the persisted playback activity claim that
|
||||
2160p was delivered.
|
||||
|
||||
Production logs confirmed this exact path with a 1080p H.264 alternate and a
|
||||
QSV `scale_vaapi` filter targeting 2160 lines.
|
||||
|
||||
## Decision
|
||||
|
||||
The server owns the output-resolution invariant. After all alternate-file and
|
||||
probe selection has completed, and before planning or starting any local or
|
||||
remote transport, it will normalize an encoded-video target so it cannot
|
||||
exceed the effective source file's resolution.
|
||||
|
||||
The normalization rules are:
|
||||
|
||||
- Do not apply the new clamp to video-copy requests; preserve their existing
|
||||
downstream recipe normalization.
|
||||
- Preserve an empty or unrecognized requested resolution unchanged.
|
||||
- Preserve an empty or unrecognized effective source resolution unchanged.
|
||||
- Preserve targets at or below the effective source resolution.
|
||||
- Clamp a recognized target above a recognized effective source resolution to
|
||||
the effective source resolution.
|
||||
- Do not alter target bitrate. The observed 10 Mbps value is already a valid
|
||||
1080p-high encoding target and is not the demonstrated defect.
|
||||
|
||||
Recognized resolutions are the same tiers already supported by the transcode
|
||||
filter builders: 2160p, 1080p, 720p, 480p, 420p, and 328p.
|
||||
|
||||
## Where it lives now
|
||||
|
||||
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:
|
||||
|
||||
- `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`;
|
||||
- playback-session replacement and activity reporting;
|
||||
- later transport reconstruction and restart recipes.
|
||||
|
||||
This boundary is preferred over a web-only correction because every client can
|
||||
call the endpoint, and over an FFmpeg-only correction because session state and
|
||||
remote transcode requests must remain truthful.
|
||||
|
||||
## Compatibility and Error Handling
|
||||
|
||||
The change is additive enforcement inside the existing `/api/v1` endpoint. It
|
||||
does not change request or response shapes, status codes, codec selection,
|
||||
alternate-file selection, or copy-video behavior. Unsupported resolution
|
||||
strings retain their existing behavior rather than being silently reinterpreted.
|
||||
|
||||
No server settings, migrations, client changes, or production deployment
|
||||
changes are part of this work.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests will establish:
|
||||
|
||||
1. A 2160p encode request switched to a 1080p alternate reaches the remote
|
||||
transcode node as 1080p and persists 1080p in the playback session.
|
||||
2. The same normalized request reaches the local transcode option boundary.
|
||||
3. Targets already below the source remain unchanged.
|
||||
4. Copy-video and unknown-resolution requests remain unchanged.
|
||||
5. Existing playback-handler and playback-package suites remain green.
|
||||
|
||||
The regression must fail against unmodified main before the implementation is
|
||||
added.
|
||||
|
||||
## Security and Operational Impact
|
||||
|
||||
The server currently permits an authenticated user with transcoding permission
|
||||
to amplify compute and bandwidth by requesting a recognized output larger than
|
||||
the effective source. The clamp removes that unnecessary amplification while
|
||||
retaining all authorized transcoding behavior.
|
||||
|
||||
Rollback is a single code commit. No data rollback is required.
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.45.0
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
github.com/open-policy-agent/opa v1.18.2
|
||||
github.com/pgvector/pgvector-go v0.3.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
github.com/zishang520/socket.io/v2 v2.5.0
|
||||
@@ -78,9 +79,8 @@ require (
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.11.1 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
@@ -109,7 +109,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.0
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
|
||||
@@ -4,8 +4,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0 h1:7MnaSIJBVdRE8FhfLXWx7Pe+Q2apJiaPMotfhb1VHoE=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.0 h1:BXf4cuNbOIIsrVJrRdlQnbc1m04X6lcPSZI+AFLMeK0=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
@@ -238,8 +238,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/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// PreferredMetadataLanguage resolves catalog.metadata_language canonically for
|
||||
// one profile: the stored profile-scope value, else the contract default. The
|
||||
// legacy user_profiles.preferred_metadata_language column is deliberately not
|
||||
// consulted — it migrated to the canonical store, and reading both would let
|
||||
// them disagree.
|
||||
//
|
||||
// Resolution is unconstrained on purpose. The manifest gives this key no
|
||||
// constrained_by because the policy input that could constrain it
|
||||
// (profile_preferred_metadata_language) is populated from this very
|
||||
// preference; a constraint here would be circular. See the key's notes in
|
||||
// contracts/settings/v1/manifest.json.
|
||||
//
|
||||
// A resolution failure degrades to "" — the contract default, meaning "inherit
|
||||
// the library's metadata language" — rather than failing scope resolution: the
|
||||
// language is a presentation preference, not an access boundary. The failure
|
||||
// itself is logged, though: before the cutover this value rode on the profile
|
||||
// row whose load failure was a hard error, and a store outage that silently
|
||||
// degrades every profile's metadata language would otherwise be
|
||||
// indistinguishable from "nobody set a preference".
|
||||
func PreferredMetadataLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
|
||||
if store == nil || profileID == "" {
|
||||
return ""
|
||||
}
|
||||
resolved, _ := resolveCanonicalViewerPreferences(ctx, store, profileID)
|
||||
return resolved.preferences.PreferredMetadataLanguage
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// settingStoreStub only answers the one method resolution reaches; the
|
||||
// embedded nil interface panics on anything else, which is the point — this
|
||||
// path must not touch the rest of the store.
|
||||
type settingStoreStub struct {
|
||||
userstore.UserStore
|
||||
rows []userstore.SettingValue
|
||||
err error
|
||||
}
|
||||
|
||||
func (s settingStoreStub) ListSettingValuesForResolution(
|
||||
context.Context, userstore.SettingResolutionQuery,
|
||||
) ([]userstore.SettingValue, error) {
|
||||
return s.rows, s.err
|
||||
}
|
||||
|
||||
type capturingLogHandler struct {
|
||||
mu sync.Mutex
|
||||
records []slog.Record
|
||||
}
|
||||
|
||||
func (h *capturingLogHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
func (h *capturingLogHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.mu.Lock()
|
||||
h.records = append(h.records, r)
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (h *capturingLogHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||
func (h *capturingLogHandler) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
func (h *capturingLogHandler) snapshot() []slog.Record {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return append([]slog.Record(nil), h.records...)
|
||||
}
|
||||
|
||||
func captureLogs(t *testing.T) *capturingLogHandler {
|
||||
t.Helper()
|
||||
handler := &capturingLogHandler{}
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(handler))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
return handler
|
||||
}
|
||||
|
||||
// TestPreferredMetadataLanguageLogsStoreFailures pins the operator signal: the
|
||||
// value deliberately degrades to "" on a store failure, but before the cutover
|
||||
// it rode on the already-loaded profile row where a load failure was a hard
|
||||
// error. A silent degrade would make transient pool exhaustion — or a
|
||||
// persistently broken query path — indistinguishable from "no preference".
|
||||
func TestPreferredMetadataLanguageLogsStoreFailures(t *testing.T) {
|
||||
handler := captureLogs(t)
|
||||
|
||||
store := settingStoreStub{err: errors.New("connection pool exhausted")}
|
||||
if got := PreferredMetadataLanguage(context.Background(), store, "profile-1"); got != "" {
|
||||
t.Fatalf("degraded value = %q, want \"\"", got)
|
||||
}
|
||||
|
||||
records := handler.snapshot()
|
||||
if len(records) == 0 {
|
||||
t.Fatal("a store failure resolved to the default with no log output")
|
||||
}
|
||||
record := records[0]
|
||||
if record.Level < slog.LevelWarn {
|
||||
t.Errorf("logged at %v, want at least WARN", record.Level)
|
||||
}
|
||||
var loggedError, loggedProfile bool
|
||||
record.Attrs(func(a slog.Attr) bool {
|
||||
switch a.Key {
|
||||
case "error":
|
||||
loggedError = strings.Contains(a.Value.String(), "connection pool exhausted")
|
||||
case "profile_id":
|
||||
loggedProfile = a.Value.String() == "profile-1"
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !loggedError {
|
||||
t.Errorf("log %q does not carry the store error", record.Message)
|
||||
}
|
||||
if !loggedProfile {
|
||||
t.Errorf("log %q does not name the profile", record.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored: the healthy
|
||||
// no-preference answer must not spam the log.
|
||||
func TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored(t *testing.T) {
|
||||
handler := captureLogs(t)
|
||||
|
||||
if got := PreferredMetadataLanguage(context.Background(), settingStoreStub{}, "profile-1"); got != "" {
|
||||
t.Fatalf("no-preference value = %q, want \"\"", got)
|
||||
}
|
||||
if records := handler.snapshot(); len(records) != 0 {
|
||||
t.Errorf("healthy resolution logged %d records, want none", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveViewerPreferencesIncludesMetadataLanguageOverrides(t *testing.T) {
|
||||
store := settingStoreStub{rows: []userstore.SettingValue{
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
},
|
||||
Value: json.RawMessage(`[]`),
|
||||
},
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage, Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
},
|
||||
Value: json.RawMessage(`"en"`),
|
||||
},
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
},
|
||||
Value: json.RawMessage(`{"nor":"x-silo-original","JA":"de"}`),
|
||||
},
|
||||
}}
|
||||
|
||||
preferences := ResolveViewerPreferences(context.Background(), store, "profile-1")
|
||||
if preferences.PreferredMetadataLanguage != "en" {
|
||||
t.Fatalf("fallback language = %q, want en", preferences.PreferredMetadataLanguage)
|
||||
}
|
||||
want := map[string]string{"no": OriginalMetadataLanguage, "ja": "de"}
|
||||
if len(preferences.MetadataLanguageOverrides) != len(want) {
|
||||
t.Fatalf("metadata overrides = %#v, want %#v", preferences.MetadataLanguageOverrides, want)
|
||||
}
|
||||
for source, target := range want {
|
||||
if preferences.MetadataLanguageOverrides[source] != target {
|
||||
t.Errorf("metadata override %q = %q, want %q", source, preferences.MetadataLanguageOverrides[source], target)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-13
@@ -10,8 +10,11 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// settingKeyDisabledLibraryIDs is the user-settings key that stores a JSON
|
||||
// array of library IDs the user has chosen to hide.
|
||||
// settingKeyDisabledLibraryIDs is the legacy account-wide user-settings key
|
||||
// that stored a JSON array of library IDs the user had chosen to hide. It is
|
||||
// read only as a fallback now: the setting moved to the profile-scoped
|
||||
// canonical key ui.disabled_library_ids, and the legacy write endpoint no
|
||||
// longer accepts this key.
|
||||
const settingKeyDisabledLibraryIDs = "disabled_library_ids"
|
||||
|
||||
// UserRepository loads account-level access settings.
|
||||
@@ -72,6 +75,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
return Scope{}, fmt.Errorf("opening user store for %d: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
preferences := ResolveViewerPreferences(ctx, store, input.ProfileID)
|
||||
if input.ProfileID != "" {
|
||||
profile, err := store.GetProfile(ctx, input.ProfileID)
|
||||
if err != nil {
|
||||
@@ -83,7 +87,8 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
|
||||
scope.MaxContentRating = profile.MaxContentRating
|
||||
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
|
||||
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
|
||||
scope.PreferredMetadataLanguage = preferences.PreferredMetadataLanguage
|
||||
scope.MetadataLanguageOverrides = preferences.MetadataLanguageOverrides
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
|
||||
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
|
||||
if err != nil {
|
||||
@@ -92,8 +97,8 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
scope.ProfileVerified = verified
|
||||
}
|
||||
|
||||
// Apply user-level disabled library IDs setting.
|
||||
disabled := DisabledLibraryIDs(ctx, store)
|
||||
// Apply the profile's disabled library IDs setting.
|
||||
disabled := preferences.DisabledLibraryIDs
|
||||
if len(disabled) > 0 {
|
||||
if scope.AllowedLibraryIDs != nil {
|
||||
// Restricted user: subtract disabled IDs from the allowed set.
|
||||
@@ -139,17 +144,29 @@ func VerifyProfileForRequest(
|
||||
return profileVerified, nil
|
||||
}
|
||||
|
||||
// DisabledLibraryIDs reads and parses the disabled_library_ids user setting.
|
||||
func DisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
|
||||
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
|
||||
if err != nil || raw == "" {
|
||||
return nil
|
||||
}
|
||||
// DisabledLibraryIDs resolves the libraries the acting profile has hidden from
|
||||
// its own browsing: the canonical profile-scoped ui.disabled_library_ids row,
|
||||
// else the legacy account-wide disabled_library_ids setting.
|
||||
//
|
||||
// The canonical row is what the web writes since the settings cutover — the
|
||||
// legacy endpoint rejects the unregistered key, so an account-key read alone
|
||||
// would silently ignore every edit made after the cutover. The legacy fallback
|
||||
// stays because the one-time backfill only ran on stores that existed when it
|
||||
// shipped: a store restored from a pre-backfill snapshot still carries its
|
||||
// hidden libraries only in the account key, and dropping the fallback would
|
||||
// unhide them. A stored canonical row always wins, so the fallback can never
|
||||
// override a post-cutover edit.
|
||||
func DisabledLibraryIDs(ctx context.Context, store userstore.UserStore, profileID string) []int {
|
||||
return ResolveViewerPreferences(ctx, store, profileID).DisabledLibraryIDs
|
||||
}
|
||||
|
||||
// parseLibraryIDList decodes a JSON library-id array, dropping anything that
|
||||
// is not a positive id. Malformed JSON reads as an empty list.
|
||||
func parseLibraryIDList(raw json.RawMessage) []int {
|
||||
var ids []int
|
||||
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
||||
if err := json.Unmarshal(raw, &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Filter out invalid values.
|
||||
n := 0
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
|
||||
@@ -2,11 +2,14 @@ package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -39,6 +42,10 @@ type stubStore struct {
|
||||
profile *userstore.Profile
|
||||
err error
|
||||
settings map[string]string
|
||||
// settingValues are the canonical setting rows the resolver may read
|
||||
// through ListSettingValuesForResolution. Scope matching is the
|
||||
// resolver's job, so the stub returns them unfiltered.
|
||||
settingValues []userstore.SettingValue
|
||||
}
|
||||
|
||||
func (s stubStore) CreateProfile(context.Context, userstore.Profile) error { panic("unused") }
|
||||
@@ -216,7 +223,19 @@ func (s stubStore) GetSetting(_ context.Context, key string) (string, error) {
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
func (s stubStore) SetSetting(context.Context, string, string) error { panic("unused") }
|
||||
func (s stubStore) SetSetting(context.Context, string, string) error { panic("unused") }
|
||||
func (s stubStore) GetOnboardingState(context.Context, string, string) (*userstore.OnboardingState, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) UpsertOnboardingState(context.Context, userstore.OnboardingState) error {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) GetJellycompatDisplayPrefs(context.Context, string, string) (string, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) SetJellycompatDisplayPrefs(context.Context, string, string, string) error {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) DeleteSetting(context.Context, string) error { panic("unused") }
|
||||
func (s stubStore) ListSettings(context.Context) ([]userstore.SettingEntry, error) { panic("unused") }
|
||||
func (s stubStore) GetDeviceSetting(context.Context, string, string, string) (*userstore.DeviceSettingEntry, error) {
|
||||
@@ -271,6 +290,42 @@ func (s stubStore) UpsertLibraryPlaybackPreference(context.Context, userstore.Li
|
||||
func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int) error {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) GetSettingValue(context.Context, userstore.SettingIdentity) (*userstore.SettingValue, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
|
||||
return s.settingValues, nil
|
||||
}
|
||||
func (s stubStore) ListAllSettingValues(context.Context) ([]userstore.SettingValue, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) UpsertSettingValue(context.Context, userstore.SettingIdentity, json.RawMessage) (*userstore.SettingValue, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) DeleteSettingValue(context.Context, userstore.SettingIdentity) (bool, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) DeleteSettingValuesForProfile(context.Context, string) (int64, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) DeleteSettingValuesForDevice(context.Context, string, string) (int64, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) DeleteSettingValuesForLibrary(context.Context, int) (int64, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) DeleteSettingValuesForSeries(context.Context, string) (int64, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) GetSettingMutation(context.Context, string) (*userstore.SettingMutationRecord, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) PutSettingMutation(context.Context, userstore.SettingMutationRecord) (userstore.SettingMutationRecord, bool, error) {
|
||||
panic("unused")
|
||||
}
|
||||
func (s stubStore) DeleteExpiredSettingMutations(context.Context, time.Time) (int64, error) {
|
||||
panic("unused")
|
||||
}
|
||||
|
||||
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
@@ -406,6 +461,93 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledLibraries_CanonicalRowWins(t *testing.T) {
|
||||
// The canonical profile-scoped ui.disabled_library_ids row wins; the
|
||||
// legacy account key carries a decoy value that must not be read once a
|
||||
// canonical row exists.
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{ID: "prof-1"},
|
||||
settings: map[string]string{"disabled_library_ids": "[9]"},
|
||||
settingValues: []userstore.SettingValue{{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.UiDisabledLibraryIds,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`[3,5]`),
|
||||
}},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if len(scope.DisabledLibraryIDs) != 2 || scope.DisabledLibraryIDs[0] != 3 || scope.DisabledLibraryIDs[1] != 5 {
|
||||
t.Fatalf("DisabledLibraryIDs = %v, want canonical [3 5]", scope.DisabledLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledLibraries_CanonicalNullClearsLegacy(t *testing.T) {
|
||||
// A stored null spells "no hidden libraries" and still wins over the
|
||||
// legacy key: the row exists, so the profile has decided.
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{ID: "prof-1"},
|
||||
settings: map[string]string{"disabled_library_ids": "[9]"},
|
||||
settingValues: []userstore.SettingValue{{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.UiDisabledLibraryIds,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`null`),
|
||||
}},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if len(scope.DisabledLibraryIDs) != 0 {
|
||||
t.Fatalf("DisabledLibraryIDs = %v, want empty", scope.DisabledLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledLibraries_ProfileIsolation(t *testing.T) {
|
||||
// Profile A's canonical hidden-library list must not leak into profile B:
|
||||
// with no canonical row of its own and no legacy key, B hides nothing.
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{ID: "prof-b"},
|
||||
settingValues: []userstore.SettingValue{{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.UiDisabledLibraryIds,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-a",
|
||||
},
|
||||
Value: json.RawMessage(`[3,5]`),
|
||||
}},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-b"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if len(scope.DisabledLibraryIDs) != 0 {
|
||||
t.Fatalf("DisabledLibraryIDs = %v, want empty for the other profile", scope.DisabledLibraryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
@@ -427,6 +569,73 @@ func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
|
||||
// The canonical catalog.metadata_language row wins; the legacy profile
|
||||
// column carries a decoy value that must no longer be read.
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
},
|
||||
settingValues: []userstore.SettingValue{
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`"de"`),
|
||||
},
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguageOverrides,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`{"no":"x-silo-original"}`),
|
||||
},
|
||||
},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if scope.PreferredMetadataLanguage != "de" {
|
||||
t.Fatalf("PreferredMetadataLanguage = %q, want canonical value %q", scope.PreferredMetadataLanguage, "de")
|
||||
}
|
||||
if got := scope.MetadataLanguageOverrides["no"]; got != OriginalMetadataLanguage {
|
||||
t.Fatalf("MetadataLanguageOverrides[no] = %q, want %q", got, OriginalMetadataLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MetadataLanguageIgnoresLegacyColumn(t *testing.T) {
|
||||
// A profile with only the legacy column value falls to the contract
|
||||
// default ("" — inherit), proving the column is no longer read.
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
|
||||
stubStoreProvider{store: stubStore{
|
||||
profile: &userstore.Profile{
|
||||
ID: "prof-1",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
|
||||
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if scope.PreferredMetadataLanguage != "" {
|
||||
t.Fatalf("PreferredMetadataLanguage = %q, want contract default \"\"", scope.PreferredMetadataLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_AppliesGroupPolicy(t *testing.T) {
|
||||
resolver := NewResolver(
|
||||
stubUserRepo{user: &models.User{
|
||||
|
||||
@@ -12,6 +12,9 @@ type Scope struct {
|
||||
// PreferredMetadataLanguage is the profile's metadata (presentation)
|
||||
// language; "" inherits the library's metadata language.
|
||||
PreferredMetadataLanguage string
|
||||
// MetadataLanguageOverrides maps a media item's canonical original-language
|
||||
// code to the profile's target metadata language for that source.
|
||||
MetadataLanguageOverrides map[string]string
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/lang"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// ViewerPreferences are the canonical preferences needed while constructing
|
||||
// an access scope. They are resolved together because this path runs on nearly
|
||||
// every authenticated request and one candidate read can answer both keys.
|
||||
type ViewerPreferences struct {
|
||||
DisabledLibraryIDs []int
|
||||
PreferredMetadataLanguage string
|
||||
MetadataLanguageOverrides map[string]string
|
||||
}
|
||||
|
||||
// ResolveViewerPreferences resolves the profile's viewer-scope preferences in
|
||||
// one canonical store read. The legacy disabled_library_ids account setting is
|
||||
// consulted only when no canonical row decided that value.
|
||||
func ResolveViewerPreferences(
|
||||
ctx context.Context, store userstore.UserStore, profileID string,
|
||||
) ViewerPreferences {
|
||||
profileID = strings.TrimSpace(profileID)
|
||||
if store == nil {
|
||||
return ViewerPreferences{}
|
||||
}
|
||||
if profileID == "" {
|
||||
return ViewerPreferences{DisabledLibraryIDs: legacyDisabledLibraryIDs(ctx, store)}
|
||||
}
|
||||
|
||||
resolved, ok := resolveCanonicalViewerPreferences(ctx, store, profileID)
|
||||
if !ok || !resolved.disabledLibraryIDsSet {
|
||||
resolved.preferences.DisabledLibraryIDs = legacyDisabledLibraryIDs(ctx, store)
|
||||
}
|
||||
return resolved.preferences
|
||||
}
|
||||
|
||||
type canonicalViewerPreferences struct {
|
||||
preferences ViewerPreferences
|
||||
disabledLibraryIDsSet bool
|
||||
}
|
||||
|
||||
func resolveCanonicalViewerPreferences(
|
||||
ctx context.Context, store userstore.UserStore, profileID string,
|
||||
) (canonicalViewerPreferences, bool) {
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "viewer preference resolution degraded: loading settings contract failed",
|
||||
"component", "access", "profile_id", profileID, "error", err)
|
||||
return canonicalViewerPreferences{}, false
|
||||
}
|
||||
values, err := settingsresolve.New(contract).Resolve(ctx, store,
|
||||
settingsresolve.Context{ProfileID: profileID},
|
||||
[]string{
|
||||
settingskeys.UiDisabledLibraryIds,
|
||||
settingskeys.CatalogMetadataLanguage,
|
||||
settingskeys.CatalogMetadataLanguageOverrides,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "viewer preference resolution degraded: reading setting values failed",
|
||||
"component", "access", "profile_id", profileID, "error", err)
|
||||
return canonicalViewerPreferences{}, false
|
||||
}
|
||||
|
||||
var out canonicalViewerPreferences
|
||||
for _, value := range values {
|
||||
switch value.Key {
|
||||
case settingskeys.UiDisabledLibraryIds:
|
||||
out.disabledLibraryIDsSet = value.Source != settingscontract.ScopeDefault
|
||||
if out.disabledLibraryIDsSet {
|
||||
out.preferences.DisabledLibraryIDs = parseLibraryIDList(value.Value)
|
||||
}
|
||||
case settingskeys.CatalogMetadataLanguage:
|
||||
var language string
|
||||
if json.Unmarshal(value.Value, &language) == nil {
|
||||
out.preferences.PreferredMetadataLanguage = strings.TrimSpace(language)
|
||||
}
|
||||
case settingskeys.CatalogMetadataLanguageOverrides:
|
||||
out.preferences.MetadataLanguageOverrides = parseMetadataLanguageOverrides(value.Value)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// OriginalMetadataLanguage is a private-use BCP 47 tag stored in
|
||||
// catalog.metadata_language (or as an override target) to mean "resolve this
|
||||
// media item's original language." Keeping the sentinel a valid language tag
|
||||
// preserves the existing setting's wire type.
|
||||
const OriginalMetadataLanguage = "x-silo-original"
|
||||
|
||||
// parseMetadataLanguageOverrides normalizes source aliases and target tag
|
||||
// casing before the map reaches catalog serving. The contract only accepts
|
||||
// canonical source keys from normal clients, but normalization also makes old
|
||||
// or manually-authored rows deterministic.
|
||||
func parseMetadataLanguageOverrides(raw json.RawMessage) map[string]string {
|
||||
var stored map[string]string
|
||||
if json.Unmarshal(raw, &stored) != nil || len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(stored))
|
||||
for source := range stored {
|
||||
keys = append(keys, source)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
out := make(map[string]string, len(stored))
|
||||
for _, source := range keys {
|
||||
canonicalSource := lang.Canonical(source)
|
||||
if canonicalSource == "" {
|
||||
continue
|
||||
}
|
||||
// When aliases collapse (for example en and eng), the canonical key
|
||||
// wins because it sorts first and is never overwritten.
|
||||
if _, exists := out[canonicalSource]; exists {
|
||||
continue
|
||||
}
|
||||
target, ok := settingscontract.NormalizeLanguageTag(stored[source])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[canonicalSource] = target
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func legacyDisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
|
||||
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
|
||||
if err != nil || raw == "" {
|
||||
return nil
|
||||
}
|
||||
return parseLibraryIDList(json.RawMessage(raw))
|
||||
}
|
||||
@@ -40,13 +40,28 @@ type deleteLibraryExecutor interface {
|
||||
Execute(ctx context.Context, req DeleteLibraryRequest, progress func(current, total int, message string)) (*DeleteLibraryResult, error)
|
||||
}
|
||||
|
||||
type LibraryDeleteExecutor struct {
|
||||
folderRepo *catalog.FolderRepository
|
||||
sectionRepo *sections.Repository
|
||||
// LibrarySettingsCleaner removes per-user canonical setting values scoped to a
|
||||
// deleted library. Satisfied by *userstore.SettingValuesCleaner.
|
||||
type LibrarySettingsCleaner interface {
|
||||
DeleteForLibrary(ctx context.Context, libraryID int) int64
|
||||
}
|
||||
|
||||
func NewLibraryDeleteExecutor(folderRepo *catalog.FolderRepository, sectionRepo *sections.Repository) *LibraryDeleteExecutor {
|
||||
return &LibraryDeleteExecutor{folderRepo: folderRepo, sectionRepo: sectionRepo}
|
||||
type LibraryDeleteExecutor struct {
|
||||
folderRepo *catalog.FolderRepository
|
||||
sectionRepo *sections.Repository
|
||||
settingsCleaner LibrarySettingsCleaner
|
||||
}
|
||||
|
||||
func NewLibraryDeleteExecutor(
|
||||
folderRepo *catalog.FolderRepository,
|
||||
sectionRepo *sections.Repository,
|
||||
settingsCleaner LibrarySettingsCleaner,
|
||||
) *LibraryDeleteExecutor {
|
||||
return &LibraryDeleteExecutor{
|
||||
folderRepo: folderRepo,
|
||||
sectionRepo: sectionRepo,
|
||||
settingsCleaner: settingsCleaner,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LibraryDeleteExecutor) Execute(
|
||||
@@ -82,6 +97,13 @@ func (e *LibraryDeleteExecutor) Execute(
|
||||
return nil, fmt.Errorf("deleting generated home sections: %w", err)
|
||||
}
|
||||
}
|
||||
if e.settingsCleaner != nil {
|
||||
// The canonical settings schema declares no FK on library_id, so the
|
||||
// per-user profile_library values must go with the library or they
|
||||
// orphan. Best-effort inside the cleaner: the library itself is
|
||||
// already deleted at this point.
|
||||
e.settingsCleaner.DeleteForLibrary(ctx, req.LibraryID)
|
||||
}
|
||||
if progress != nil {
|
||||
progress(5, 5, "Library deletion completed")
|
||||
}
|
||||
|
||||
@@ -118,11 +118,39 @@ func (l *PGLibraryRefreshItemLister) ListLibraryItems(ctx context.Context, libra
|
||||
COALESCE(mi.tvdb_id, '') <> ''
|
||||
OR COALESCE(mi.imdb_id, '') <> ''
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM stale_media_ids rejected_tmdb
|
||||
WHERE rejected_tmdb.content_id = mi.content_id
|
||||
AND LOWER(TRIM(rejected_tmdb.provider)) = 'tmdb'
|
||||
AND TRIM(rejected_tmdb.provider_id) <> ''
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM stale_media_ids smi
|
||||
WHERE smi.content_id = mi.content_id
|
||||
AND LOWER(TRIM(smi.provider)) IN ('tmdb', 'tvdb', 'imdb')
|
||||
AND TRIM(smi.provider_id) <> ''
|
||||
AND (
|
||||
LOWER(TRIM(COALESCE(mi.status, ''))) <> 'matched'
|
||||
OR (
|
||||
LOWER(TRIM(smi.provider)) = 'tmdb'
|
||||
AND TRIM(smi.provider_id) = TRIM(COALESCE(mi.tmdb_id, ''))
|
||||
)
|
||||
OR (
|
||||
LOWER(TRIM(smi.provider)) = 'tvdb'
|
||||
AND TRIM(smi.provider_id) = TRIM(COALESCE(mi.tvdb_id, ''))
|
||||
)
|
||||
OR (
|
||||
LOWER(TRIM(smi.provider)) = 'imdb'
|
||||
AND LOWER(TRIM(smi.provider_id)) = LOWER(TRIM(COALESCE(mi.imdb_id, '')))
|
||||
)
|
||||
OR LOWER(TRIM(mi.content_id)) =
|
||||
'movie-' || LOWER(TRIM(smi.provider)) || '-' || LOWER(TRIM(smi.provider_id))
|
||||
OR LOWER(TRIM(mi.content_id)) =
|
||||
'series-' || LOWER(TRIM(smi.provider)) || '-' || LOWER(TRIM(smi.provider_id))
|
||||
)
|
||||
)
|
||||
OR (
|
||||
COALESCE(mi.default_metadata_language, '') <> ''
|
||||
|
||||
@@ -91,3 +91,102 @@ func TestQuickRefreshListsLanguageMismatchedItems(t *testing.T) {
|
||||
t.Errorf("quick refresh must not include complete item whose stamped language matches the library language")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickRefreshIgnoresHistoricalSecondaryStaleIDs(t *testing.T) {
|
||||
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)
|
||||
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
historicalID := "movie-tmdb-" + suffix
|
||||
actionableProviderID := suffix + "1"
|
||||
actionableID := "movie-tmdb-" + actionableProviderID
|
||||
rejectedSiblingID := "series-tvdb-" + suffix
|
||||
var folderID int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO media_folders (type, name, enabled, metadata_language)
|
||||
VALUES ('movies', 'Stale Refresh Test', true, 'en')
|
||||
RETURNING id
|
||||
`).Scan(&folderID); err != nil {
|
||||
t.Fatalf("seed folder: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{historicalID, actionableID, rejectedSiblingID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
|
||||
})
|
||||
|
||||
for _, row := range []struct {
|
||||
contentID, tmdbID, staleProvider, staleProviderID string
|
||||
}{
|
||||
{historicalID, suffix, "tvdb", "999999"},
|
||||
{actionableID, actionableProviderID, "tmdb", actionableProviderID},
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_items (
|
||||
content_id, type, title, status, genres, tmdb_id,
|
||||
default_metadata_language, overview, poster_path, backdrop_path,
|
||||
last_refreshed, refresh_failures, episode_metadata_incomplete
|
||||
) VALUES ($1, 'movie', 'Complete Item', 'matched', '{}'::text[], $2,
|
||||
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
|
||||
`, row.contentID, row.tmdbID); err != nil {
|
||||
t.Fatalf("seed media item %s: %v", row.contentID, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
|
||||
`, row.contentID, folderID); err != nil {
|
||||
t.Fatalf("link media item %s: %v", row.contentID, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO stale_media_ids (content_id, provider, provider_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`, row.contentID, row.staleProvider, row.staleProviderID); err != nil {
|
||||
t.Fatalf("seed stale id for %s: %v", row.contentID, err)
|
||||
}
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_items (
|
||||
content_id, type, title, status, genres, tvdb_id,
|
||||
default_metadata_language, overview, poster_path, backdrop_path,
|
||||
last_refreshed, refresh_failures, episode_metadata_incomplete
|
||||
) VALUES ($1, 'series', 'Complete TVDB Item', 'matched', '{}'::text[], $2,
|
||||
'en', 'An overview', '/p.jpg', '/b.jpg', NOW(), 0, FALSE)
|
||||
`, rejectedSiblingID, suffix); err != nil {
|
||||
t.Fatalf("seed TVDB-only media item: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)
|
||||
`, rejectedSiblingID, folderID); err != nil {
|
||||
t.Fatalf("link TVDB-only media item: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO stale_media_ids (content_id, provider, provider_id)
|
||||
VALUES ($1, 'tmdb', $2)
|
||||
`, rejectedSiblingID, suffix+"2"); err != nil {
|
||||
t.Fatalf("seed rejected TMDB sibling: %v", err)
|
||||
}
|
||||
|
||||
items, err := NewPGLibraryRefreshItemLister(pool).ListLibraryItems(ctx, folderID, LibraryRefreshModeQuick)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLibraryItems: %v", err)
|
||||
}
|
||||
listed := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
listed[item.ContentID] = true
|
||||
}
|
||||
if listed[historicalID] {
|
||||
t.Fatal("historical secondary stale ID must not keep a complete item in quick refresh")
|
||||
}
|
||||
if !listed[actionableID] {
|
||||
t.Fatal("current canonical stale ID must remain in quick refresh")
|
||||
}
|
||||
if listed[rejectedSiblingID] {
|
||||
t.Fatal("a rejected TMDB sibling must not keep provider-ID enrichment debt active")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,20 +99,51 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
Description: scanSourceMetadataString(c, "description"),
|
||||
Descriptor: scanSourceDescriptor(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanSourceMetadataString reads a trimmed string out of a capability's
|
||||
// manifest metadata, returning "" when absent or of the wrong type.
|
||||
func scanSourceMetadataString(c *plugins.Capability, key string) string {
|
||||
if c == nil || c.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := c.Metadata[key].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// scanSourceDescriptor resolves the setup contract for a discovered capability.
|
||||
// It reads whatever the manifest declares (falling back to host defaults that
|
||||
// reproduce pre-descriptor behavior), then lets a host-side compatibility
|
||||
// descriptor fill gaps for first-party plugins that have not yet published one
|
||||
// of their own.
|
||||
func scanSourceDescriptor(pluginID string, c *plugins.Capability) autoscan.ScanSourceDescriptor {
|
||||
var metadata map[string]any
|
||||
if c != nil {
|
||||
metadata = c.Metadata
|
||||
}
|
||||
declared := autoscan.DescriptorFromMetadata(metadata)
|
||||
capabilityID := ""
|
||||
if c != nil {
|
||||
capabilityID = c.ID
|
||||
}
|
||||
return autoscan.ApplyCompatibilityDescriptor(pluginID, capabilityID, declared)
|
||||
}
|
||||
|
||||
// scanSourceDisplayName derives a human-friendly label for a scan_source
|
||||
// capability: the capability manifest's display_name when present, else the
|
||||
// plugin id (with the capability id appended when it adds information).
|
||||
func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
||||
if c != nil && c.Metadata != nil {
|
||||
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
if name := scanSourceMetadataString(c, "display_name"); name != "" {
|
||||
return name
|
||||
}
|
||||
switch {
|
||||
case pluginID != "" && c != nil && c.ID != "":
|
||||
|
||||
+58
-366
@@ -37,6 +37,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/policy"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
|
||||
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
@@ -1355,10 +1357,6 @@ type adminSettingResponse struct {
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}
|
||||
|
||||
type adminSettingsListResponse struct {
|
||||
Settings []adminSettingResponse `json:"settings"`
|
||||
}
|
||||
|
||||
type adminDeviceSettingResponse struct {
|
||||
UserID int `json:"user_id"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
@@ -1413,345 +1411,6 @@ type adminDeviceDetailResponse struct {
|
||||
Settings []adminDeviceSettingResponse `json:"settings"`
|
||||
}
|
||||
|
||||
// HandleListUserSettings handles GET /admin/users/{id}/settings.
|
||||
func (h *AdminHandler) HandleListUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := store.ListSettings(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list settings")
|
||||
return
|
||||
}
|
||||
resp := adminSettingsListResponse{
|
||||
Settings: make([]adminSettingResponse, 0, len(entries)),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !keyUsesUserScope(entry.Key) {
|
||||
continue
|
||||
}
|
||||
resp.Settings = append(resp.Settings, adminSettingResponse{
|
||||
Key: entry.Key,
|
||||
Value: entry.Value,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleGetUserSetting handles GET /admin/users/{id}/settings/{key}.
|
||||
func (h *AdminHandler) HandleGetUserSetting(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
||||
return
|
||||
}
|
||||
if !keyUsesUserScope(key) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
value, err := store.GetSetting(r.Context(), key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
|
||||
return
|
||||
}
|
||||
if value == "" {
|
||||
entries, err := store.ListSettings(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load setting")
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if entry.Key == key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Setting not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: value})
|
||||
}
|
||||
|
||||
// HandleUpdateUserSetting handles PUT /admin/users/{id}/settings/{key}.
|
||||
func (h *AdminHandler) HandleUpdateUserSetting(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
||||
return
|
||||
}
|
||||
if !keyUsesUserScope(key) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
|
||||
return
|
||||
}
|
||||
var req updateSettingRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if err := validateRegisteredSetting(key, req.Value, scopeUser); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := store.SetSetting(r.Context(), key, req.Value); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
|
||||
}
|
||||
|
||||
// HandleDeleteUserSetting handles DELETE /admin/users/{id}/settings/{key}.
|
||||
func (h *AdminHandler) HandleDeleteUserSetting(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
||||
return
|
||||
}
|
||||
if !keyUsesUserScope(key) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("%s is not a %s setting", key, scopeUser))
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := store.DeleteSetting(r.Context(), key); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete setting")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListUserDeviceSettings handles GET /admin/users/{id}/device-settings.
|
||||
func (h *AdminHandler) HandleListUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := store.ListAllDeviceSettings(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
|
||||
return
|
||||
}
|
||||
profileNames, err := listProfileNamesByID(r.Context(), store)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
|
||||
}
|
||||
|
||||
// HandleListUserDeviceSettingsByKey handles GET /admin/users/{id}/device-settings/{key}.
|
||||
func (h *AdminHandler) HandleListUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := store.ListDeviceSettings(r.Context(), key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list device settings")
|
||||
return
|
||||
}
|
||||
profileNames, err := listProfileNamesByID(r.Context(), store)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list profiles")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildAdminDeviceSettingsResponse(userID, profileNames, entries))
|
||||
}
|
||||
|
||||
// HandleUpdateUserDeviceSetting handles PUT /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
|
||||
func (h *AdminHandler) HandleUpdateUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
|
||||
if profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
|
||||
return
|
||||
}
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
||||
return
|
||||
}
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
|
||||
return
|
||||
}
|
||||
var req updateSettingRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if err := validateRegisteredSetting(key, req.Value, scopeDevice); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !adminProfileExists(w, r, store, profileID) {
|
||||
return
|
||||
}
|
||||
existing, err := store.GetDeviceSetting(r.Context(), profileID, deviceID, key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device setting")
|
||||
return
|
||||
}
|
||||
entry := userstore.DeviceSettingEntry{
|
||||
ProfileID: profileID,
|
||||
DeviceID: deviceID,
|
||||
Key: key,
|
||||
Value: req.Value,
|
||||
}
|
||||
if existing != nil {
|
||||
entry.DeviceName = existing.DeviceName
|
||||
entry.DevicePlatform = existing.DevicePlatform
|
||||
} else if registered, err := registeredDeviceForProfile(r.Context(), store, profileID, deviceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
} else if registered != nil {
|
||||
entry.DeviceName = registered.DeviceName
|
||||
entry.DevicePlatform = registered.DevicePlatform
|
||||
}
|
||||
if err := store.SetDeviceSetting(r.Context(), entry); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device setting")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
|
||||
}
|
||||
|
||||
// HandleDeleteUserDeviceSetting handles DELETE /admin/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}.
|
||||
func (h *AdminHandler) HandleDeleteUserDeviceSetting(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
|
||||
if profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
|
||||
return
|
||||
}
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
||||
return
|
||||
}
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !adminProfileExists(w, r, store, profileID) {
|
||||
return
|
||||
}
|
||||
if err := store.DeleteDeviceSetting(r.Context(), profileID, deviceID, key); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleDeleteAllUserDeviceSettings handles DELETE /admin/users/{id}/profiles/{profile_id}/devices/{device_id}/settings.
|
||||
func (h *AdminHandler) HandleDeleteAllUserDeviceSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profileID := strings.TrimSpace(chi.URLParam(r, "profile_id"))
|
||||
deviceID := strings.TrimSpace(chi.URLParam(r, "device_id"))
|
||||
if profileID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Profile id is required")
|
||||
return
|
||||
}
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Device id is required")
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !adminProfileExists(w, r, store, profileID) {
|
||||
return
|
||||
}
|
||||
if err := store.DeleteAllDeviceSettings(r.Context(), profileID, deviceID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleDeleteUserDeviceSettingsByKey handles DELETE /admin/users/{id}/device-settings/{key}.
|
||||
func (h *AdminHandler) HandleDeleteUserDeviceSettingsByKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := parseAdminUserIDParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(chi.URLParam(r, "key"))
|
||||
if key == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Setting key is required")
|
||||
return
|
||||
}
|
||||
store, ok := h.adminUserStore(w, r, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := store.DeleteDeviceSettingsByKey(r.Context(), key); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device settings")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleListDevices handles GET /admin/devices.
|
||||
func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
if h.userRepo == nil || h.storeProv == nil {
|
||||
@@ -1779,6 +1438,10 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list device settings: %w", err)
|
||||
}
|
||||
canonicalValues, err := store.ListAllSettingValues(gctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list canonical setting values: %w", err)
|
||||
}
|
||||
devices, err := listRegisteredDevices(gctx, store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list devices: %w", err)
|
||||
@@ -1796,6 +1459,7 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request)
|
||||
user.Username,
|
||||
user.Email,
|
||||
entries,
|
||||
canonicalValues,
|
||||
devices,
|
||||
profileNames,
|
||||
)
|
||||
@@ -1862,6 +1526,11 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
}
|
||||
canonicalValues, err := store.ListAllSettingValues(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
return
|
||||
}
|
||||
registeredDevices, err := listRegisteredDevices(r.Context(), store)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load device")
|
||||
@@ -1885,11 +1554,18 @@ func (h *AdminHandler) HandleGetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceRegistrations = append(deviceRegistrations, entry)
|
||||
}
|
||||
}
|
||||
deviceCanonicalValues := make([]userstore.SettingValue, 0)
|
||||
for _, value := range canonicalValues {
|
||||
if value.Scope == settingscontract.ScopeProfileDevice && value.DeviceID == deviceID {
|
||||
deviceCanonicalValues = append(deviceCanonicalValues, value)
|
||||
}
|
||||
}
|
||||
summaries := buildAdminDeviceSummaries(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Email,
|
||||
deviceEntries,
|
||||
deviceCanonicalValues,
|
||||
deviceRegistrations,
|
||||
profileNames,
|
||||
)
|
||||
@@ -1922,25 +1598,6 @@ func listRegisteredDevices(ctx context.Context, store userstore.UserStore) ([]us
|
||||
return registry.ListDevices(ctx)
|
||||
}
|
||||
|
||||
func registeredDeviceForProfile(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
profileID string,
|
||||
deviceID string,
|
||||
) (*userstore.DeviceEntry, error) {
|
||||
devices, err := listRegisteredDevices(ctx, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, device := range devices {
|
||||
if device.ProfileID == profileID && device.DeviceID == deviceID {
|
||||
matched := device
|
||||
return &matched, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func buildAdminDeviceSettingsResponse(userID int, profileNames map[string]string, entries []userstore.DeviceSettingEntry) adminDeviceSettingsListResponse {
|
||||
resp := adminDeviceSettingsListResponse{
|
||||
Settings: make([]adminDeviceSettingResponse, 0, len(entries)),
|
||||
@@ -1966,6 +1623,7 @@ func buildAdminDeviceSummaries(
|
||||
username string,
|
||||
email string,
|
||||
entries []userstore.DeviceSettingEntry,
|
||||
canonicalValues []userstore.SettingValue,
|
||||
registeredDevices []userstore.DeviceEntry,
|
||||
profileNames map[string]string,
|
||||
) []adminDeviceSummaryResponse {
|
||||
@@ -2067,12 +1725,36 @@ func buildAdminDeviceSummaries(
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
if profileID != "" && entry.Key != "" {
|
||||
current.keys[profileID+":"+entry.Key] = struct{}{}
|
||||
key := canonicalAdminDeviceSettingKey(entry.Key)
|
||||
if profileID != "" && key != "" {
|
||||
current.keys[profileID+":"+key] = struct{}{}
|
||||
}
|
||||
profile := ensureProfile(current, profileID, entry.UpdatedAt)
|
||||
if profile != nil && entry.Key != "" {
|
||||
profile.keys[entry.Key] = struct{}{}
|
||||
if profile != nil && key != "" {
|
||||
profile.keys[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical profile_device rows are the authoritative overrides after the
|
||||
// settings cutover. Merge them by (profile,key) with the still-mounted
|
||||
// legacy rows so a mirrored value counts once while a canonical-only write
|
||||
// remains visible to fleet management.
|
||||
for _, value := range canonicalValues {
|
||||
if value.Scope != settingscontract.ScopeProfileDevice {
|
||||
continue
|
||||
}
|
||||
deviceID := strings.TrimSpace(value.DeviceID)
|
||||
profileID := strings.TrimSpace(value.ProfileID)
|
||||
current := ensureDevice(deviceID, "", "", value.UpdatedAt)
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
if profileID != "" && value.Key != "" {
|
||||
current.keys[profileID+":"+value.Key] = struct{}{}
|
||||
}
|
||||
profile := ensureProfile(current, profileID, value.UpdatedAt)
|
||||
if profile != nil && value.Key != "" {
|
||||
profile.keys[value.Key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2105,6 +1787,13 @@ func buildAdminDeviceSummaries(
|
||||
return devices
|
||||
}
|
||||
|
||||
// canonicalAdminDeviceSettingKey uses the migration's rename table so fleet
|
||||
// counts describe logical overrides and every legacy/canonical pair counts
|
||||
// once, including pre-cutover appearance rows left in the legacy table.
|
||||
func canonicalAdminDeviceSettingKey(key string) string {
|
||||
return settingsmigrate.CanonicalKey(strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
func listProfileNamesByID(ctx context.Context, store userstore.UserStore) (map[string]string, error) {
|
||||
profiles, err := store.ListProfiles(ctx)
|
||||
if err != nil {
|
||||
@@ -2586,7 +2275,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
req.Value = normalized
|
||||
case "ai.base_url", "ai.chat_model", "ai.asr_model":
|
||||
req.Value = strings.TrimSpace(req.Value)
|
||||
case "ai.asr_base_url":
|
||||
req.Value = strings.TrimSpace(req.Value)
|
||||
if llm.IsChatOnlyGateway(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/invitations"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// AdminInvitationHandler handles admin endpoints for emailed invitations.
|
||||
type AdminInvitationHandler struct {
|
||||
service *invitations.Service
|
||||
}
|
||||
|
||||
// NewAdminInvitationHandler creates a new AdminInvitationHandler.
|
||||
func NewAdminInvitationHandler(service *invitations.Service) *AdminInvitationHandler {
|
||||
return &AdminInvitationHandler{service: service}
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
type createInvitationRequest struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
AccessGroupID *int64 `json:"access_group_id"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
CreateProfile *bool `json:"create_profile"`
|
||||
ShowTour *bool `json:"show_tour"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type invitationResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
AccessGroupID *int64 `json:"access_group_id,omitempty"`
|
||||
LibraryIDs []int `json:"library_ids,omitempty"`
|
||||
CreateProfile bool `json:"create_profile"`
|
||||
ShowTour bool `json:"show_tour"`
|
||||
Note string `json:"note,omitempty"`
|
||||
InvitedBy int64 `json:"invited_by"`
|
||||
InvitedByName string `json:"invited_by_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
AcceptedAt *string `json:"accepted_at,omitempty"`
|
||||
AcceptedUser *int64 `json:"accepted_user_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type sendInvitationResponse struct {
|
||||
Invitation invitationResponse `json:"invitation"`
|
||||
EmailSent bool `json:"email_sent"`
|
||||
// ClaimURL embeds the single-use token, so this response is the only
|
||||
// chance to read it — the server keeps just the hash. Returned even when
|
||||
// the email sent, so the admin can also deliver the link directly.
|
||||
ClaimURL string `json:"claim_url,omitempty"`
|
||||
}
|
||||
|
||||
func toInvitationResponse(inv *models.Invitation, now time.Time) invitationResponse {
|
||||
resp := invitationResponse{
|
||||
ID: inv.ID,
|
||||
Email: inv.Email,
|
||||
Role: inv.Role,
|
||||
AccessGroupID: inv.AccessGroupID,
|
||||
LibraryIDs: inv.LibraryIDs,
|
||||
CreateProfile: inv.CreateProfile,
|
||||
ShowTour: inv.ShowTour,
|
||||
Note: inv.Note,
|
||||
InvitedBy: inv.InvitedBy,
|
||||
InvitedByName: inv.InvitedByName,
|
||||
Status: inv.Status(now),
|
||||
ExpiresAt: inv.ExpiresAt,
|
||||
AcceptedUser: inv.AcceptedUserID,
|
||||
CreatedAt: inv.CreatedAt,
|
||||
}
|
||||
if inv.AcceptedAt != nil {
|
||||
accepted := inv.AcceptedAt.Format(time.RFC3339)
|
||||
resp.AcceptedAt = &accepted
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleListInvitations handles GET /admin/invitations.
|
||||
func (h *AdminInvitationHandler) HandleListInvitations(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.service.List(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list invitations")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
resp := make([]invitationResponse, 0, len(list))
|
||||
for _, inv := range list {
|
||||
resp = append(resp, toInvitationResponse(inv, now))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleCreateInvitation handles POST /admin/invitations.
|
||||
func (h *AdminInvitationHandler) HandleCreateInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req createInvitationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
createProfile := true
|
||||
if req.CreateProfile != nil {
|
||||
createProfile = *req.CreateProfile
|
||||
}
|
||||
showTour := true
|
||||
if req.ShowTour != nil {
|
||||
showTour = *req.ShowTour
|
||||
}
|
||||
|
||||
result, err := h.service.Send(r.Context(), invitations.SendInput{
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
AccessGroupID: req.AccessGroupID,
|
||||
LibraryIDs: req.LibraryIDs,
|
||||
CreateProfile: createProfile,
|
||||
ShowTour: showTour,
|
||||
Note: req.Note,
|
||||
InvitedBy: int64(claims.UserID),
|
||||
})
|
||||
if err != nil {
|
||||
writeInvitationSendError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, buildSendResponse(result))
|
||||
}
|
||||
|
||||
// HandleResendInvitation handles POST /admin/invitations/{id}/resend.
|
||||
func (h *AdminInvitationHandler) HandleResendInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
claims := apimw.GetClaims(r.Context())
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.Resend(r.Context(), id, int64(claims.UserID))
|
||||
if err != nil {
|
||||
if errors.Is(err, invitations.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
|
||||
return
|
||||
}
|
||||
writeInvitationSendError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, buildSendResponse(result))
|
||||
}
|
||||
|
||||
// HandleRevokeInvitation handles DELETE /admin/invitations/{id}.
|
||||
func (h *AdminInvitationHandler) HandleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
|
||||
return
|
||||
}
|
||||
if err := h.service.Revoke(r.Context(), id); err != nil {
|
||||
if errors.Is(err, invitations.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke invitation")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func buildSendResponse(result *invitations.SendResult) sendInvitationResponse {
|
||||
resp := sendInvitationResponse{
|
||||
Invitation: toInvitationResponse(result.Invitation, time.Now()),
|
||||
EmailSent: result.EmailSent,
|
||||
ClaimURL: result.ClaimURL,
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func writeInvitationSendError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, invitations.ErrInvalidEmail):
|
||||
writeError(w, http.StatusBadRequest, "invalid_email", "Invalid email address")
|
||||
case errors.Is(err, invitations.ErrEmailTaken):
|
||||
writeError(w, http.StatusConflict, "email_taken", "An account with this email already exists")
|
||||
case errors.Is(err, invitations.ErrRoleNotAllowed):
|
||||
writeError(w, http.StatusForbidden, "role_not_allowed", "You may not grant this role")
|
||||
case errors.Is(err, invitations.ErrNoLinkBase):
|
||||
writeError(w, http.StatusConflict, "no_link_base",
|
||||
"Configure notifications.email.external_url (or a server public URL) so invitation links can be built")
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send invitation")
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,19 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
@@ -51,6 +54,11 @@ type mdblistSettingsCheckClient interface {
|
||||
Check(ctx context.Context) error
|
||||
}
|
||||
|
||||
type aiSettingsCheckClient interface {
|
||||
Chat(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
|
||||
Transcribe(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
|
||||
}
|
||||
|
||||
type redisSettingsCheckAdapter struct {
|
||||
client *redis.Client
|
||||
}
|
||||
@@ -88,6 +96,10 @@ var newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsChec
|
||||
return mdblist.NewClient(apiKey, nil)
|
||||
}
|
||||
|
||||
var newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
|
||||
return llm.NewClient(cfg)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SettingsRepo == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
|
||||
@@ -109,7 +121,7 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
|
||||
req.Values = map[string]string{}
|
||||
}
|
||||
|
||||
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), req)
|
||||
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), kind, req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
|
||||
return
|
||||
@@ -131,6 +143,10 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
|
||||
response = checkRedisConnection(r.Context(), cfg)
|
||||
case "recommendations_embedding":
|
||||
response = checkRecommendationsEmbeddingConnection(r.Context(), cfg)
|
||||
case "ai_chat":
|
||||
response = checkAIChatConnection(r.Context(), cfg)
|
||||
case "ai_transcription":
|
||||
response = checkAITranscriptionConnection(r.Context(), cfg)
|
||||
case "meilisearch":
|
||||
response = checkMeilisearchConnection(r.Context(), effectiveSettings)
|
||||
case "mdblist":
|
||||
@@ -155,6 +171,105 @@ func checkMDBListConnection(ctx context.Context, cfg *config.Config) connectionC
|
||||
return connectionCheckResponse{Success: true, Message: "MDBList API key verified."}
|
||||
}
|
||||
|
||||
func aiClientConfig(cfg *config.Config) llm.Config {
|
||||
return llm.Config{
|
||||
BaseURL: strings.TrimSpace(cfg.AI.BaseURL),
|
||||
APIKey: cfg.AI.APIKey,
|
||||
ChatModel: strings.TrimSpace(cfg.AI.ChatModel),
|
||||
ASRBaseURL: strings.TrimSpace(cfg.AI.ASRBaseURL),
|
||||
ASRAPIKey: cfg.AI.ASRAPIKey,
|
||||
ASRModel: strings.TrimSpace(cfg.AI.ASRModel),
|
||||
}
|
||||
}
|
||||
|
||||
func checkAIChatConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
|
||||
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
|
||||
return connectionCheckResponse{Success: false, Message: "Text AI base URL is required."}
|
||||
}
|
||||
if strings.TrimSpace(cfg.AI.ChatModel) == "" {
|
||||
return connectionCheckResponse{Success: false, Message: "Chat model is required."}
|
||||
}
|
||||
|
||||
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := client.Chat(checkCtx, []llm.Message{
|
||||
{Role: "system", Content: "Return a JSON object with status set to ok."},
|
||||
{Role: "user", Content: "Check this Silo text translation connection."},
|
||||
}, true); err != nil {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Text AI connection check failed: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
return connectionCheckResponse{Success: true, Message: "Text AI connection successful."}
|
||||
}
|
||||
|
||||
func checkAITranscriptionConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
|
||||
effectiveBaseURL := strings.TrimSpace(cfg.AI.ASRBaseURL)
|
||||
if effectiveBaseURL == "" {
|
||||
effectiveBaseURL = strings.TrimSpace(cfg.AI.BaseURL)
|
||||
}
|
||||
if effectiveBaseURL == "" {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: "Speech-to-text base URL is required.",
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.AI.ASRModel) == "" {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: "Transcription model is required.",
|
||||
}
|
||||
}
|
||||
|
||||
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
|
||||
defer cancel()
|
||||
if _, err := client.Transcribe(checkCtx, llm.TranscribeRequest{
|
||||
Filename: "silo-connection-check.wav",
|
||||
Audio: silenceWAV(),
|
||||
Timeout: 30 * time.Second,
|
||||
}); err != nil {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Speech-to-text connection check failed: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
return connectionCheckResponse{
|
||||
Success: true,
|
||||
Message: "Speech-to-text connection successful.",
|
||||
}
|
||||
}
|
||||
|
||||
// silenceWAV returns 250 ms of 16 kHz mono PCM. It is long enough for
|
||||
// transcription providers to parse while keeping connection checks cheap.
|
||||
func silenceWAV() []byte {
|
||||
const (
|
||||
sampleRate = 16_000
|
||||
bitsPerSample = 16
|
||||
sampleCount = sampleRate / 4
|
||||
dataSize = sampleCount * bitsPerSample / 8
|
||||
)
|
||||
wav := make([]byte, 44+dataSize)
|
||||
copy(wav[0:4], "RIFF")
|
||||
binary.LittleEndian.PutUint32(wav[4:8], uint32(36+dataSize))
|
||||
copy(wav[8:12], "WAVE")
|
||||
copy(wav[12:16], "fmt ")
|
||||
binary.LittleEndian.PutUint32(wav[16:20], 16)
|
||||
binary.LittleEndian.PutUint16(wav[20:22], 1)
|
||||
binary.LittleEndian.PutUint16(wav[22:24], 1)
|
||||
binary.LittleEndian.PutUint32(wav[24:28], sampleRate)
|
||||
binary.LittleEndian.PutUint32(wav[28:32], sampleRate*bitsPerSample/8)
|
||||
binary.LittleEndian.PutUint16(wav[32:34], bitsPerSample/8)
|
||||
binary.LittleEndian.PutUint16(wav[34:36], bitsPerSample)
|
||||
copy(wav[36:40], "data")
|
||||
binary.LittleEndian.PutUint32(wav[40:44], dataSize)
|
||||
return wav
|
||||
}
|
||||
|
||||
func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse {
|
||||
searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings)
|
||||
if err != nil {
|
||||
@@ -173,6 +288,7 @@ func checkMeilisearchConnection(ctx context.Context, settings map[string]string)
|
||||
|
||||
func (h *AdminHandler) effectiveSettingsForConnectionCheck(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
req adminSettingsConnectionCheckRequest,
|
||||
) (map[string]string, error) {
|
||||
settings, err := h.SettingsRepo.GetAll(ctx)
|
||||
@@ -190,13 +306,76 @@ func (h *AdminHandler) effectiveSettingsForConnectionCheck(
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
var storedAIConfig *config.Config
|
||||
if kind == "ai_chat" || kind == "ai_transcription" {
|
||||
storedAIConfig, err = config.LoadFromDB(merged)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, key := range req.DirtyKeys {
|
||||
merged[key] = req.Values[key]
|
||||
}
|
||||
if storedAIConfig != nil {
|
||||
draftAIConfig, loadErr := config.LoadFromDB(merged)
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
protectAIConnectionCheckSecrets(kind, req, storedAIConfig, draftAIConfig, merged)
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func protectAIConnectionCheckSecrets(
|
||||
kind string,
|
||||
req adminSettingsConnectionCheckRequest,
|
||||
storedCfg *config.Config,
|
||||
draftCfg *config.Config,
|
||||
settings map[string]string,
|
||||
) {
|
||||
storedEndpoint := storedCfg.AI.BaseURL
|
||||
draftEndpoint := draftCfg.AI.BaseURL
|
||||
if kind == "ai_transcription" {
|
||||
if strings.TrimSpace(storedCfg.AI.ASRBaseURL) != "" {
|
||||
storedEndpoint = storedCfg.AI.ASRBaseURL
|
||||
}
|
||||
if strings.TrimSpace(draftCfg.AI.ASRBaseURL) != "" {
|
||||
draftEndpoint = draftCfg.AI.ASRBaseURL
|
||||
}
|
||||
}
|
||||
if endpointAuthority(storedEndpoint) == endpointAuthority(draftEndpoint) {
|
||||
return
|
||||
}
|
||||
|
||||
if kind == "ai_transcription" && !hasExplicitDraftSecret(req, "ai.asr_api_key") {
|
||||
settings["ai.asr_api_key"] = ""
|
||||
}
|
||||
if !hasExplicitDraftSecret(req, "ai.api_key") {
|
||||
settings["ai.api_key"] = ""
|
||||
settings["subtitle_ai.api_key"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
func hasExplicitDraftSecret(req adminSettingsConnectionCheckRequest, key string) bool {
|
||||
for _, dirtyKey := range req.DirtyKeys {
|
||||
if dirtyKey == key {
|
||||
return strings.TrimSpace(req.Values[key]) != ""
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func endpointAuthority(rawURL string) string {
|
||||
trimmed := strings.TrimSpace(rawURL)
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.ToLower(trimmed)
|
||||
}
|
||||
return strings.ToLower(parsed.Scheme + "://" + parsed.Host)
|
||||
}
|
||||
|
||||
func checkS3PublicConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
|
||||
if strings.TrimSpace(cfg.S3.Public.Endpoint) == "" {
|
||||
return connectionCheckResponse{Success: false, Message: "S3 endpoint is required."}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user