diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d22600a2e3..1f2d1325c8 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,4 +3,7 @@ set -euo pipefail # Resolve via git toplevel so the hook works from any cwd (e.g., when # git invokes hooks from within a non-root subdirectory). -"$(git rev-parse --show-toplevel)/scripts/check-local-path-leaks.sh" --cached +repo_root="$(git rev-parse --show-toplevel)" + +"$repo_root/scripts/check-local-path-leaks.sh" --cached +"$repo_root/scripts/check-go-lint.sh" --cached diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..9643fcfc09 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +# These five trusted-tool checkouts use job.workflow_sha, which the runner +# provides but actionlint 1.7.12 does not yet include in its job context model. +paths: + .github/workflows/trusted-linux-ci.yml: + ignore: + - '^property "workflow_sha" is not defined in object type \{check_run_id: number; container: \{id: string; network: string\}; services: \{string => \{id: string; network: string; ports: \{string => string\}\}\}; status: string\}$' diff --git a/.github/actions/ci-tools/go-checks.mk b/.github/actions/ci-tools/go-checks.mk index fb73582174..2660c736e2 100644 --- a/.github/actions/ci-tools/go-checks.mk +++ b/.github/actions/ci-tools/go-checks.mk @@ -3,7 +3,7 @@ # tests, each job on its own runner. # LINT_BASE_REF, LINT_BASE_MODE, CONTRACT_BASE_REF, and TEST_GOFLAGS come from # the workflow. -.PHONY: lint contracts tests +.PHONY: lint contracts tests test-dependencies # Router recovery must hold over the whole tree, including inherited lines. lint: @@ -17,14 +17,28 @@ contracts: $(MAKE) -j1 -f Makefile verify-settings-bindings verify-playback-fixtures verify-route-inventory $(MAKE) -j1 -f Makefile verify-migration-ledger verify-apiv2-openapi CONTRACT_GO_TESTS=0 git fetch --no-tags origin "+refs/heads/$${CONTRACT_BASE_REF}:refs/remotes/origin/$${CONTRACT_BASE_REF}" + $(MAKE) -j1 -f Makefile verify-v1-route-freeze BASE_REF="origin/$${CONTRACT_BASE_REF}" $(MAKE) -j1 -f Makefile verify-apiv2-contract CONTRACT_GO_TESTS=0 BASE_REF="origin/$${CONTRACT_BASE_REF}" # Go's test cache cannot see files a test's child process reads, so these # packages always rerun; the grep fails when a new test starts go, git, or node # outside them or the packages whose subprocess inputs are immutable. -UNCACHED := cmd/prairie internal/jellycompat internal/pluginhost internal/plugins +UNCACHED := cmd/prairie internal/jellycompat internal/pluginhost internal/plugins scripts/ci/sizeratchet scripts/ci/migrationcheck +# subprocess-manifest: scripts/ci/subprocess-tests.json +# deferred: no recorded occurrence; retain UNCACHED and the subprocess guard. IMMUTABLE := internal/contractledger internal/contractspec internal/debugserver -tests: +test-dependencies: + @variants="$$(go list -deps -test -f '{{.ImportPath}}|{{.ForTest}}' ./internal/auth/ ./internal/api/handlers/)" || exit $$?; \ + [ -n "$$variants" ] || { echo "::error::test dependency metadata is empty"; exit 1; }; \ + printf '%s\n' "$$variants" | awk -F '|' '\ + $$2 != "" { split($$1, name, " "); \ + if ((name[1] == "github.com/prairie-server/prairie-server/internal/api/handlers" || \ + name[1] == "github.com/prairie-server/prairie-server/internal/apiv2") && name[1] != $$2) { \ + print "::error::lower-layer external test recompiles " $$0; bad=1 \ + } \ + } END { exit bad }' + +tests: test-dependencies GOFLAGS="$${TEST_GOFLAGS}" $(MAKE) -f Makefile test-go @want="$$(printf '%s\n' $(UNCACHED) $(IMMUTABLE) | sort -u)"; \ got="$$(git grep -l -E '(exec\.Command(Context)?\(([^,]+, )?"(go|git|node)"|LookPath\("(go|git|node)"\))' -- '*_test.go' | sed 's@/[^/]*$$@@' | sort -u)"; \ diff --git a/.github/actions/ci-tools/select-jobs.py b/.github/actions/ci-tools/select-jobs.py index 165e274acf..e299d7405b 100644 --- a/.github/actions/ci-tools/select-jobs.py +++ b/.github/actions/ci-tools/select-jobs.py @@ -14,6 +14,12 @@ # Run on every event regardless of what the diff touched. ALWAYS_JOBS = ("repo-checks",) SHA = re.compile(r"[0-9a-fA-F]{40}\Z") +# These are embedded by contracts/api/v2 and consumed by the Go ledger, +# scenario and compatibility gates. Web generation consumes openapi/fixtures. +GO_ONLY_CONTRACTS = frozenset("contracts/api/v2/" + name for name in ( + "migration.json", "migration.schema.json", "offline-routes.txt", + "breaking-approvals.json", "breaking-approvals.schema.json", +)) def all_jobs(reason): @@ -29,6 +35,16 @@ def classify(paths): if (not parts or path.startswith("/") or ".." in parts or any(ord(char) < 32 for char in path)): return all_jobs("unrecognized path") + if (path == ".github/evidence.yml" + or (path.startswith(".github/ISSUE_TEMPLATE/") and path.endswith((".yml", ".yaml", ".md"))) + or (path.endswith(".md") and path.startswith((".github/", "scripts/", "cmd/")))): + continue + if (path in GO_ONLY_CONTRACTS + or path in {"scripts/ci/db-pins.txt", "scripts/ci/db-contracts.txt"} + or (path.startswith("internal/apiv2/") and path.endswith(".go")) + or (path.startswith("cmd/") and not path.startswith("cmd/settingsgen/"))): + go = True + continue # These feed both generated Go artifacts and their Web bindings, or # change how validation runs. Keep the entire gate for them. # Schemas under docs include executable fixtures whose tests glob all @@ -90,7 +106,9 @@ def plan(event_name, event): # count a rename once, so a larger local count is still complete. if len(paths) < count: return all_jobs("incomplete changed-file count") - return classify(paths) + result = classify(paths) + result["paths"] = paths + return result except (KeyError, TypeError, ValueError, UnicodeError, OSError, subprocess.CalledProcessError): return all_jobs("PR comparison unavailable") @@ -120,10 +138,20 @@ def main(): if len(sys.argv) != 2 or sys.argv[1] not in {"plan", "check"}: raise SystemExit("expected plan or check") if sys.argv[1] == "check": - errors = check_results(json.loads(os.environ["CI_JOB_RESULTS"])) + needs = json.loads(os.environ["CI_JOB_RESULTS"]) + errors = check_results(needs) if errors: + cancelled_jobs = sorted(job for job, outcome in needs.items() + if outcome.get("result") == "cancelled") + if cancelled_jobs: + print("Cancelled CI jobs: " + ", ".join(cancelled_jobs)) for error in errors: print("::error::" + error) + # A distinct nonzero status preserves cancellation evidence without + # turning an unexpectedly cancelled required child into success. + if cancelled_jobs and all(error.endswith(" ended with cancelled") + for error in errors): + return 2 return 1 print("Every selected CI job passed.") return 0 diff --git a/.github/actions/ci-tools/test_select_jobs.py b/.github/actions/ci-tools/test_select_jobs.py index 51bab2a55f..097f2262eb 100644 --- a/.github/actions/ci-tools/test_select_jobs.py +++ b/.github/actions/ci-tools/test_select_jobs.py @@ -8,6 +8,7 @@ import json import os from pathlib import Path +import shutil import subprocess import sys import tempfile @@ -119,6 +120,18 @@ def test_understood_inputs_choose_only_required_groups(self): ("pkg/client/client.go", True, False), ("example.go", True, False), ("docs/example.go", True, False), + ("internal/apiv2/registry.go", True, False), + ("internal/apiv2/registry_test.go", True, False), + ("cmd/prairie/main.go", True, False), + ("scripts/ci/db-pins.txt", True, False), + ("scripts/ci/db-contracts.txt", True, False), + ("contracts/api/v2/migration.json", True, False), + ("contracts/api/v2/migration.schema.json", True, False), + ("contracts/api/v2/offline-routes.txt", True, False), + ("contracts/api/v2/breaking-approvals.json", True, False), + (".github/actions/README.md", False, False), + (".github/evidence.yml", False, False), + (".github/ISSUE_TEMPLATE/bug-report.yml", False, False), ] for number, (path, go, web) in enumerate(cases): with self.subTest(path=path): @@ -133,13 +146,11 @@ def test_understood_inputs_choose_only_required_groups(self): def test_shared_generation_and_validation_inputs_require_both_groups(self): cases = [ ".github/workflows/ci.yml", - ".github/actions/README.md", "scripts/check-local-path-leaks.sh", "contracts/api/v2/openapi.json", "contracts/api/v2/README.md", "contracts/settings/v1/conformance.json", "tools/generate.py", - "internal/apiv2/registry.go", "internal/apiv2/docsui/LICENSE", "internal/settingscontract/contract.go", "cmd/settingsgen/main.go", @@ -164,6 +175,11 @@ def test_shared_generation_and_validation_inputs_require_both_groups(self): self.repo.commit() self.assert_groups(self.repo.plan(), True, True) + def test_plan_reports_triggering_paths(self): + self.repo.write("internal/apiv2/registry.go") + self.repo.commit() + self.assertEqual(self.repo.plan().get("paths"), ["internal/apiv2/registry.go"]) + def test_markdown_inside_schema_inputs_is_not_skipped_as_documentation(self): for path in ( "docs/design/schemas/README.md", @@ -517,6 +533,153 @@ def test_check_cli_returns_failure_and_identifies_the_failed_job(self): if failed: self.assertIn("::error::go-integration ended with failure", run.stdout) + def test_check_cli_distinguishes_cancelled_children_from_failures(self): + needs = self.results() + needs["go-test"]["result"] = "cancelled" + run = subprocess.run( + [sys.executable, str(SCRIPT), "check"], capture_output=True, text=True, + env=dict(os.environ, CI_JOB_RESULTS=json.dumps(needs)), + ) + self.assertEqual(run.returncode, 2, run.stdout + run.stderr) + self.assertIn("Cancelled CI jobs: go-test", run.stdout) + self.assertNotIn("Every selected CI job passed", run.stdout) + needs["web"]["result"] = "failure" + run = subprocess.run( + [sys.executable, str(SCRIPT), "check"], capture_output=True, text=True, + env=dict(os.environ, CI_JOB_RESULTS=json.dumps(needs)), + ) + self.assertEqual(run.returncode, 1, run.stdout + run.stderr) + self.assertIn("web ended with failure", run.stdout) + + +class WorkflowGateTests(unittest.TestCase): + root = SCRIPT.parents[3] + + def pin_script(self): + workflow = (self.root / ".github/workflows/trusted-linux-ci.yml").read_text() + marker = " - name: Check action SHA pins\n" + self.assertIn(marker, workflow, "Repository checks must enforce immutable actions") + step = workflow.split(marker, 1)[1].split(" - ", 1)[0] + script = step.split(" run: |\n", 1)[1] + return "\n".join(line[10:] for line in script.splitlines() if line.strip()) + + def test_pin_gate_rejects_nested_mutable_and_malformed_references(self): + script = self.pin_script() + with tempfile.TemporaryDirectory() as directory: + repo = Repository(Path(directory) / "repo") + for reference in ("actions/cache@v4", "owner/repo/workflow.yml@main", + "actions/checkout@" + "a" * 39, + "actions/checkout@" + "z" * 40): + with self.subTest(reference=reference): + repo.write(".github/actions/nested/action.yml", "steps:\n - uses: " + reference + "\n") + repo.commit() + run = subprocess.run(["bash", "-c", script], cwd=repo.path, + capture_output=True, text=True) + self.assertNotEqual(run.returncode, 0, run.stdout + run.stderr) + self.assertIn(reference, run.stdout + run.stderr) + + def check_pin_fixture(self, step, expected): + workflow = ("name: pins\non: push\npermissions: {}\njobs:\n test:\n" + " runs-on: ubuntu-24.04\n steps:\n" + " - uses: actions/checkout@" + "a" * 40 + "\n" + " " + step + "\n") + if shutil.which("actionlint"): + valid = subprocess.run(["actionlint", "-oneline", "-"], input=workflow, + cwd=self.root, capture_output=True, text=True) + self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr) + with tempfile.TemporaryDirectory() as directory: + repo = Repository(Path(directory) / "repo") + repo.write(".github/workflows/test.yml", workflow) + repo.commit() + run = subprocess.run(["bash", "-c", self.pin_script()], cwd=repo.path, + capture_output=True, text=True) + self.assertEqual(run.returncode, expected, + f"{step}: gate returned {run.returncode}, want {expected}\n" + + run.stdout + run.stderr) + + def test_pin_gate_rejects_flow_mapping(self): + self.check_pin_fixture("- { uses: actions/checkout@v4 }", 1) + + def test_pin_gate_rejects_quoted_key(self): + self.check_pin_fixture('- "uses": actions/checkout@v4', 1) + + def test_pin_gate_accepts_flow_mapping_without_uses(self): + self.check_pin_fixture("- run: echo ok\n env: { A: b }", 0) + + def test_pin_gate_accepts_pinned_block_mapping(self): + self.check_pin_fixture("- uses: actions/checkout@" + "b" * 40, 0) + + def check_workflow_pin_fixture(self, reference, expected): + workflow = ("name: pins\non: push\npermissions: {}\njobs:\n test:\n" + " uses: " + reference + "\n") + if shutil.which("actionlint"): + valid = subprocess.run(["actionlint", "-oneline", "-"], input=workflow, + cwd=self.root, capture_output=True, text=True) + self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr) + with tempfile.TemporaryDirectory() as directory: + repo = Repository(Path(directory) / "repo") + repo.write(".github/workflows/test.yml", workflow) + repo.commit() + run = subprocess.run(["bash", "-c", self.pin_script()], cwd=repo.path, + capture_output=True, text=True) + self.assertEqual(run.returncode, expected, + f"{reference}: gate returned {run.returncode}, want {expected}\n" + + run.stdout + run.stderr) + + def test_pin_gate_accepts_same_repository_workflows_at_main(self): + for filename in ("trusted-linux-ci.yml", "another-workflow.yaml"): + with self.subTest(filename=filename): + self.check_workflow_pin_fixture( + "Prairie-Server/prairie-server/.github/workflows/" + filename + "@main", 0) + + def test_pin_gate_rejects_other_repository_workflows_at_main(self): + for repository in ("another-owner/prairie-server", "Prairie-Server/another-repo"): + with self.subTest(repository=repository): + self.check_workflow_pin_fixture( + repository + "/.github/workflows/trusted-linux-ci.yml@main", 1) + + def test_pin_gate_rejects_same_repository_workflows_at_other_refs(self): + for ref in ("feature", "v1.0.0"): + with self.subTest(ref=ref): + self.check_workflow_pin_fixture( + "Prairie-Server/prairie-server/.github/workflows/trusted-linux-ci.yml@" + ref, 1) + + def test_pin_gate_accepts_immutable_and_local_references(self): + script = self.pin_script() + with tempfile.TemporaryDirectory() as directory: + repo = Repository(Path(directory) / "repo") + repo.write(".github/workflows/test.yml", "steps:\n - uses: actions/checkout@" + "a" * 40 + " # reviewed release\n - uses: ./.github/actions/setup-go\n") + repo.write(".github/actions/nested/action.yml", "steps:\n - uses: ./.github/actions/local\n") + repo.commit() + run = subprocess.run(["bash", "-c", script], cwd=repo.path, + capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + run = subprocess.run(["bash", "-c", script], cwd=directory, + capture_output=True, text=True) + self.assertNotEqual(run.returncode, 0, "A missing Git input must fail") + + def test_full_tree_tool_targets_exist(self): + run = subprocess.run(["make", "-n", "verify-actionlint", "verify-shellcheck"], + cwd=self.root, capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + self.assertIn("1.7.12", run.stdout) + self.assertIn("0.11.0", run.stdout) + + def test_workflow_and_shell_tools_reject_seeded_defects(self): + missing = [tool for tool in ("actionlint", "shellcheck") if not shutil.which(tool)] + if missing: + self.skipTest("required tools not on PATH: " + ", ".join(missing)) + workflow = "name: test\non: push\njobs:\n test:\n runs-on: ubuntu-24.04\n steps:\n - run: echo ok\n" + for contents, expected in ((workflow, 0), (workflow.replace("echo ok", "echo ${{ unknown.value }}"), 1)): + run = subprocess.run(["actionlint", "-oneline", "-"], input=contents, + cwd=self.root, capture_output=True, text=True) + self.assertEqual(run.returncode, expected, run.stdout + run.stderr) + for contents, expected in (("#!/bin/bash\nprintf '%s\\n' \"$1\"\n", 0), + ("#!/bin/bash\nprintf '%s\\n' $1\n", 1)): + run = subprocess.run(["shellcheck", "-s", "bash", "-"], input=contents, + cwd=self.root, capture_output=True, text=True) + self.assertEqual(run.returncode, expected, run.stdout + run.stderr) + if __name__ == "__main__": unittest.main() diff --git a/.github/actions/save-go-cache/action.yml b/.github/actions/save-go-cache/action.yml index 5c839ec86b..2eb01bfa98 100644 --- a/.github/actions/save-go-cache/action.yml +++ b/.github/actions/save-go-cache/action.yml @@ -23,16 +23,18 @@ inputs: runs: using: composite steps: + # if: !cancelled() && inputs.job-status != 'cancelled' + # Declined for compiled-cache saving: failed main runs keep the success-only policy. - name: Prune unused Go build cache entries - if: success() && inputs.job-status == 'success' && runner.environment == 'github-hosted' && github.ref_name == github.event.repository.default_branch && github.event_name != 'pull_request' + if: success() && inputs.job-status == 'success' && runner.environment == 'github-hosted' && github.ref_name == github.event.repository.default_branch && github.event_name != 'pull_request' && github.event_name != 'schedule' shell: bash run: | cache_dir="$(go env GOCACHE)" find "$cache_dir" -mindepth 2 -maxdepth 2 \( -name '*-a' -o -name '*-d' \) ! -newermt '@0' -exec rm -rf -- {} + - name: Save the Go build cache - if: success() && inputs.job-status == 'success' && runner.environment == 'github-hosted' && github.ref_name == github.event.repository.default_branch && github.event_name != 'pull_request' - uses: actions/cache/save@v4 + if: success() && inputs.job-status == 'success' && runner.environment == 'github-hosted' && github.ref_name == github.event.repository.default_branch && github.event_name != 'pull_request' && github.event_name != 'schedule' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/go-build key: ${{ inputs.cache-key }} @@ -46,7 +48,7 @@ runs: - name: Save the shared Go module cache if: ${{ !cancelled() && inputs.job-status != 'cancelled' && runner.environment == 'github-hosted' && github.ref_name == github.event.repository.default_branch && github.event_name != 'pull_request' && inputs.module-cache-key != '' && inputs.module-cache-hit != 'true' }} - uses: actions/cache/save@v4 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/go/pkg/mod key: ${{ inputs.module-cache-key }} diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index 9f91acc0ef..2ab73e72d4 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -1,6 +1,6 @@ name: Set up Go for this repository description: >- - Installs the latest available Go 1.26 toolchain, a shared module cache, a + Installs the latest Go 1.26 patch, a shared module cache, a per-commit build cache, and the stub frontend bundle that cmd/prairie embeds, then waits for the libvips install the job started before checkout. @@ -28,23 +28,13 @@ outputs: runs: using: composite steps: - - name: Restore the shared Go module cache - if: runner.environment == 'github-hosted' - id: modules - uses: actions/cache/restore@v4 - with: - path: ~/go/pkg/mod - key: go-mod-${{ runner.os }}-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - go-mod-${{ runner.os }}- - # setup-go's own cache is keyed on go.mod and written only on a # miss, so between dependency bumps every run restored a build cache # from weeks ago and recompiled the tree. The cache below is keyed on the # commit and restored by prefix, so each run starts from the newest # cache any earlier run saved. - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: '<1.28' # The runner image pins an older patch release; govulncheck needs the @@ -57,6 +47,16 @@ runs: shell: bash run: echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT" + - name: Restore the shared Go module cache + if: runner.environment == 'github-hosted' + id: modules + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/go/pkg/mod + key: go-mod-${{ runner.os }}-${{ steps.go.outputs.version }}-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }} + restore-keys: | + go-mod-${{ runner.os }}-${{ steps.go.outputs.version }}- + # The restore below unpacks the build cache while libvips installs in the # background. On tmpfs, those writes and the job's compile output stay off # the disk the install writes to. The path, and so the cache key, is unchanged. @@ -70,7 +70,7 @@ runs: - name: Restore the Go build cache if: runner.environment == 'github-hosted' id: cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/go-build key: go-${{ inputs.cache-name }}-${{ runner.os }}-${{ steps.go.outputs.version }}-${{ hashFiles('go.sum') }}-${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7864fe10e..fb9ed96536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,9 +154,14 @@ jobs: CI_GO_LINT_FULL: ${{ github.event_name == 'pull_request' && '0' || '1' }} CI_GO_LINT_NEW_FROM_MERGE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || '' }} CI_GO_LINT_NEW_FROM_REV: ${{ github.event_name != 'pull_request' && 'HEAD~1' || '' }} + # Upstream sync PRs gate only lines new to both main and the merged + # Silo commit; upstream's own backlog was gated (per changed line) + # upstream. Every other PR keeps the merge-base gate. + CI_GO_LINT_SYNC: ${{ github.event_name == 'pull_request' && startsWith(github.head_ref, 'sync/upstream') && '1' || '0' }} + CI_GO_LINT_SYNC_HEAD: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail - chmod +x scripts/ci-go-lint.sh scripts/ci-go-lint-packages.sh + chmod +x scripts/ci-go-lint.sh scripts/ci-go-lint-packages.sh scripts/ci-go-lint-sync-patch.sh if [[ "${{ github.event_name }}" == "pull_request" ]]; then ./scripts/ci-go-lint.sh "origin/${PR_BASE_REF}" else diff --git a/.golangci-router-recovery.yml b/.golangci-router-recovery.yml new file mode 100644 index 0000000000..65a507b023 --- /dev/null +++ b/.golangci-router-recovery.yml @@ -0,0 +1,32 @@ +version: "2" + +run: + timeout: 15m + allow-serial-runners: true + tests: false + modules-download-mode: readonly + +linters: + default: none + enable: + - gocritic + settings: + gocritic: + disable-all: true + enabled-checks: + - ruleguard + settings: + ruleguard: + rules: '${base-path}/internal/routeinventory/lintrules/*.go' + failOn: all + exclusions: + rules: + - path: _test\.go + linters: + - gocritic + text: "^ruleguard: route inventory:" + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + uniq-by-line: false diff --git a/.golangci.yml b/.golangci.yml index 052950bc40..ea24767ac0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -8,6 +8,7 @@ run: # core, so queueing concurrent runs (several agents, several worktrees) # beats running them side by side. Never pass --allow-parallel-runners. allow-serial-runners: true + modules-download-mode: readonly formatters: enable: @@ -15,15 +16,13 @@ formatters: - goimports settings: - # Many files still group Prairie imports with third-party ones. CI lints - # changed lines only, so such a file is flagged when a change edits an - # import line that goimports would regroup; `golangci-lint fmt ` - # regroups the whole file. + # Format files as touched. goimports: local-prefixes: - github.com/prairie-server/prairie-server linters: + default: none enable: - bodyclose - errcheck @@ -38,6 +37,73 @@ linters: - unconvert - unused - wastedassign + - noctx + - nilnesserr + - durationcheck + - makezero + - asasalint + - gocheckcompilerdirectives + - nosprintfhostport + - usetesting + - rowserrcheck + - sqlclosecheck + - errchkjson + - exhaustive + - revive + - fatcontext + - forcetypeassert + - containedctx + - gosec + - bidichk + - nolintlint + - perfsprint + - predeclared + - iface + - interfacebloat + - errname + - recvcheck + - forbidigo + - gocritic + - funlen + - gocyclo + - nestif + - asciicheck + - exptostd + - gochecksumtype + - gomoddirectives + - iotamixing + - zerologlint + - reassign + - goprintffuncname + - mirror + + # Unenrolled checks retain their selection reason. Failed scans are unmeasured. + # - prealloc # 222 findings; disabled pending review. + # - gocognit # Declined: funlen, gocyclo, and nestif own function limits. + # - mnd # Declined: broad style cleanup. + # - dupl # Declined: broad style cleanup. + # - wrapcheck # Declined: broad style cleanup. + # - contextcheck # not measured; needs a prototype for detached Background cancellation. + # - canonicalheader # 41 findings; disabled pending review. + # - nilnil # 879 findings; TEST nil/value contracts require review. + # - thelper # 195 findings; disabled pending review. + # - unparam # 227 findings; TEST public parameters require consumer review. + # - usestdlibvars # 244 findings; disabled pending review. + # - dupword # 43 findings; disabled pending review. + # - nakedret # 12 findings; disabled pending review. + # - intrange # 278 findings; disabled pending review. + # - modernize # 1405 findings; disabled pending review. + # - varnamelen # 7302 findings; disabled pending review. + # - tagliatelle # 1046 findings; disabled pending review. + # - copyloopvar # 83 findings; disabled pending review. + # - musttag # 108 findings; disabled pending review. + # - dogsled # 40 findings; disabled pending review. + # - embeddedstructfieldcheck # 765 findings; TEST mutex/embedding contracts require review. + # - whitespace # 57 findings; disabled pending review. + # - spancheck # Declined for six tracing sites. + # - err113 # Declined: error-style cleanup. + # - lll # Declined: line wrapping. + # - fieldalignment # Declined: struct reordering. exclusions: # Anchored regexes, not directory names: `paths` matches anywhere in the @@ -49,6 +115,16 @@ linters: - ^web/ - ^migrations/ rules: + - path: _test\.go$ + linters: + - noctx + - gosec + - forcetypeassert + - errchkjson + # Sleeps in production are not test synchronization. + - path-except: _test\.go$ + linters: + - forbidigo # Tests may freely repeat fixture strings / leave cleanup errors unchecked. - path: _test\.go linters: @@ -62,34 +138,220 @@ linters: text: "^ruleguard: route inventory:" settings: - # gocritic is enabled for exactly one check: the ruleguard rules under - # internal/routeinventory/lintrules/, defense in depth for the legacy - # native route inventory (docs/architecture/api-contract.md, "Legacy - # native route inventory"). The guarantee itself is structural — every - # listener entry function returns a sealed handler the generator checks — - # and these rules report the attempt early: a type assertion, type switch - # or generic instantiation whose type registers routes by its method set - # (alias, defined type, embedding or structural interface included), and - # reflect.Value.MethodByName, outside tests. ruleguard was chosen over - # forbidigo because forbidigo visits only identifier and selector nodes - # and cannot tell an assertion from a parameter type. Keep the check list - # to ruleguard: widening it enrolls the whole tree in gocritic's style set. - # - # gocritic is deliberately absent from linters.enable above and runs only - # through make lint-router-recovery (--enable-only gocritic), full-tree, - # which CI gates on. A changed-lines pass would find nothing that step - # does not, and it is the costliest linter here: gocritic rebuilds the - # ruleguard engine for every package, and each rebuild type-checks chi - # and net/http from source — about two seconds of fixed cost per package, - # nearly half the analysis time of a cold run over ./... . + funlen: + lines: 100 + statements: -1 + gocyclo: + min-complexity: 30 + nestif: + min-complexity: 4 + gocritic: disable-all: true enabled-checks: - - ruleguard - settings: - ruleguard: - rules: '${base-path}/internal/routeinventory/lintrules/*.go' - failOn: all + - argOrder + # - assignOp # 21 findings; disabled. + - badCall + - badCond + - caseOrder + - codegenComment + - defaultCaseOrder + # - deprecatedComment # 1 findings; disabled. + - dupArg + - dupBranchBody + - dupCase + - dupSubExpr + - elseif + - exitAfterDefer + - flagDeref + - flagName + # - mapKey # 14 findings, all whitespace-trimming test inputs; disabled. + - newDeref + - offBy1 + - regexpMust + - sloppyLen + - sloppyTypeAssert + - switchTrue + - typeSwitchVar + - underef + # - unlambda # 4 findings; disabled. + - unslice + - valSwap + - wrapperFunc + - badLock + - badRegexp + - badSyncOnceFunc + - deferInLoop + - externalErrorReassign + - sortSlice + - truncateCmp + - uncheckedInlineErr + # - weakCond # 10 findings, all regexp index results, which are full length when non-nil; disabled. + - badSorting + - dynamicFmtString + - evalOrder + - returnAfterHttpError + - sqlQuery + # - emptyFallthrough # 1 findings; disabled. + # - rangeValCopy # 1200 findings; disabled. + # - hugeParam # Declined: broad performance cleanup. + - rangeExprCopy + # - appendCombine # 17 findings; disabled. + - indexAlloc + # - preferStringWriter # 1 findings; disabled. + - preferFprint + - preferWriteByte + - sliceClear + # - stringXbytes # 28 findings; disabled. + # - boolExprSimplify # 1 findings; disabled. + # - builtinShadow # 123 findings; disabled. + - dupImport + # - emptyStringTest # 15 findings; disabled. + # - equalFold # 12 findings; disabled. + # - filepathJoin # 15 findings; disabled. + # - httpNoBody # 1114 findings; disabled. + # - importShadow # 97 findings; disabled. + # - nestingReduce # 19 findings; disabled. + - nilValReturn + - redundantSprint + - regexpPattern + # - sloppyReassign # 120 findings; disabled. + # - sprintfQuotedString # 16 findings; disabled. + # - stringConcatSimplify # 5 findings; disabled. + - timeExprSimplify + - unnecessaryBlock + - unnecessaryDefer + + # gocritic.settings.hugeParam: declined broad performance cleanup. + # gocritic: + # settings: + # hugeParam: + # sizeThreshold: 256 + # nilnil: 879 findings; TEST remains inactive. + # nilnil: + # detect-opposite: true + # nakedret: 12 findings; inactive. + # nakedret: + # max-func-lines: 10 + + gomoddirectives: + # Allow local replace directives. + replace-local: true + + # tagliatelle: 1046 findings; inactive. + # tagliatelle: + # case: + # rules: + # json: snake + # yaml: kebab + # varnamelen: 7302 findings; inactive. + # varnamelen: + # min-name-length: 3 + # max-distance: 10 + # check-receiver: false + # check-return: false + # check-type-param: false + # ignore-type-assert-ok: true + # ignore-map-index-ok: true + # ignore-chan-recv-ok: true + # ignore-names: [err, fd, db, tx, mu, rl, wg] + # ignore-decls: + # - t testing.T + # - b testing.B + # - i int + # - T any + # - wg sync.WaitGroup + # - w http.ResponseWriter + # - r *http.Request + # copyloopvar: 83 findings; inactive. + # copyloopvar: + # check-alias: true + # embeddedstructfieldcheck: 765 findings; TEST remains inactive. + # embeddedstructfieldcheck: + # forbid-mutex: true + + govet: + enable: + - nilness + - unusedwrite + - reflectvaluecompare + - sortslice + - deepequalerrors + # enable-all: true # Unmeasured; retain the repository analyzer set. + # disable: [fieldalignment] # Declined: struct reordering work. + + rowserrcheck: + packages: + - github.com/jackc/pgx/v5 + + errchkjson: + check-error-free-encoding: true + + exhaustive: + explicit-exhaustive-switch: true + explicit-exhaustive-map: true + default-signifies-exhaustive: false + # default-signifies-exhaustive: true # A default does not prove enum coverage. + # explicit-exhaustive-switch: false # Default-on exhaustiveness declined. + + revive: + enable-default-rules: false + rules: + - name: unconditional-recursion + - name: datarace + - name: waitgroup-by-value + - name: range-val-address + - name: defer + - name: time-equal + - name: atomic + - name: identical-branches + - name: identical-ifelseif-branches + # - name: identical-switch-branches # 56 findings; disabled. + - name: identical-switch-conditions + # - name: var-naming # Declined naming work. + # - name: exported # Declined naming/documentation work. + # - name: package-comments # Declined naming/documentation work. + # - name: receiver-naming # Declined naming work. + # - name: confusing-naming # Declined naming work. + # - name: time-naming # Declined naming work. + # enable-all-rules: true # Select only measured zero-finding unnamed rules. + + fatcontext: + check-struct-pointers: true + + gosec: + excludes: + - G104 # errcheck owns unchecked errors. + # G201/G202 value-versus-identifier ruleguard: not measured; needs a prototype. + # severity: medium # Retain default security coverage. + # confidence: medium # Retain default security coverage. + + nolintlint: + require-explanation: true + require-specific: true + + forbidigo: + forbid: + - pattern: '^time\.Sleep$' + msg: Wait on observable state; timing tests require a reasoned nolint. + analyze-types: true + exclude-godoc-examples: false + + perfsprint: + int-conversion: true + err-error: true + errorf: true + sprintf1: true + + iface: + enable: [identical, unused, opaque] + + interfacebloat: + max: 20 + # max: 10 # The agreed interface limit is 20. + + recvcheck: + disable-builtin: false sloglint: context: "scope" @@ -211,3 +473,75 @@ linters: # Upstream prose and the "dialogue" marker vocabulary (a wire value). - catalogued - dialogue + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + uniq-by-line: false + +# Unenrolled settings: unmeasured unless a decline or replacement is stated above. +# staticcheck: +# # All checks except these style checks and quickfix suggestions. +# checks: +# # package comment, initialism naming, receiver names, doc-comment form +# - all +# - -ST1000 +# - -ST1003 +# - -ST1016 +# - -ST1020 +# - -ST1021 +# - -ST1022 +# # Disable quickfix checks +# - -QF* +# gocognit: +# # Maximum cognitive complexity +# min-complexity: 30 +# mnd: +# # Magic Number Detector settings +# checks: +# - argument +# - case +# - condition +# - operation +# - return +# - assign +# # Common constants that are acceptable +# ignored-numbers: +# - '0' +# - '1' +# - '2' +# - '10' +# - '100' +# - '1024' # buffer sizes, byte units +# - '0o755' # file modes +# - '0o644' +# # Functions where magic numbers are acceptable +# ignored-functions: +# - 'os.MkdirAll' +# - 'os.OpenFile' +# - 'os.WriteFile' +# # Ignore magic numbers in test files +# ignored-files: +# - '.*_test\.go' +# dupl: +# # Minimum token sequence for duplication detection +# threshold: 100 +# wrapcheck: +# # No exempt packages, signatures, or interfaces. +# ignore-package-globs: [] +# ignore-sigs: [] +# ignore-interface-regexps: [] +# staticcheck.checks: repository defaults remain active; the alternate all/-ST*/-QF* selection is unmeasured. +# sloglint.attr-only: true # Declined: retain the repository mixed-call policy. +# sloglint.context: all # Declined: retain scope-aware context requirements. +# sloglint.key-naming-case: kebab # Declined: retain snake_case log keys. +# formatters: +# enable: [gci, gofumpt] # Declined companion formatting; retain gofmt/goimports. +# settings: +# gci: +# custom-order: true +# gofumpt: +# extra: +# group-params: true +# clothe-returns: true +# balance-calls: true diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000000..cd51d47534 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +# Infer the dialect from each tracked file's shebang; inspect every severity. +# SC2029 is suppressed only at on_host's intentional caller-expansion site. diff --git a/AGENTS.md b/AGENTS.md index 80105cd7f3..e70eb62aaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,7 +167,7 @@ changed-line findings as CI while analyzing only the packages the branch touched `./...` saturates every core for minutes, and parallel agents make that worse. Never pass `--allow-parallel-runners`; concurrent runs queue behind one another on purpose. -Go stays `gofmt`/`goimports` clean; the frontend follows `web/.prettierrc`. +Format Go files as touched with `gofmt`/`goimports`; the frontend follows `web/.prettierrc`. ## Development environment diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 17b7c35259..f0d39c4ed5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -82,6 +82,11 @@ that can hold millions of rows. Keep locks on existing tables short: before creating a new index: a copy left by an interrupted run is invalid, and `CREATE INDEX CONCURRENTLY IF NOT EXISTS` would keep it, so the planner would never use the index. +- When a migration replaces one index with another, a retry must not drop the + replacement: goose records the version only after the last statement, so a + run that already dropped the old index can run again. Use a Go migration + that builds through `ensureIndexConcurrently` in `internal/database`, which + drops only an invalid copy (`internal/database/push_attempt_key_migration.go`). - Add foreign keys and `CHECK` constraints to existing tables as `NOT VALID`, and run `VALIDATE CONSTRAINT` only after that commits: in a later migration, or as a separate statement in a `NO TRANSACTION` file. Inside one diff --git a/Dockerfile b/Dockerfile index f6a9659715..7ceb643b22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ FROM scratch AS frontend_dist COPY --from=frontend /app/web/dist/. / # Stage 2: Build Go binary +# The floating 1.26 tag is deliberate so images get Go patch releases without a manual bump. FROM golang:1.26 AS build ENV CGO_ENABLED=1 ENV GOPROXY=https://proxy.golang.org,direct diff --git a/Makefile b/Makefile index 8d859fe48e..1face48419 100644 --- a/Makefile +++ b/Makefile @@ -59,12 +59,35 @@ lint: golangci-lint run cd web && pnpm run lint -# Lint the Go packages this branch touched, on the lines it changed. Same -# findings as CI's changed-lines step at a fraction of the cost of ./... . +# Lint changed lines in the Go packages this branch touched. # BASE_REF=origin/ when not main. lint-changed: BASE_REF=$(BASE_REF) scripts/lint-changed.sh +.PHONY: verify-actionlint verify-shellcheck +verify-actionlint: + @test "$$(actionlint -version | head -n 1)" = 1.7.12 || { echo 'actionlint 1.7.12 is required' >&2; exit 1; } + actionlint + +verify-shellcheck: + @test "$$(shellcheck --version | sed -n 's/^version: //p')" = 0.11.0 || { echo 'ShellCheck 0.11.0 is required' >&2; exit 1; } + @python3 -c 'import pathlib, re, subprocess; paths = subprocess.check_output(["git", "ls-files", "-z"]).decode().split("\0"); files = [p for p in paths if p and pathlib.Path(p).is_file() and (pathlib.Path(p).suffix in (".sh", ".bash", ".ksh") or re.match(rb"^#!.*\b(?:sh|bash|dash|ksh)\b", pathlib.Path(p).open("rb").readline()))]; assert files, "no tracked shell inputs"; subprocess.run(["shellcheck", "--", *files], check=True)' + +# hadolint: not measured; adoption needs a pinned binary, a seeded-defect trial, and reviewed findings and cost. +# Makefile shell lint: not measured; recipe expansion needs a prototype that preserves Make's shell context. + +.PHONY: verify-size-ratchet verify-bidi +verify-size-ratchet: + @base=$$(git merge-base "$(BASE_REF)" HEAD) && \ + go run ./scripts/ci/sizeratchet -base "$$base" + +# grep status 1 means no controls; unsupported PCRE and other errors refuse. +verify-bidi: + @status=0; git grep -I -nP '[\x{061c}\x{200e}\x{200f}\x{202a}-\x{202e}\x{2066}-\x{2069}]' -- . || status=$$?; \ + if [ "$$status" -eq 1 ]; then exit 0; fi; \ + if [ "$$status" -eq 0 ]; then echo 'Bidi controls are not permitted in tracked text' >&2; exit 1; fi; \ + exit "$$status" + # 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 @@ -81,8 +104,10 @@ embed-stub: # Run the Go and frontend test suites. test: test-go test-web +# The queryattr concurrency tests prove nothing without the race detector. test-go: embed-stub go test ./... + go test -race -count=1 ./internal/database/queryattr/ # Run the DB-backed query-budget pins listed in $(DB_PINS), then the database # contracts, against the migrated, disposable database named by @@ -178,7 +203,7 @@ verify-settings-bindings-all: verify-settings-bindings verify-settings-bindings- # 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 native-decision_response.json capability_response.json error_response.json route_event.json +PLAYBACK_WIRE_FIXTURES := start_request.json replan_request.json decision_response.json native-decision_response.json hls-subtitle-rendition-decision_response.json capability_response.json error_response.json route_event.json playback-fixtures: go run ./cmd/playbackfixtures -out $(PLAYBACK_FIXTURE_DIR) @@ -214,13 +239,15 @@ verify-route-inventory: @go run ./cmd/route-inventory -check $(ROUTE_INVENTORY) \ || { echo "::error::$(ROUTE_INVENTORY) is stale or a route is unaccounted for; run make route-inventory"; exit 1; } -# Run the route inventory's router-recovery lint rule over the whole tree, not -# only changed lines. It is the one gocritic check the repo enables (see -# .golangci.yml), and the tree passes it today, so this can gate CI while the -# rest of `make lint` cannot. +.PHONY: verify-v1-route-freeze +verify-v1-route-freeze: + @base=$$(git merge-base "$(BASE_REF)" HEAD) && \ + python3 -c 'import json, subprocess, sys; path="contracts/api/v2/route-inventory.json"; project=lambda doc: sorted((r["method"], r["path"]) for r in doc["routes"] if r["namespace"] == "api_v1"); before=json.loads(subprocess.check_output(["git", "show", sys.argv[1]+":"+path])); after=json.load(open(path)); sys.exit("v1 routes changed; the bridge contract is frozen" if project(before) != project(after) else 0)' "$$base" + +# Run only the route inventory's router-recovery rule over the whole tree. # Tests are excluded from this rule, so do not analyze their package variants. lint-router-recovery: - golangci-lint run --tests=false --enable-only gocritic --max-same-issues=0 --max-issues-per-linter=0 ./... + golangci-lint run --config .golangci-router-recovery.yml --tests=false --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./... MIGRATION_LEDGER := contracts/api/v2/migration.json # CI gives test-go ownership of these Go assertions and passes 0 to avoid @@ -424,6 +451,16 @@ migrate-create: migrate-validate: $(GOOSE) -dir $(GOOSE_DIR) validate +.PHONY: verify-migrations verify-prometheus-rules +verify-migrations: migrate-validate + @base=$$(git merge-base "$(BASE_REF)" HEAD) && \ + go run ./scripts/ci/migrationcheck -base "$$base" + +# Use the same Prometheus release locally and in CI. +PROMTOOL ?= docker run --rm --pull=never --entrypoint promtool -v "$(CURDIR)/deploy/observability:/rules:ro" -w /rules prom/prometheus:v3.5.0 +verify-prometheus-rules: + $(PROMTOOL) test rules prairie.rules.test.yml + # Show Goose migration status through Prairie's bootstrapping runner. migrate-status: go run ./cmd/prairie/ --env "$(ENV_FILE)" --migrate-status diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 31738bf64e..0f8a8efdce 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,3 +17,22 @@ license, and source/provenance metadata beside the installed assets. poster artwork. The artwork is intended to use generic original scenes and avoid copyrighted movie/show posters, recognizable actors, franchise characters, provider logos, and readable in-image third-party branding. + +## FFmpeg audio decoders (WebAssembly) + +`web/src/player/engine/wasm/prairie-audio-decoders.wasm` and its JavaScript glue +are compiled from FFmpeg (https://ffmpeg.org/), licensed under the GNU Lesser +General Public License, version 2.1 or later. The build enables only the +AC-3, E-AC-3, DTS, TrueHD and MLP decoders from `libavcodec` and `libavutil`, +with no GPL components. The FFmpeg release tag and configure options are in +`web/wasm/audio-decoders/build.sh`, the exact upstream commit is recorded in +`web/src/player/engine/wasm/prairie-audio-decoders.ffmpeg-revision`, and +`web/wasm/audio-decoders/bridge.c` is the only code linked against it. The +corresponding FFmpeg source is available from the FFmpeg project at that tag, +and the build script reproduces the binary from it. + +## Mediabunny + +The web player's browser decode engine depends on Mediabunny +(https://github.com/Vanilagy/mediabunny), licensed under the Mozilla Public +License 2.0, through the `mediabunny` npm package. diff --git a/cmd/playbackfixtures/hls_subtitle_rendition.go b/cmd/playbackfixtures/hls_subtitle_rendition.go new file mode 100644 index 0000000000..4f88eb8098 --- /dev/null +++ b/cmd/playbackfixtures/hls_subtitle_rendition.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/prairie-server/prairie-server/internal/models" + "github.com/prairie-server/prairie-server/internal/playback" +) + +// goldenHLSSubtitleRenditionDecisionResponse is a /api/v2 transcode whose +// embedded SubRip track is carried as the HLS stream's subtitle rendition. +func goldenHLSSubtitleRenditionDecisionResponse() playback.DecisionResponseV3 { + file := conformanceFallbackFile() + file.SubtitleTracks = []models.SubtitleTrack{ + {Index: 3, Language: languageEnglish, Codec: "subrip", Title: titleEnglish}, + } + request := goldenStartRequest() + request.ClientFeatures = append(request.ClientFeatures, playback.FeatureHLSSubtitleRenditionsV3) + request.QualityPreference = playback.QualityRung720pMediumV3 + request.SubtitleTrackIndex = new(0) + request.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", 0) + request.ClientPlaybackContext.Deliveries[playback.DeliveryClassHLSV3] = playback.DeliveryCapabilityV3{ + Enabled: true, + SupportedOnDevice: true, + Containers: []string{"hls"}, + VideoCodecs: []string{codecH264}, + AudioDecodeCodecs: []string{codecAAC}, + AudioPassthroughCodecs: []string{}, + Subtitles: playback.DeliverySubtitleCapabilitiesV3{SidecarText: true}, + Features: []string{}, + AuthHeaderRefresh: true, + ValidatedClaims: []string{}, + Transformations: []playback.TransformationV3{}, + } + response := goldenDecisionForSourceWith(file, request, nil, func(input *playback.PlannerInputV3) { + input.SubtitleRenditionExecutable = func() bool { return true } + }) + if plan := response.PlaybackPlan; plan.Delivery != playback.DeliveryTranscodeHLSV3 || plan.Subtitle.HLSRendition == nil { + failf("golden subtitle rendition plan is %s without a rendition", plan.Delivery) + } + response.PlaybackPlan.Stream.URL = "/playback/transcode/" + goldenSessionID + "/master.m3u8" + response.ServerFeatures = playback.NativeServerFeaturesV3() + return response +} diff --git a/cmd/playbackfixtures/main.go b/cmd/playbackfixtures/main.go index 8d83182ce7..247822a76b 100644 --- a/cmd/playbackfixtures/main.go +++ b/cmd/playbackfixtures/main.go @@ -57,6 +57,7 @@ const ( audioLayout71 = "7.1" filmFrameRate = "24000/1001" languageEnglish = "eng" + titleEnglish = "English" videoRangeSDR = "SDR" categoryMidSeek = "mid_seek_replan" ) @@ -65,16 +66,17 @@ func main() { out := flag.String("out", "", "directory to write fixtures into (required)") flag.Parse() if *out == "" { - fail("usage: playbackfixtures -out ") + failf("usage: playbackfixtures -out ") } if err := os.MkdirAll(*out, 0o755); err != nil { - fail("create %s: %v", *out, err) + failf("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, "native-decision_response.json", goldenNativeDecisionResponse()) + write(*out, "hls-subtitle-rendition-decision_response.json", goldenHLSSubtitleRenditionDecisionResponse()) write(*out, "capability_response.json", goldenCapabilityResponse()) write(*out, "error_response.json", goldenErrorResponse()) write(*out, "route_event.json", goldenRouteEvent()) @@ -90,16 +92,16 @@ func goldenErrorResponse() playback.ErrorResponseV3 { func write(dir, name string, value any) { body, err := json.MarshalIndent(value, "", " ") if err != nil { - fail("marshal %s: %v", name, err) + failf("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) + failf("write %s: %v", path, err) } } -func fail(format string, args ...any) { +func failf(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(1) } @@ -193,7 +195,7 @@ func goldenReplanRequest() playback.ReplanRequestV3 { audioIndex := 0 decision := goldenDecisionResponse() if decision.PlaybackPlan == nil { - fail("golden decision has no plan") + failf("golden decision has no plan") } plan := decision.PlaybackPlan return playback.ReplanRequestV3{ @@ -231,7 +233,7 @@ func goldenMediaFile() *models.MediaFile { return &models.MediaFile{ ID: goldenMediaFileID, ExternalSubtitles: []models.ExternalSubtitle{ - {Path: "/library/movie.en.srt", Language: languageEnglish, Format: "srt", Title: "English"}, + {Path: "/library/movie.en.srt", Language: languageEnglish, Format: "srt", Title: titleEnglish}, }, SubtitleTracks: []models.SubtitleTrack{ {Index: 0, Language: languageEnglish, Codec: "ass", Title: "English (Signs)", Forced: true}, @@ -309,11 +311,15 @@ func goldenNativeDecisionResponse() playback.DecisionResponseV3 { } func goldenDecisionForSource(file *models.MediaFile, request playback.StartRequestV3, additional []playback.SubtitleInventoryEntryV3) playback.DecisionResponseV3 { + return goldenDecisionForSourceWith(file, request, additional, nil) +} + +func goldenDecisionForSourceWith(file *models.MediaFile, request playback.StartRequestV3, additional []playback.SubtitleInventoryEntryV3, configure func(*playback.PlannerInputV3)) playback.DecisionResponseV3 { now, err := time.Parse(time.RFC3339, "2029-12-31T23:55:00Z") if err != nil { - fail("parse golden planner time: %v", err) + failf("parse golden planner time: %v", err) } - result := playback.PlanPlaybackV3(playback.PlannerInputV3{ + input := playback.PlannerInputV3{ Request: request, RequestedFile: file, EffectiveFile: file, @@ -322,9 +328,13 @@ func goldenDecisionForSource(file *models.MediaFile, request playback.StartReque Registry: conformanceRegistry(), Now: now, AdditionalSubtitles: additional, - }) + } + if configure != nil { + configure(&input) + } + result := playback.PlanPlaybackV3(input) if result.Plan == nil { - fail("golden planner returned terminal: %#v", result.Terminal) + failf("golden planner returned terminal: %#v", result.Terminal) } plan := *result.Plan // These values belong to session/transport setup, which runs after the @@ -370,7 +380,7 @@ func goldenCapabilityResponse() playback.CapabilityResponseV3 { func goldenRouteEvent() playback.RouteEventV3 { decision := goldenDecisionResponse() if decision.PlaybackPlan == nil { - fail("golden decision has no plan") + failf("golden decision has no plan") } plan := decision.PlaybackPlan return playback.RouteEventV3{ @@ -728,7 +738,7 @@ func goldenConformanceMatrix() playback.ConformanceMatrixV3 { 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"}} + dvdFile.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Language: languageEnglish, Codec: "dvd_subtitle", Title: titleEnglish}} dvdRequest := conformanceStartRequest() dvdRequest.PlaybackAttemptID = "attempt-dvd-burn-in" dvdIndex := 0 @@ -744,7 +754,7 @@ func goldenConformanceMatrix() playback.ConformanceMatrixV3 { decision := goldenDecisionResponse() plan := decision.PlaybackPlan if plan == nil { - fail("golden decision has no plan") + failf("golden decision has no plan") } trackIndex := 1 trackChange := goldenReplanRequest() @@ -797,7 +807,7 @@ func goldenConformanceMatrix() playback.ConformanceMatrixV3 { 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") + failf("output context must change the opaque plan attempt key") } recovery := goldenReplanRequest() recovery.ReplanRequestID = "replan-failure-matrix-0001" diff --git a/cmd/prairie/main.go b/cmd/prairie/main.go index 0c16c8aa7b..26fb558ee0 100644 --- a/cmd/prairie/main.go +++ b/cmd/prairie/main.go @@ -30,6 +30,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/go-hclog" "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" "github.com/redis/go-redis/v9" pluginv1 "github.com/prairie-server/prairie-plugin-sdk/pkg/pluginproto/prairie/plugin/v1" @@ -51,6 +52,7 @@ import ( "github.com/prairie-server/prairie-server/internal/blobgc" "github.com/prairie-server/prairie-server/internal/blobstore" "github.com/prairie-server/prairie-server/internal/branding" + "github.com/prairie-server/prairie-server/internal/buildinfo" "github.com/prairie-server/prairie-server/internal/cache" "github.com/prairie-server/prairie-server/internal/catalog" "github.com/prairie-server/prairie-server/internal/catalogseed" @@ -60,6 +62,7 @@ import ( "github.com/prairie-server/prairie-server/internal/dashmetrics" "github.com/prairie-server/prairie-server/internal/database" "github.com/prairie-server/prairie-server/internal/database/pglock" + "github.com/prairie-server/prairie-server/internal/database/queryattr" "github.com/prairie-server/prairie-server/internal/diagnostics" "github.com/prairie-server/prairie-server/internal/downloads" "github.com/prairie-server/prairie-server/internal/ebooks" @@ -71,6 +74,7 @@ import ( "github.com/prairie-server/prairie-server/internal/imageutil" "github.com/prairie-server/prairie-server/internal/intromarkers" "github.com/prairie-server/prairie-server/internal/jellycompat" + "github.com/prairie-server/prairie-server/internal/lantls" "github.com/prairie-server/prairie-server/internal/libraryingest" "github.com/prairie-server/prairie-server/internal/librarymonitor" "github.com/prairie-server/prairie-server/internal/literaryworks" @@ -775,7 +779,14 @@ func main() { // package-level global. dataCipher, err := secret.New(bc.SecretKey) if err != nil { - log.Fatalf("secret cipher: %v", err) + log.Fatalf("secret cipher: %v", err) //nolint:gocritic // exiting closes the debug listener with the process + } + // The API listener also answers TLS on its own port, with a key derived + // from SECRET_KEY, so signed-in clients can use the server's local-network + // address over a channel they authenticate by pin (internal/lantls). + lanTLS, err := lantls.New(bc.SecretKey) + if err != nil { + log.Fatalf("local network TLS: %v", err) } apiKeyHashKey, err := secret.DeriveAPIKeyHashKey(bc.SecretKey) @@ -794,26 +805,11 @@ func main() { if *migrateStatus { migCtx, migCancel := database.MigrationContext(ctx) - statuses, statusErr := database.MigrationStatuses(migCtx, pool, migrations.FS, "sql") + statusErr := database.PrintMigrationStatus(migCtx, pool, migrations.FS, "sql", os.Stdout) migCancel() if statusErr != nil { log.Fatalf("failed to read migration status: %v", statusErr) } - - fmt.Printf("%-8s %8s %-25s %s\n", "STATE", "VERSION", "APPLIED_AT", "MIGRATION") - for _, status := range statuses { - appliedAt := "-" - if !status.AppliedAt.IsZero() { - appliedAt = status.AppliedAt.UTC().Format(time.RFC3339) - } - source := status.Source - if source != "" { - source = filepath.Base(source) - } else { - source = "-" - } - fmt.Printf("%-8s %8d %-25s %s\n", status.State, status.Version, appliedAt, source) - } return } @@ -834,10 +830,7 @@ func main() { if *migrateOnly { migCtx, migCancel := database.MigrationContext(ctx) - migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql") - if migErr == nil { - migErr = ensureAuditDetailIndexes(migCtx, pool) - } + migErr := database.MigrateOnly(migCtx, pool, migrations.FS, "sql") migCancel() if migErr != nil { log.Fatalf("failed to run migrations: %v", migErr) @@ -855,10 +848,7 @@ func main() { isPrimaryNode := bc.Mode == postgresTuneModeIntegrated || bc.Mode == "api" || bc.Mode == "" if isPrimaryNode { migCtx, migCancel := database.MigrationContext(ctx) - migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql") - if migErr == nil { - migErr = ensureAuditDetailIndexes(migCtx, pool) - } + migErr := database.MigrateOnly(migCtx, pool, migrations.FS, "sql") if migErr != nil { migCancel() log.Fatalf("failed to run migrations: %v", migErr) @@ -1026,7 +1016,16 @@ func main() { mode := cfg.Server.Mode maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode) - slog.Info("silo starting", "mode", mode, "listen", cfg.Server.Listen, "log_level", cfg.Server.LogLevel, "node_id", nodeID) + queryAttribution := "off" + if queryattr.Initialize() { + queryAttribution = "on" + } + queryRun := uuid.NewString() + if isPrimaryNode && queryattr.Enabled() { + queryattr.RegisterMetrics(prometheus.DefaultRegisterer) + } + slog.Info("prairie starting", "mode", mode, "listen", cfg.Server.Listen, "log_level", cfg.Server.LogLevel, "node_id", nodeID, + "query_attribution", queryAttribution, "query_run", queryRun) appCtx, appCancel := context.WithCancel(ctx) defer appCancel() @@ -1098,6 +1097,10 @@ func main() { // rows to other nodes' live tails, and the event bus closes last. defer logStreamHub.Close() defer stopOperationalLog() + if queryattr.Enabled() { + stopQuerySummary := startLogDrain(func(ctx context.Context) { queryattr.RunSummary(ctx, queryRun, buildinfo.Current().Display) }) + defer stopQuerySummary() + } // Proxy and transcode modes run with DB + Redis for hot-reload. if mode == "proxy" || mode == "transcode" { @@ -1307,6 +1310,8 @@ func main() { // OnServerSettingUpdated closure, which only runs on admin requests after // startup completes. var ipResolver *clientip.Resolver + // Assigned below when recommendations start; nil (a no-op) otherwise. + var settingsRecWorker *recommendations.Worker var shutdownWork []<-chan struct{} registerShutdownWork := func(done <-chan struct{}) { shutdownWork = append(shutdownWork, done) @@ -1363,6 +1368,11 @@ func main() { slog.WarnContext(context.Background(), "clientip config reload failed", "component", "app", "error", loadErr) } } + // Cached recommendation rows of profiles with a rating ceiling + // are built under the unrated-content decision. + if key == config.AccessUnratedContentSettingKey { + settingsRecWorker.NotifyUnratedContentChanged(context.Background(), config.UnratedContentCacheTTL+time.Second) + } // Nudge the hot-reload watcher so same-process settings changes // apply immediately even without Redis (the event bus is a no-op // then, leaving only the 60s poll). @@ -1642,6 +1652,7 @@ func main() { // revoke, status pushes) and all three listeners (token validation). networkAccess := netaccess.NewBroker() deps.NetworkAccess = networkAccess + deps.LocalTLSPin = lanTLS.Pin() if deps.DB != nil { pluginCacheDir := resolvePluginCacheDir() repositoryStore := plugins.NewRepositoryStore(deps.DB) @@ -2019,15 +2030,15 @@ func main() { } if deps.Blobs.Assets != nil { imageResolver.SetArtworkResolver(deps.ArtworkResolver) - // Local storage publishes with an atomic rename, so the catalog - // can trust the manifest as written. Only external delivery (a - // public or token-authenticated read endpoint in front of S3) - // can lag behind a write and needs the verified-keys lookup. - if deps.ArtworkDelivery.External { - imageResolver.SetArtworkAvailabilityReader(metadata.NewArtworkDeliveryStore( - deps.DB, deps.ArtworkDelivery.Scope, true, - )) - } + // Every backend reads the publication manifest, because a key the + // catalog holds can name a rung its revision predates: after a + // ladder change, existing artwork lacks the new widest rung until + // the ladder backfill regenerates it. Only external delivery (a + // public or token-authenticated read endpoint in front of S3) can + // also lag behind a write, so only it runs the verifier. + imageResolver.SetArtworkAvailabilityReader(metadata.NewArtworkDeliveryStore( + deps.DB, deps.ArtworkDelivery.Scope, deps.ArtworkDelivery.External, + )) } deps.ImageResolver = imageResolver deps.PluginImageResolver = imageResolver @@ -2086,6 +2097,11 @@ func main() { personRepo, deps.FileRepo, skippedRootRepo, staleIDRepo, rootClaimRepo, ) + // Catalog merges and splits mark stale the recommendations of the + // profiles whose user state they moved. + if cfg.Recommendations.Enabled && deps.DB != nil { + metadataService.SetRecommendationStaler(recommendations.NewRepo(deps.DB)) + } // Drop the resolved-chain cache whenever a plugin is installed, enabled, // disabled, updated, or uninstalled. The installation-enabled check is // served from the plugins service's in-memory cache (invalidated on the @@ -2186,6 +2202,9 @@ func main() { avifBackfillProcessor.SetRetiredVariantSweeper(metadata.NewRetiredVariantCleaner(deps.DB, pngCleaner)) } metadataService.SetLocalArtworkCopier(metadataImageCacheProcessor) + // The admin image picker previews and applies the same local files, + // under the same confinement. + metadataService.SetLibraryRootResolver(deps.FolderRepo) metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages) metadataImageCacheProcessor.SetEnabled(cfg.Metadata.CacheImages) avifBackfillProcessor.SetEnabled(cfg.Metadata.CacheImages) @@ -2584,7 +2603,7 @@ func main() { WithMatcher(historyimport.NewMatcher(historyRepo)). WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)). WithUserStoreProvider(userStoreProvider). - WithRatingStore(catalog.NewRatingsRepo(deps.DB), recommendations.NewRepo(deps.DB)). + WithRatingStore(catalog.NewRatingsRepo(deps.DB)). WithDroppedStore(notifications.TrackDroppedSeries(catalog.NewDroppedSeriesRepo(deps.DB), notificationSystem)) backgroundInit = append(backgroundInit, func(ctx context.Context) { if compatTerminalRecoveryReady != nil { @@ -2767,7 +2786,19 @@ func main() { catalog.NewPersonRepository(deps.DB), userStoreProvider, cfg.Recommendations, - ).WithUnratedContentPolicy(unratedContent) + ).WithUnratedContentPolicy(unratedContent). + WithUserStoreOutsidePostgres(cfg.UserDB.Backend == "sqlite") + if userStoreProvider != nil { + // Cached rows are built under the scope the API resolves for the + // profile's own requests, so reads do not filter them down. + recUserRepo := auth.NewUserRepository(deps.DB) + if policySystem != nil { + recEngine.WithScopeResolver(policy.NewViewerResolver(recUserRepo, userStoreProvider, nil, policySystem.PDP(), accessGroupStore).WithUnratedContentPolicy(unratedContent)) + } else { + // Legacy resolver: proxy/test wiring without a policy system. Production integrated/api modes always take the policy path. Removed with the legacy cleanup phase. + recEngine.WithScopeResolver(access.NewResolver(recUserRepo, userStoreProvider, nil, accessGroupStore).WithUnratedContentPolicy(unratedContent)) + } + } deps.Recommender = recEngine deps.CatalogSearchVectorizer = recEngine @@ -2783,7 +2814,31 @@ func main() { if err != nil { slog.Error("failed to create recommendation worker", "error", err) } else { + recWorker.WithJobHistory(taskrepository.NewPgExecutionRepository(deps.DB)). + WithPolicyCheck(policySystem.Current). + WithSavedConfig(func(ctx context.Context) (config.RecommendationsConfig, error) { + saved, err := settingsRepo.GetAll(ctx) + if err != nil { + return config.RecommendationsConfig{}, err + } + savedCfg, err := config.LoadFromDB(saved) + if err != nil { + return config.RecommendationsConfig{}, err + } + return savedCfg.Recommendations, nil + }) deps.RecWorker = recWorker + settingsRecWorker = recWorker + // Watch-provider syncs rebuild a profile's recommendations once + // per run that imported something. + if watchProviderService != nil { + watchProviderService.WithSignalsChangedNotifier(recWorker) + } + // A scope policy can change every profile's scope, and cached + // rows are built under it. The first node to load a new policy + // generation marks every profile stale; refreshes wait until + // their node has loaded it (WithPolicyCheck above). + policySystem.OnChangeApplied(recWorker.NotifyPolicyChanged) } } @@ -2931,6 +2986,7 @@ func main() { var collectionSyncScheduler *catalog.CollectionSyncScheduler var userCollectionScheduler *usercollections.Scheduler var trendingRefresher *sections.TrendingRefresher + var seasonalListRefresher *sections.SeasonalListRefresher if needsWorkers && deps.DB != nil { collectionRepo := catalog.NewLibraryCollectionRepository(deps.DB) collItemRepo := catalog.NewItemRepository(deps.DB) @@ -2942,16 +2998,17 @@ func main() { // The trending refresher reuses the section repo (to find used source/ // window combos beyond the calendar's fixed feed), a snapshot repo, an - // item repo (external-ID matching), and the TMDB fetcher. The Trakt fetcher needs settingsRepo and is - // propagated onto deps.TrendingRefresher later in router.go. + // item repo (external-ID matching), and the TMDB fetcher. + sectionDemand := sections.NewSectionDemandLister(sectionRepo, auth.NewUserRepository(deps.DB), userStoreProvider) trendingRefresher = sections.NewTrendingRefresher( - sections.NewTrendingDemandLister(sectionRepo, auth.NewUserRepository(deps.DB), userStoreProvider), + sectionDemand, sections.NewTrendingSnapshotRepository(pool), catalog.NewItemRepository(deps.DB), collectionService.TMDBCollections, - collectionService.TraktCollections, ) deps.TrendingRefresher = trendingRefresher + seasonalListRefresher = sections.NewSeasonalListRefresher(sectionDemand, sections.NewSeasonalListRepository(pool)) + deps.SeasonalListRefresher = seasonalListRefresher if deps.UserStoreProvider != nil { // Imports fill their item limit with titles their owner profile @@ -2971,8 +3028,6 @@ func main() { // here; the router gives it its generator. deps.PersonalCollectionCollages = catalog.NewPersonalCollectionCollages(deps.DB, nil) userSync.Collages = deps.PersonalCollectionCollages - // Trakt fetchers are wired in router.go (they need settingsRepo); - // router.go propagates them onto userSync once configured. userCollectionScheduler = usercollections.NewScheduler(deps.DB, userSync, slog.Default()) deps.UserCollectionSync = userSync deps.UserCollectionScheduler = userCollectionScheduler @@ -3144,6 +3199,9 @@ func main() { } } catalogSearchIndexer := catalog.NewCatalogSearchIndexerFromSettings(deps.DB, settingsRepo, catalogSearchStartupSettings) + if recEngine != nil { + catalogSearchIndexer.WithSemanticModelProvider(recEngine) + } taskMgr.Register(tasks.NewSyncCatalogSearchIndexTask(catalogSearchIndexer)) taskMgr.Register(tasks.NewRebuildCatalogSearchIndexTask(catalogSearchIndexer)) maintenanceSteps = append(maintenanceSteps, tasks.NewCatalogSearchEventRetentionTask(catalog.NewSearchIndexEventRepository(deps.DB))) @@ -3304,6 +3362,19 @@ func main() { ) taskMgr.Register(cacheImagesTask) taskMgr.Register(tasks.NewBackfillMetadataImagesTask(metadataImageCacheProcessor)) + if releaser := metadata.NewStoredArtworkReleaser(deps.DB); releaser != nil { + taskMgr.Register(tasks.NewReleaseStoredArtworkTask(releaser)) + // Turning caching off releases stored artwork now, not at the + // next daily pass. + configWatcher.OnChange(func(previous, updated *config.Config) { + if previous == nil || !previous.Metadata.CacheImages || updated.Metadata.CacheImages { + return + } + if _, err := taskMgr.StartTask(tasks.ReleaseStoredArtworkTaskKey); err != nil { + slog.Debug("metadata: stored artwork release not started", "error", err) + } + }) + } } if avifBackfillProcessor != nil { avifBackfillProcessor.SetPlaybackActivityCheck(artworkShouldYield) @@ -3354,6 +3425,9 @@ func main() { if trendingRefresher != nil { taskMgr.Register(tasks.NewRefreshTrendingDiscoverTask(trendingRefresher)) } + if seasonalListRefresher != nil { + taskMgr.Register(tasks.NewRefreshSeasonalListsTask(seasonalListRefresher)) + } taskMgr.Register(tasks.NewRefreshAnimeIDsTask(animeids.NewRefresher(deps.DB))) if userCollectionScheduler != nil { taskMgr.Register(tasks.NewSyncUserCollectionsTask(userCollectionScheduler)) @@ -3428,7 +3502,11 @@ func main() { reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) historyResolver := watchstate.NewStableIdentityResolver(nil, reconcileEpisodeRepo, reconcileProviderIDRepo) historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver) - taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler)) + providerIDRepairer := metadata.NewProviderIDIntegrityRepairer(deps.DB) + if recWorker != nil { + providerIDRepairer.WithRecommendationStaler(recommendations.NewRepo(deps.DB)) + } + taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(providerIDRepairer, historyReconciler)) taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler)) taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB))) if liveTVSvc != nil { @@ -3677,7 +3755,8 @@ func main() { } adminJobRunner = adminjob.NewRunner( adminjob.NewRepository(deps.DB), - catalogseed.NewService(deps.DB, catalog.NewPersonRepository(deps.DB), recommendations.NewRepo(deps.DB)), + catalogseed.NewService(deps.DB, catalog.NewPersonRepository(deps.DB), recommendations.NewRepo(deps.DB)). + WithEmbeddingModel(cfg.Recommendations.EmbeddingModel), artifactStore, itemRefreshExecutor, libraryRefreshExecutor, @@ -3698,11 +3777,13 @@ func main() { recWorker.Start() defer recWorker.Stop() - // Check if this is first run (no embeddings yet). - embCount, _ := recommendations.NewRepo(deps.DB).EmbeddingCount(appCtx) - if embCount == 0 { - slog.Info("first run detected, triggering initial embedding") - recWorker.RunEmbeddingsNow() + // Resume a first or interrupted backfill now rather than at the + // next catch-up tick. + if needed, err := recEngine.NeedsEmbedding(appCtx); err != nil { + slog.WarnContext(appCtx, "checking for items that need embeddings failed", "component", "recommendations", "error", err) + } else if needed { + slog.InfoContext(appCtx, "items need embeddings; starting the embedding catch-up", "component", "recommendations") + recWorker.EmbedMissingNow() } } @@ -3737,6 +3818,7 @@ func main() { JWTSecret: cfg.Auth.JWTSecret, RecWorker: recWorker, FrontendFS: deps.FrontendFS, + ArtworkStore: deps.Blobs.Assets, // Hand remote-transcode recipes to the shared recipe store so a dedicated // transcode node that restarts can rebuild a jellycompat session. RecipeNodeStore: noderecipe.NewStore(apiRedisClient, 0), @@ -3827,6 +3909,23 @@ func main() { compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher) compatDeps.LiveTV = liveTVSvc + // /Movies/Recommendations reads the cached rows the native API + // reads. With recommendations disabled it has no reader and + // answers an empty list, so rows cached before are not served. + if cfg.Recommendations.Enabled { + var compatRecRefresh recommendations.ReadRefreshRequester + if recWorker != nil { + compatRecRefresh = recWorker + } + compatDeps.RecommendationReader = recommendations.NewReader(recommendations.NewRepo(deps.DB), catalog.NewRatingsRepo(deps.DB), compatRecRefresh, userStoreProvider). + WithUserStoreOutsidePostgres(cfg.UserDB.Backend == "sqlite") + } + // /Items/{id}/Similar ranks by embeddings when recommendations + // are enabled, and by genre otherwise. + if recEngine != nil { + compatDeps.Recommender = recEngine + } + // Construct auth service for jellycompat login. userRepo := auth.NewUserRepository(deps.DB) compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB, apiKeyHashKey) @@ -3942,7 +4041,7 @@ func main() { } else { go func() { slog.Info("HTTP server listening", "addr", cfg.Server.Listen) - if serveErr := srv.Serve(apiListener); serveErr != nil && serveErr != http.ErrServerClosed { + if serveErr := srv.Serve(lantls.NewListener(apiListener, lanTLS.Config(), 0)); serveErr != nil && serveErr != http.ErrServerClosed { errCh <- fmt.Errorf("HTTP server error: %w", serveErr) } }() @@ -4588,6 +4687,14 @@ func replaceWatchSyncPluginProviders( if installation == nil || installation.IsBuiltin() { continue } + if watchsync.RetiredPlugin(installation.PluginID) { + slog.WarnContext(ctx, "skip retired watch sync plugin; Prairie no longer supports its provider", + "component", "app", + "installation_id", installation.ID, + "plugin_id", installation.PluginID, + ) + continue + } prairieManaged, err := service.InstalledFromPrairieRepository(ctx, installation) if err != nil { // Registering a first-party plugin under a per-installation key @@ -5107,8 +5214,3 @@ func artworkBackendName(deps api.Dependencies) string { func chapterThumbnailStoreReady(deps api.Dependencies) bool { return deps.Blobs.Assets != nil } - -func ensureAuditDetailIndexes(ctx context.Context, pool *pgxpool.Pool) error { - return partman.NewManager(pool, "activity_log", partman.Weekly, 2).EnsureIndexes(ctx, - "activity_log_action_time_idx", "activity_log_target_time_idx") -} diff --git a/cmd/prairie/main_test.go b/cmd/prairie/main_test.go index 0d665eb140..45fdb79949 100644 --- a/cmd/prairie/main_test.go +++ b/cmd/prairie/main_test.go @@ -632,10 +632,10 @@ func (f *fakeWatchSyncMigrationStore) HasConnections(context.Context, string) (b return true, nil } -func traktPluginCapabilities(t *testing.T) []*plugins.Capability { +func watchSyncPluginCapabilities(t *testing.T, id, displayName string) []*plugins.Capability { t.Helper() records, err := plugins.CapabilityRecordsFromManifest(&pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{ - Type: "watch_sync_provider.v1", Id: "trakt", DisplayName: "Trakt", + Type: "watch_sync_provider.v1", Id: id, DisplayName: displayName, WatchSyncProvider: &pluginv1.WatchSyncProviderDescriptor{ AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE}, ExportWatched: true, @@ -657,13 +657,13 @@ func traktPluginCapabilities(t *testing.T) []*plugins.Capability { func TestReloadWatchSyncPluginProvidersKeepsOneFirstPartyInstallation(t *testing.T) { store := firstPartyWatchSyncStore{ installations: []*plugins.Installation{ - {ID: 9, PluginID: "prairie.watchprovider.trakt", Enabled: true, Kind: plugins.KindPlugin}, - {ID: 7, PluginID: "prairie.watchprovider.trakt", Enabled: true, Kind: plugins.KindPlugin}, + {ID: 9, PluginID: "prairie.watchprovider.simkl", Enabled: true, Kind: plugins.KindPlugin}, + {ID: 7, PluginID: "prairie.watchprovider.simkl", Enabled: true, Kind: plugins.KindPlugin}, }, - capabilities: traktPluginCapabilities(t), + capabilities: watchSyncPluginCapabilities(t, "simkl", "Simkl"), } registry := watchsync.NewRegistry() - migration := &fakeWatchSyncMigrationStore{settings: map[string]string{"watchsync.trakt.client_id": "id"}} + migration := &fakeWatchSyncMigrationStore{settings: map[string]string{"watchsync.simkl.client_id": "id"}} service := &fakeWatchSyncPluginService{} if err := reloadWatchSyncPluginProviders(context.Background(), registry, store, service, nil, migration); err != nil { t.Fatal(err) @@ -672,28 +672,49 @@ func TestReloadWatchSyncPluginProvidersKeepsOneFirstPartyInstallation(t *testing t.Fatalf("cache invalidations = %d, want one before reloading providers", service.cacheInvalidations) } summaries := registry.List() - if len(summaries) != 1 || summaries[0].Key != "trakt" { - t.Fatalf("registered providers = %#v, want only trakt", summaries) + if len(summaries) != 1 || summaries[0].Key != "simkl" { + t.Fatalf("registered providers = %#v, want only simkl", summaries) } if !slices.Equal(service.seeded, []int{7}) { t.Fatalf("seeded installations = %v, want the older one", service.seeded) } - if migration.settings["watchsync.plugin_migration.trakt"] == "" { + if migration.settings["watchsync.plugin_migration.simkl"] == "" { t.Fatal("migration was not recorded") } } +// A retired plugin left installed registers no provider, under its old key or +// any other, and records no migration. +func TestReloadWatchSyncPluginProvidersSkipsRetiredPlugin(t *testing.T) { + store := firstPartyWatchSyncStore{ + installations: []*plugins.Installation{{ID: 7, PluginID: "prairie.watchprovider.trakt", Enabled: true, Kind: plugins.KindPlugin}}, + capabilities: watchSyncPluginCapabilities(t, "trakt", "Trakt"), + } + registry := watchsync.NewRegistry() + migration := &fakeWatchSyncMigrationStore{settings: map[string]string{}} + service := &fakeWatchSyncPluginService{} + if err := reloadWatchSyncPluginProviders(context.Background(), registry, store, service, nil, migration); err != nil { + t.Fatal(err) + } + if summaries := registry.List(); len(summaries) != 0 { + t.Fatalf("registered providers = %#v, want none", summaries) + } + if len(service.seeded) != 0 || len(migration.settings) != 0 { + t.Fatalf("seeded = %v, settings = %v; want nothing", service.seeded, migration.settings) + } +} + // Seeding saves plugin config, and the save fires the lifecycle hook that // reloads again. The nested reload must not wait on the outer one. func TestReloadWatchSyncPluginProvidersSeedsOutsideTheReloadLock(t *testing.T) { store := firstPartyWatchSyncStore{ - installations: []*plugins.Installation{{ID: 7, PluginID: "prairie.watchprovider.trakt", Enabled: true, Kind: plugins.KindPlugin}}, - capabilities: traktPluginCapabilities(t), + installations: []*plugins.Installation{{ID: 7, PluginID: "prairie.watchprovider.simkl", Enabled: true, Kind: plugins.KindPlugin}}, + capabilities: watchSyncPluginCapabilities(t, "simkl", "Simkl"), } registry := watchsync.NewRegistry() migration := &fakeWatchSyncMigrationStore{settings: map[string]string{ - "watchsync.trakt.client_id": "id", - "watchsync.trakt.client_secret": "secret", + "watchsync.simkl.client_id": "id", + "watchsync.simkl.client_secret": "secret", }} service := &fakeWatchSyncPluginService{} nested := make(chan error, 1) diff --git a/cmd/settingsgen/main.go b/cmd/settingsgen/main.go index ae6013d50a..0a45821c73 100644 --- a/cmd/settingsgen/main.go +++ b/cmd/settingsgen/main.go @@ -33,7 +33,7 @@ func main() { contract, err := settingscontract.Load() if err != nil { - fail("loading contract: %v", err) + failf("loading contract: %v", err) } var body []byte @@ -48,10 +48,10 @@ func main() { case "swift": body, err = generateSwift(contract) default: - fail("unknown -lang %q: want go, ts, kotlin or swift", *lang) + failf("unknown -lang %q: want go, ts, kotlin or swift", *lang) } if err != nil { - fail("generating %s: %v", *lang, err) + failf("generating %s: %v", *lang, err) } if *out == "" { @@ -59,11 +59,11 @@ func main() { return } if err := os.WriteFile(*out, body, 0o644); err != nil { //nolint:gosec // generated source - fail("writing %s: %v", *out, err) + failf("writing %s: %v", *out, err) } } -func fail(format string, args ...any) { +func failf(format string, args ...any) { fmt.Fprintf(os.Stderr, "settingsgen: "+format+"\n", args...) os.Exit(1) } @@ -245,7 +245,8 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) { 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(" /** A deprecated member stays a valid stored value but is no longer offered. */\n") + out.WriteString(" values?: readonly { value: unknown; label: string; introducedIn: number; deprecated?: boolean }[];\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") @@ -303,8 +304,12 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) { if err != nil { return nil, err } - fmt.Fprintf(&out, " { value: %s, label: %s, introducedIn: %d },\n", - encoded, jsString(member.Label), memberRevision(def, member)) + deprecated := "" + if member.Deprecated { + deprecated = ", deprecated: true" + } + fmt.Fprintf(&out, " { value: %s, label: %s, introducedIn: %d%s },\n", + encoded, jsString(member.Label), memberRevision(def, member), deprecated) } out.WriteString(" ],\n") } diff --git a/contracts/api/v2/embed.go b/contracts/api/v2/embed.go index e04f207aea..14c5466e02 100644 --- a/contracts/api/v2/embed.go +++ b/contracts/api/v2/embed.go @@ -8,10 +8,7 @@ // and validation live in the packages above. package apiv2 -import ( - "embed" - _ "embed" -) +import "embed" // FS holds route-inventory.json, migration.json, migration.schema.json, // offline-routes.txt, the breaking-change approvals with their schema, and diff --git a/contracts/api/v2/fixtures/admin_access_group_create.json b/contracts/api/v2/fixtures/admin_access_group_create.json index 91c871cba3..f10a2964c0 100644 --- a/contracts/api/v2/fixtures/admin_access_group_create.json +++ b/contracts/api/v2/fixtures/admin_access_group_create.json @@ -12,6 +12,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "allowed_permissions": null, "requests_allowed": false, "is_default": false, diff --git a/contracts/api/v2/fixtures/admin_access_group_get.json b/contracts/api/v2/fixtures/admin_access_group_get.json index 91c871cba3..f10a2964c0 100644 --- a/contracts/api/v2/fixtures/admin_access_group_get.json +++ b/contracts/api/v2/fixtures/admin_access_group_get.json @@ -12,6 +12,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "allowed_permissions": null, "requests_allowed": false, "is_default": false, diff --git a/contracts/api/v2/fixtures/admin_access_group_list.json b/contracts/api/v2/fixtures/admin_access_group_list.json index f792883503..992c397bba 100644 --- a/contracts/api/v2/fixtures/admin_access_group_list.json +++ b/contracts/api/v2/fixtures/admin_access_group_list.json @@ -14,6 +14,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "allowed_permissions": null, "requests_allowed": false, "is_default": false, diff --git a/contracts/api/v2/fixtures/admin_access_group_update.json b/contracts/api/v2/fixtures/admin_access_group_update.json index 91c871cba3..f10a2964c0 100644 --- a/contracts/api/v2/fixtures/admin_access_group_update.json +++ b/contracts/api/v2/fixtures/admin_access_group_update.json @@ -12,6 +12,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "allowed_permissions": null, "requests_allowed": false, "is_default": false, diff --git a/contracts/api/v2/fixtures/admin_account_capabilities.json b/contracts/api/v2/fixtures/admin_account_capabilities.json index b9ace12057..565d692148 100644 --- a/contracts/api/v2/fixtures/admin_account_capabilities.json +++ b/contracts/api/v2/fixtures/admin_account_capabilities.json @@ -1,5 +1,5 @@ { - "revision": "756d6d9a7e0546dde4bb44fe196522008b61748576d7923c93e7ed0d1c124c6d", + "revision": "c537fe27b1bb7a978c5584e48aa7f4503db9c74bbd825ce421b75ac378bb5f9c", "state": "available", "allowed": true, "available": true, @@ -7,6 +7,7 @@ "default_profile": true, "exact_identity_filter": true, "access_groups": true, + "profile_limits": true, "password_reset_link": true, "password_reset_email": true, "ownership_transfer": true, diff --git a/contracts/api/v2/fixtures/admin_account_delete_break_glass_required.json b/contracts/api/v2/fixtures/admin_account_delete_break_glass_required.json index 0df0280d19..ff870bb403 100644 --- a/contracts/api/v2/fixtures/admin_account_delete_break_glass_required.json +++ b/contracts/api/v2/fixtures/admin_account_delete_break_glass_required.json @@ -3,5 +3,5 @@ "title": "Break-glass account required", "status": 409, "detail": "Local password sign-in is off, and this is the last break-glass admin that can still sign in with a password", - "instance": "urn:silo:request:0000000000000000000002d0" + "instance": "urn:silo:request:0000000000000000000002d1" } diff --git a/contracts/api/v2/fixtures/admin_account_get.json b/contracts/api/v2/fixtures/admin_account_get.json index f4027099d9..c3750cf1d2 100644 --- a/contracts/api/v2/fixtures/admin_account_get.json +++ b/contracts/api/v2/fixtures/admin_account_get.json @@ -13,7 +13,7 @@ "max_local_stream_bitrate_kbps": null, "transcode_allowed": null, "audio_transcode_allowed": null, - "max_profiles": 5, + "max_profiles": null, "download_allowed": null, "download_transcode_allowed": null, "requests_allowed": null, @@ -29,6 +29,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "transcode_allowed": false, "audio_transcode_allowed": false, "download_allowed": false, diff --git a/contracts/api/v2/fixtures/admin_account_profile_sections_not_found.json b/contracts/api/v2/fixtures/admin_account_profile_sections_not_found.json index a78991288c..bceb836f39 100644 --- a/contracts/api/v2/fixtures/admin_account_profile_sections_not_found.json +++ b/contracts/api/v2/fixtures/admin_account_profile_sections_not_found.json @@ -3,5 +3,5 @@ "title": "Not found", "status": 404, "detail": "Profile not found", - "instance": "urn:silo:request:00000000000000000000021d" + "instance": "urn:silo:request:00000000000000000000021e" } diff --git a/contracts/api/v2/fixtures/admin_apple_push_test_notification.json b/contracts/api/v2/fixtures/admin_apple_push_test_notification.json new file mode 100644 index 0000000000..5b01ec24ca --- /dev/null +++ b/contracts/api/v2/fixtures/admin_apple_push_test_notification.json @@ -0,0 +1,8 @@ +{ + "attempt_id": "attempt-1", + "push_device_id": "device-1", + "server_device_id": "target-device", + "delivery_id": "delivery-1", + "outcome": "delivered", + "upstream_status": 200 +} diff --git a/contracts/api/v2/fixtures/admin_catalog_import_committed.json b/contracts/api/v2/fixtures/admin_catalog_import_committed.json index 0d570e0300..305aecb20b 100644 --- a/contracts/api/v2/fixtures/admin_catalog_import_committed.json +++ b/contracts/api/v2/fixtures/admin_catalog_import_committed.json @@ -12,5 +12,6 @@ "links_created": 0, "credits_replaced": 0, "embeddings_imported": 0, + "embeddings_skipped": 0, "skipped": 0 } diff --git a/contracts/api/v2/fixtures/admin_download_revoke_mixed.json b/contracts/api/v2/fixtures/admin_download_revoke_mixed.json index e5d38f046c..dd30c92fc4 100644 --- a/contracts/api/v2/fixtures/admin_download_revoke_mixed.json +++ b/contracts/api/v2/fixtures/admin_download_revoke_mixed.json @@ -3,5 +3,5 @@ "title": "Validation failed", "status": 422, "detail": "Send download ids or one device, not both.", - "instance": "urn:silo:request:0000000000000000000002f2" + "instance": "urn:silo:request:0000000000000000000002f3" } diff --git a/contracts/api/v2/fixtures/admin_download_storage_delete_invalid_id.json b/contracts/api/v2/fixtures/admin_download_storage_delete_invalid_id.json index 7231e8a0c6..9cb50fdf7a 100644 --- a/contracts/api/v2/fixtures/admin_download_storage_delete_invalid_id.json +++ b/contracts/api/v2/fixtures/admin_download_storage_delete_invalid_id.json @@ -3,5 +3,5 @@ "title": "Validation failed", "status": 422, "detail": "Invalid prepared file id.", - "instance": "urn:silo:request:0000000000000000000002eb" + "instance": "urn:silo:request:0000000000000000000002ec" } diff --git a/contracts/api/v2/fixtures/admin_item_markers_redetect_unknown_kind.json b/contracts/api/v2/fixtures/admin_item_markers_redetect_unknown_kind.json index 9e3d77228b..305690267c 100644 --- a/contracts/api/v2/fixtures/admin_item_markers_redetect_unknown_kind.json +++ b/contracts/api/v2/fixtures/admin_item_markers_redetect_unknown_kind.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:000000000000000000000247", + "instance": "urn:silo:request:000000000000000000000248", "errors": [ { "location": "body.kind", diff --git a/contracts/api/v2/fixtures/admin_login_session_forbidden.json b/contracts/api/v2/fixtures/admin_login_session_forbidden.json index 7e95ea3ac7..a8abf9841c 100644 --- a/contracts/api/v2/fixtures/admin_login_session_forbidden.json +++ b/contracts/api/v2/fixtures/admin_login_session_forbidden.json @@ -3,5 +3,5 @@ "title": "Permission denied", "status": 403, "detail": "Admin access required", - "instance": "urn:silo:request:0000000000000000000002e6" + "instance": "urn:silo:request:0000000000000000000002e7" } diff --git a/contracts/api/v2/fixtures/admin_network_access_status_not_found.json b/contracts/api/v2/fixtures/admin_network_access_status_not_found.json index be5fe326c3..6e626a00a7 100644 --- a/contracts/api/v2/fixtures/admin_network_access_status_not_found.json +++ b/contracts/api/v2/fixtures/admin_network_access_status_not_found.json @@ -3,5 +3,5 @@ "title": "Not found", "status": 404, "detail": "Network access provider not found.", - "instance": "urn:silo:request:00000000000000000000028f" + "instance": "urn:silo:request:000000000000000000000290" } diff --git a/contracts/api/v2/fixtures/admin_network_access_unknown_host.json b/contracts/api/v2/fixtures/admin_network_access_unknown_host.json index 8ec3c04e7a..f6fc1ea8d3 100644 --- a/contracts/api/v2/fixtures/admin_network_access_unknown_host.json +++ b/contracts/api/v2/fixtures/admin_network_access_unknown_host.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:000000000000000000000292", + "instance": "urn:silo:request:000000000000000000000293", "errors": [ { "location": "body.hosts", diff --git a/contracts/api/v2/fixtures/admin_recommendations_embeddings_reset.json b/contracts/api/v2/fixtures/admin_recommendations_embeddings_reset.json new file mode 100644 index 0000000000..1511aedda8 --- /dev/null +++ b/contracts/api/v2/fixtures/admin_recommendations_embeddings_reset.json @@ -0,0 +1,6 @@ +{ + "embeddings": 120, + "taste_profiles": 3, + "taste_clusters": 9, + "cached_rows": 41 +} diff --git a/contracts/api/v2/fixtures/admin_recommendations_status.json b/contracts/api/v2/fixtures/admin_recommendations_status.json index 73baa22df2..1741641a6b 100644 --- a/contracts/api/v2/fixtures/admin_recommendations_status.json +++ b/contracts/api/v2/fixtures/admin_recommendations_status.json @@ -2,7 +2,25 @@ "embeddings": { "running": true, "count": 3, - "total": 10 + "total": 10, + "last_run": { + "status": "failed", + "started_at": "2026-10-03T03:00:00.000Z", + "completed_at": "2026-10-03T03:01:30.000Z", + "error": "embed batch: POST https://[REDACTED]@embed.example/v1/embeddings: api_key=[REDACTED] rejected", + "result": { + "embedded": 0 + } + }, + "last_partial_run": { + "status": "completed", + "started_at": "2026-10-03T03:15:00.000Z", + "completed_at": "2026-10-03T03:16:00.000Z", + "result": { + "embedded": 2, + "missing_only": true + } + } }, "taste_profiles": { "running": false, @@ -14,6 +32,34 @@ }, "recommendations": { "running": false, - "count": 5 - } + "count": 5, + "last_run": { + "status": "completed", + "started_at": "2026-10-03T05:00:00.000Z", + "completed_at": "2026-10-03T05:01:00.000Z", + "result": { + "build_errors": 0, + "cached_rows": 14, + "processed": 2, + "profiles": 2 + } + } + }, + "lock_conflict": "", + "stale_sweep": { + "pending": 7, + "last_run": { + "status": "completed", + "started_at": "2026-10-03T06:00:00.000Z", + "completed_at": "2026-10-03T06:00:20.000Z", + "result": { + "deferred": 0, + "failed": 1, + "queued": 0, + "refreshed": 2, + "stale": 3 + } + } + }, + "cache_refreshed_at": "2026-10-03T05:01:00.000Z" } diff --git a/contracts/api/v2/fixtures/admin_subtitle_metadata_precondition_required.json b/contracts/api/v2/fixtures/admin_subtitle_metadata_precondition_required.json index fb6d7d9436..6a2b87fbd1 100644 --- a/contracts/api/v2/fixtures/admin_subtitle_metadata_precondition_required.json +++ b/contracts/api/v2/fixtures/admin_subtitle_metadata_precondition_required.json @@ -3,5 +3,5 @@ "title": "Precondition required", "status": 428, "detail": "This operation requires the If-Match header field carrying the resource's current ETag.", - "instance": "urn:silo:request:000000000000000000000265" + "instance": "urn:silo:request:000000000000000000000266" } diff --git a/contracts/api/v2/fixtures/admin_user_policy_defaults.json b/contracts/api/v2/fixtures/admin_user_policy_defaults.json index c0bae30f15..2fbe1973cb 100644 --- a/contracts/api/v2/fixtures/admin_user_policy_defaults.json +++ b/contracts/api/v2/fixtures/admin_user_policy_defaults.json @@ -6,6 +6,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "transcode_allowed": true, "audio_transcode_allowed": true, "download_allowed": true, @@ -19,6 +20,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "transcode_allowed": true, "audio_transcode_allowed": true, "download_allowed": true, diff --git a/contracts/api/v2/fixtures/android_push_registration_ok.json b/contracts/api/v2/fixtures/android_push_registration_ok.json new file mode 100644 index 0000000000..218eb6438c --- /dev/null +++ b/contracts/api/v2/fixtures/android_push_registration_ok.json @@ -0,0 +1,7 @@ +{ + "generation": "3", + "registration_id": "registration", + "server_device_id": "server-device", + "push_mode": "private_push", + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" +} diff --git a/contracts/api/v2/fixtures/apple_push_registration_capability.json b/contracts/api/v2/fixtures/apple_push_registration_capability.json index a2a492118d..ce86ca8451 100644 --- a/contracts/api/v2/fixtures/apple_push_registration_capability.json +++ b/contracts/api/v2/fixtures/apple_push_registration_capability.json @@ -1,6 +1,6 @@ { - "revision": "4bc701e05299627e8b05db8a01da13a810e2fc870e80d43066bf735ddf1e014a", - "state": "not_configured", + "revision": "021627e60916f147b6bb80e8512f738e97b7c22cb1f30e20d5eed6abf506305a", + "state": "available", "allowed": false, - "registration_available": false + "registration_available": true } diff --git a/contracts/api/v2/fixtures/apple_push_registration_ok.json b/contracts/api/v2/fixtures/apple_push_registration_ok.json new file mode 100644 index 0000000000..b71553eeb8 --- /dev/null +++ b/contracts/api/v2/fixtures/apple_push_registration_ok.json @@ -0,0 +1,11 @@ +{ + "generation": "3", + "id": "registration", + "server_device_id": "server-device", + "push_mode": "private_push", + "enabled": true, + "removed": false, + "display_token": "synthetic-display", + "display_token_expires_at": "2026-02-01T03:04:05.678Z", + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" +} diff --git a/contracts/api/v2/fixtures/approve_device_login_api_key_forbidden.json b/contracts/api/v2/fixtures/approve_device_login_api_key_forbidden.json index 4f1e6d8b5b..255b6a565a 100644 --- a/contracts/api/v2/fixtures/approve_device_login_api_key_forbidden.json +++ b/contracts/api/v2/fixtures/approve_device_login_api_key_forbidden.json @@ -3,5 +3,5 @@ "title": "Permission denied", "status": 403, "detail": "Approving or denying a device needs a signed-in session; API keys and impersonation sessions cannot.", - "instance": "urn:silo:request:0000000000000000000002cc" + "instance": "urn:silo:request:0000000000000000000002cd" } diff --git a/contracts/api/v2/fixtures/cancel_admin_download_preparations_invalid_id.json b/contracts/api/v2/fixtures/cancel_admin_download_preparations_invalid_id.json index f7a6994de4..0c66f5bcd9 100644 --- a/contracts/api/v2/fixtures/cancel_admin_download_preparations_invalid_id.json +++ b/contracts/api/v2/fixtures/cancel_admin_download_preparations_invalid_id.json @@ -3,5 +3,5 @@ "title": "Validation failed", "status": 422, "detail": "Invalid preparation job id.", - "instance": "urn:silo:request:0000000000000000000002d7" + "instance": "urn:silo:request:0000000000000000000002d8" } diff --git a/contracts/api/v2/fixtures/cancel_device_login_not_found.json b/contracts/api/v2/fixtures/cancel_device_login_not_found.json index df5d7b8c70..604f04a54e 100644 --- a/contracts/api/v2/fixtures/cancel_device_login_not_found.json +++ b/contracts/api/v2/fixtures/cancel_device_login_not_found.json @@ -3,5 +3,5 @@ "title": "Not found", "status": 404, "detail": "Device login request not found", - "instance": "urn:silo:request:0000000000000000000002b5" + "instance": "urn:silo:request:0000000000000000000002b6" } diff --git a/contracts/api/v2/fixtures/clear_resume_position_not_found.json b/contracts/api/v2/fixtures/clear_resume_position_not_found.json new file mode 100644 index 0000000000..6a69cbb148 --- /dev/null +++ b/contracts/api/v2/fixtures/clear_resume_position_not_found.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/not_found", + "title": "Not found", + "status": 404, + "detail": "Item not found", + "instance": "urn:silo:request:0000000000000000000002fb" +} diff --git a/contracts/api/v2/fixtures/clear_resume_position_not_leaf.json b/contracts/api/v2/fixtures/clear_resume_position_not_leaf.json new file mode 100644 index 0000000000..da720d2206 --- /dev/null +++ b/contracts/api/v2/fixtures/clear_resume_position_not_leaf.json @@ -0,0 +1,14 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/validation_failed", + "title": "Validation failed", + "status": 422, + "detail": "The request did not pass validation; see errors.", + "instance": "urn:silo:request:0000000000000000000002fa", + "errors": [ + { + "location": "path.media_item_id", + "code": "invalid", + "detail": "Only a movie or an episode has a resume position" + } + ] +} diff --git a/contracts/api/v2/fixtures/collection_order_foreign_id.json b/contracts/api/v2/fixtures/collection_order_foreign_id.json index e2c3737616..6dbfedc1ab 100644 --- a/contracts/api/v2/fixtures/collection_order_foreign_id.json +++ b/contracts/api/v2/fixtures/collection_order_foreign_id.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:0000000000000000000002d8", + "instance": "urn:silo:request:0000000000000000000002d9", "errors": [ { "location": "body.ordered_ids", diff --git a/contracts/api/v2/fixtures/complete_account_identity_link_invalid_grant.json b/contracts/api/v2/fixtures/complete_account_identity_link_invalid_grant.json index d0454f55b4..7ee4f6ab7b 100644 --- a/contracts/api/v2/fixtures/complete_account_identity_link_invalid_grant.json +++ b/contracts/api/v2/fixtures/complete_account_identity_link_invalid_grant.json @@ -3,5 +3,5 @@ "title": "Invalid grant", "status": 400, "detail": "The code_verifier does not fit this link code.", - "instance": "urn:silo:request:0000000000000000000002c7" + "instance": "urn:silo:request:0000000000000000000002c8" } diff --git a/contracts/api/v2/fixtures/complete_oauth_login_invalid_grant.json b/contracts/api/v2/fixtures/complete_oauth_login_invalid_grant.json index e154533212..8fe8c7b6d6 100644 --- a/contracts/api/v2/fixtures/complete_oauth_login_invalid_grant.json +++ b/contracts/api/v2/fixtures/complete_oauth_login_invalid_grant.json @@ -3,5 +3,5 @@ "title": "Invalid grant", "status": 400, "detail": "The code_verifier does not fit this completion code.", - "instance": "urn:silo:request:0000000000000000000002c1" + "instance": "urn:silo:request:0000000000000000000002c2" } diff --git a/contracts/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json b/contracts/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json index 50949a7c1b..03ab88117d 100644 --- a/contracts/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json +++ b/contracts/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:0000000000000000000002c3", + "instance": "urn:silo:request:0000000000000000000002c4", "errors": [ { "location": "body.password", diff --git a/contracts/api/v2/fixtures/create_admin_user_identity_linked_elsewhere.json b/contracts/api/v2/fixtures/create_admin_user_identity_linked_elsewhere.json index 7367183acb..efcd51ed79 100644 --- a/contracts/api/v2/fixtures/create_admin_user_identity_linked_elsewhere.json +++ b/contracts/api/v2/fixtures/create_admin_user_identity_linked_elsewhere.json @@ -3,5 +3,5 @@ "title": "Identity linked elsewhere", "status": 409, "detail": "This identity is already linked to another account", - "instance": "urn:silo:request:0000000000000000000002bc" + "instance": "urn:silo:request:0000000000000000000002bd" } diff --git a/contracts/api/v2/fixtures/delete_account_identity_last_sign_in_method.json b/contracts/api/v2/fixtures/delete_account_identity_last_sign_in_method.json index f31f1f0ebc..ee15f9a315 100644 --- a/contracts/api/v2/fixtures/delete_account_identity_last_sign_in_method.json +++ b/contracts/api/v2/fixtures/delete_account_identity_last_sign_in_method.json @@ -3,5 +3,5 @@ "title": "Last sign-in method", "status": 409, "detail": "The account has no other way to sign in; ask an administrator to set a password for it first", - "instance": "urn:silo:request:0000000000000000000002b8" + "instance": "urn:silo:request:0000000000000000000002b9" } diff --git a/contracts/api/v2/fixtures/ebook_annotation_delete_missing_guard.json b/contracts/api/v2/fixtures/ebook_annotation_delete_missing_guard.json index b111039754..36fb11c712 100644 --- a/contracts/api/v2/fixtures/ebook_annotation_delete_missing_guard.json +++ b/contracts/api/v2/fixtures/ebook_annotation_delete_missing_guard.json @@ -3,5 +3,5 @@ "title": "Precondition required", "status": 428, "detail": "This operation requires the If-Match header field carrying the resource's current ETag.", - "instance": "urn:silo:request:000000000000000000000234" + "instance": "urn:silo:request:000000000000000000000235" } diff --git a/contracts/api/v2/fixtures/ebook_annotation_patch_missing_guard.json b/contracts/api/v2/fixtures/ebook_annotation_patch_missing_guard.json index 7ee45276b4..b111039754 100644 --- a/contracts/api/v2/fixtures/ebook_annotation_patch_missing_guard.json +++ b/contracts/api/v2/fixtures/ebook_annotation_patch_missing_guard.json @@ -3,5 +3,5 @@ "title": "Precondition required", "status": 428, "detail": "This operation requires the If-Match header field carrying the resource's current ETag.", - "instance": "urn:silo:request:000000000000000000000233" + "instance": "urn:silo:request:000000000000000000000234" } diff --git a/contracts/api/v2/fixtures/ebook_config_missing_guard.json b/contracts/api/v2/fixtures/ebook_config_missing_guard.json index a4699eca3f..cb12562065 100644 --- a/contracts/api/v2/fixtures/ebook_config_missing_guard.json +++ b/contracts/api/v2/fixtures/ebook_config_missing_guard.json @@ -3,5 +3,5 @@ "title": "Precondition required", "status": 428, "detail": "This operation requires the If-Match header field carrying the resource's current ETag.", - "instance": "urn:silo:request:00000000000000000000022f" + "instance": "urn:silo:request:000000000000000000000230" } diff --git a/contracts/api/v2/fixtures/ebook_progress_missing_event_time.json b/contracts/api/v2/fixtures/ebook_progress_missing_event_time.json index 7e8e4c360e..d8cb502de7 100644 --- a/contracts/api/v2/fixtures/ebook_progress_missing_event_time.json +++ b/contracts/api/v2/fixtures/ebook_progress_missing_event_time.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:00000000000000000000022d", + "instance": "urn:silo:request:00000000000000000000022e", "errors": [ { "location": "body.updated_at", diff --git a/contracts/api/v2/fixtures/get_catalog_item_ok.json b/contracts/api/v2/fixtures/get_catalog_item_ok.json index 091f25ac1b..bd8722c443 100644 --- a/contracts/api/v2/fixtures/get_catalog_item_ok.json +++ b/contracts/api/v2/fixtures/get_catalog_item_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": true, "is_favorite": true, - "in_watchlist": false + "in_watchlist": false, + "not_interested": false }, "work_formats": [ { diff --git a/contracts/api/v2/fixtures/get_discover_ok.json b/contracts/api/v2/fixtures/get_discover_ok.json index 7d6906e3e8..83f886634e 100644 --- a/contracts/api/v2/fixtures/get_discover_ok.json +++ b/contracts/api/v2/fixtures/get_discover_ok.json @@ -31,7 +31,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/get_for_you_main_ok.json b/contracts/api/v2/fixtures/get_for_you_main_ok.json index d82d883b42..e4f3146560 100644 --- a/contracts/api/v2/fixtures/get_for_you_main_ok.json +++ b/contracts/api/v2/fixtures/get_for_you_main_ok.json @@ -29,7 +29,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/get_home_layout_include_items_out_of_range.json b/contracts/api/v2/fixtures/get_home_layout_include_items_out_of_range.json index 793ecc20a6..ca0e9a9325 100644 --- a/contracts/api/v2/fixtures/get_home_layout_include_items_out_of_range.json +++ b/contracts/api/v2/fixtures/get_home_layout_include_items_out_of_range.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:0000000000000000000002f6", + "instance": "urn:silo:request:0000000000000000000002f7", "errors": [ { "location": "query.include_items", diff --git a/contracts/api/v2/fixtures/get_home_layout_with_items_ok.json b/contracts/api/v2/fixtures/get_home_layout_with_items_ok.json index e1583af6ee..6bd2744627 100644 --- a/contracts/api/v2/fixtures/get_home_layout_with_items_ok.json +++ b/contracts/api/v2/fixtures/get_home_layout_with_items_ok.json @@ -55,7 +55,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/get_home_section_items_ok.json b/contracts/api/v2/fixtures/get_home_section_items_ok.json index cce9d8c8b4..34c9d90d79 100644 --- a/contracts/api/v2/fixtures/get_home_section_items_ok.json +++ b/contracts/api/v2/fixtures/get_home_section_items_ok.json @@ -33,7 +33,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/get_image_capabilities_ok.json b/contracts/api/v2/fixtures/get_image_capabilities_ok.json index 15d10b9252..189b60b778 100644 --- a/contracts/api/v2/fixtures/get_image_capabilities_ok.json +++ b/contracts/api/v2/fixtures/get_image_capabilities_ok.json @@ -1,11 +1,18 @@ { - "revision": "0ebdd7fa6c49a596cc6371358390a174e111965b73e938af57b825b2b2200118", + "revision": "9b056d77a083a4cdd2737681cfece78887272a600ab70debf5a4ececc3dc845c", "state": "available", "allowed": true, "episode_still_provenance": true, "extended_item_artwork": true, "season_list_artwork_param": "include_artwork", "param": "image_size", + "slot_width_params": { + "backdrop": "backdrop_width", + "logo": "logo_width", + "photo": "photo_width", + "poster": "poster_width", + "still": "still_width" + }, "sizes": [ "small", "medium", @@ -16,7 +23,7 @@ "backdrop": { "small": 300, "medium": 1920, - "large": 1920 + "large": 2560 }, "banner": { "small": 500, @@ -59,7 +66,7 @@ "large": 1280 } }, - "original_max_width_px": 1920, + "original_max_width_px": 3840, "storage_backend": "local", "delivery": "server" } diff --git a/contracts/api/v2/fixtures/get_library_capabilities_ok.json b/contracts/api/v2/fixtures/get_library_capabilities_ok.json index da27a3afdd..d75c5392f3 100644 --- a/contracts/api/v2/fixtures/get_library_capabilities_ok.json +++ b/contracts/api/v2/fixtures/get_library_capabilities_ok.json @@ -1,8 +1,9 @@ { - "revision": "acb6061b6ed685ee85933c4187157c9cf4380cd0e099b671dcee7690ef3e7e74", + "revision": "6fcbb994aa4a8925a3276d419d1a8d73147ac1dee0abfad0fb8c18a1f42accba", "state": "available", "allowed": true, "realtime_monitoring": true, "trickplay": true, - "trickplay_supported": false + "trickplay_supported": false, + "ignore_unmatched": true } diff --git a/contracts/api/v2/fixtures/get_library_section_items_ok.json b/contracts/api/v2/fixtures/get_library_section_items_ok.json index cce9d8c8b4..34c9d90d79 100644 --- a/contracts/api/v2/fixtures/get_library_section_items_ok.json +++ b/contracts/api/v2/fixtures/get_library_section_items_ok.json @@ -33,7 +33,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/get_not_interested_capability_ok.json b/contracts/api/v2/fixtures/get_not_interested_capability_ok.json new file mode 100644 index 0000000000..d4af0ff21b --- /dev/null +++ b/contracts/api/v2/fixtures/get_not_interested_capability_ok.json @@ -0,0 +1,5 @@ +{ + "revision": "b0b4875beb0989c8313675f36e32d8cc5f1ff9b616797eab4cdfbb495288f0b3", + "state": "available", + "allowed": true +} diff --git a/contracts/api/v2/fixtures/get_not_interested_not_found.json b/contracts/api/v2/fixtures/get_not_interested_not_found.json new file mode 100644 index 0000000000..a9b9d26669 --- /dev/null +++ b/contracts/api/v2/fixtures/get_not_interested_not_found.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/not_found", + "title": "Not found", + "status": 404, + "detail": "The profile has not marked the item Not interested.", + "instance": "urn:silo:request:000000000000000000000306" +} diff --git a/contracts/api/v2/fixtures/get_not_interested_ok.json b/contracts/api/v2/fixtures/get_not_interested_ok.json new file mode 100644 index 0000000000..adf15d746e --- /dev/null +++ b/contracts/api/v2/fixtures/get_not_interested_ok.json @@ -0,0 +1,4 @@ +{ + "item_id": "movie:c", + "marked_at": "2026-01-02T03:04:05.000Z" +} diff --git a/contracts/api/v2/fixtures/get_progress_capabilities_ok.json b/contracts/api/v2/fixtures/get_progress_capabilities_ok.json new file mode 100644 index 0000000000..47332472a4 --- /dev/null +++ b/contracts/api/v2/fixtures/get_progress_capabilities_ok.json @@ -0,0 +1,6 @@ +{ + "revision": "49220410b1e429a5668743a5a4b0a0dff17504393d81f2255cd5a66e5adfa8a0", + "state": "available", + "allowed": true, + "clear_resume_position": true +} diff --git a/contracts/api/v2/fixtures/get_ratings_capability_authentication_required.json b/contracts/api/v2/fixtures/get_ratings_capability_authentication_required.json index 85fc227efd..7ad2a928c3 100644 --- a/contracts/api/v2/fixtures/get_ratings_capability_authentication_required.json +++ b/contracts/api/v2/fixtures/get_ratings_capability_authentication_required.json @@ -3,5 +3,5 @@ "title": "Authentication required", "status": 401, "detail": "Authentication is required.", - "instance": "urn:silo:request:0000000000000000000002ab" + "instance": "urn:silo:request:0000000000000000000002ac" } diff --git a/contracts/api/v2/fixtures/get_recommendation_section_ok.json b/contracts/api/v2/fixtures/get_recommendation_section_ok.json index 86e6f66e8a..a93d6d3b86 100644 --- a/contracts/api/v2/fixtures/get_recommendation_section_ok.json +++ b/contracts/api/v2/fixtures/get_recommendation_section_ok.json @@ -29,7 +29,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/get_settings_contract_capabilities_ok.json b/contracts/api/v2/fixtures/get_settings_contract_capabilities_ok.json index 70691fde45..707cd8b0f1 100644 --- a/contracts/api/v2/fixtures/get_settings_contract_capabilities_ok.json +++ b/contracts/api/v2/fixtures/get_settings_contract_capabilities_ok.json @@ -1,5 +1,5 @@ { - "revision": "36e767e32d6613323df470594b9c91068ed062132912c3af462965668b2d31a4", + "revision": "221d308c596c46d40c4dfa9f5bdc0777e4cb0fb54c0c49685059d709f9eb56ae", "state": "available", "allowed": true, "api_version": 1, @@ -16,5 +16,6 @@ ], "supports_batched_effective": true, "supports_idempotent_writes": true, - "supports_atomic_shortcuts": true + "supports_atomic_shortcuts": true, + "supports_device_rename": true } diff --git a/contracts/api/v2/fixtures/get_watch_state_ok.json b/contracts/api/v2/fixtures/get_watch_state_ok.json index e29539f8a4..f949a81639 100644 --- a/contracts/api/v2/fixtures/get_watch_state_ok.json +++ b/contracts/api/v2/fixtures/get_watch_state_ok.json @@ -95,5 +95,9 @@ "played": false, "last_file_id": "3" }, - "effective_subtitle_language": "eng" + "effective_subtitle_language": "eng", + "poster_url": "/api/images/poster/heat.jpg?size=medium", + "poster_thumbhash": "poster-hash", + "backdrop_url": "/api/images/backdrop/heat.jpg?size=medium", + "backdrop_thumbhash": "backdrop-hash" } diff --git a/contracts/api/v2/fixtures/get_watch_tonight_ok.json b/contracts/api/v2/fixtures/get_watch_tonight_ok.json index 50510ffcd5..e873617e9b 100644 --- a/contracts/api/v2/fixtures/get_watch_tonight_ok.json +++ b/contracts/api/v2/fixtures/get_watch_tonight_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false }, "watch_tonight_source": "next_up" } diff --git a/contracts/api/v2/fixtures/get_watch_trickplay_not_found.json b/contracts/api/v2/fixtures/get_watch_trickplay_not_found.json index 2f3613f07a..382e89bb61 100644 --- a/contracts/api/v2/fixtures/get_watch_trickplay_not_found.json +++ b/contracts/api/v2/fixtures/get_watch_trickplay_not_found.json @@ -3,5 +3,5 @@ "title": "Not found", "status": 404, "detail": "The item has no such file.", - "instance": "urn:silo:request:0000000000000000000002ae" + "instance": "urn:silo:request:0000000000000000000002af" } diff --git a/contracts/api/v2/fixtures/ignore_unmatched_item_not_found.json b/contracts/api/v2/fixtures/ignore_unmatched_item_not_found.json new file mode 100644 index 0000000000..a1635f590e --- /dev/null +++ b/contracts/api/v2/fixtures/ignore_unmatched_item_not_found.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/not_found", + "title": "Not found", + "status": 404, + "detail": "Item not found", + "instance": "urn:silo:request:000000000000000000000302" +} diff --git a/contracts/api/v2/fixtures/index.json b/contracts/api/v2/fixtures/index.json index 89ed6a2aef..8315216b12 100644 --- a/contracts/api/v2/fixtures/index.json +++ b/contracts/api/v2/fixtures/index.json @@ -867,6 +867,27 @@ "schema": "#/components/schemas/AdminNotificationPushTestResult", "body_file": "admin_apple_push_test.json" }, + { + "name": "admin_apple_push_test_notification", + "operation_id": "testAdminApplePushNotification", + "scenario": "The test push carries the profile's newest notification and names it, so the device shows that notification's real text.", + "request": { + "method": "POST", + "path": "/api/v2/admin/notifications/push/apple/test", + "headers": { + "Authorization": "Bearer tok-admin" + }, + "body": "{\"profile_id\":\"notified-profile\",\"server_device_id\":\"target-device\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AdminNotificationPushTestResult", + "body_file": "admin_apple_push_test_notification.json" + }, { "name": "admin_catalog_export_link", "operation_id": "publishCatalogExportJob", @@ -3219,6 +3240,25 @@ "schema": "#/components/schemas/AdminRecommendationStarted", "body_file": "admin_recommendations_embeddings.json" }, + { + "name": "admin_recommendations_embeddings_reset", + "operation_id": "resetAdminRecommendationEmbeddings", + "scenario": "A reset reports what it deleted; global rows are kept.", + "request": { + "method": "POST", + "path": "/api/v2/admin/recommendations/embeddings/reset", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 200, + "response_headers": { + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AdminRecommendationEmbeddingsReset", + "body_file": "admin_recommendations_embeddings_reset.json" + }, { "name": "admin_recommendations_recommendations", "operation_id": "triggerAdminRecommendationRefresh", @@ -3241,7 +3281,7 @@ { "name": "admin_recommendations_status", "operation_id": "getAdminRecommendationsStatus", - "scenario": "Persisted counts and process-local running flags have distinct meanings.", + "scenario": "Persisted counts, last runs from any server and process-local running flags have distinct meanings.", "request": { "method": "GET", "path": "/api/v2/admin/recommendations/status", @@ -3873,10 +3913,34 @@ "schema": "#/components/schemas/AdminUserPolicyDefaults", "body_file": "admin_user_policy_defaults.json" }, + { + "name": "android_push_registration_ok", + "operation_id": "registerPushDevice", + "scenario": "An Android installation registers for private push; the receipt names the server so the app can route that server's pushes by server and profile.", + "request": { + "method": "POST", + "path": "/api/v2/notifications/push/devices", + "headers": { + "Authorization": "Bearer tok-events", + "X-Profile-Id": "p-owner", + "X-Push-Generation": "3", + "X-Push-Installation-Key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s" + }, + "body": "{\"device_id\":\"install\",\"platform\":\"android\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"push_mode\":\"private_push\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AndroidPushRegistrationReceipt", + "body_file": "android_push_registration_ok.json" + }, { "name": "apple_push_registration_capability", "operation_id": "getApplePushRegistrationCapabilities", - "scenario": "Local ordered Apple registration is unavailable when the service is absent; this does not describe provider delivery.", + "scenario": "Local ordered Apple registration and current-login validation are configured; this does not describe provider delivery.", "request": { "method": "GET", "path": "/api/v2/devices/push/apple/capabilities", @@ -3894,6 +3958,30 @@ "schema": "#/components/schemas/ApplePushRegistrationCapability", "body_file": "apple_push_registration_capability.json" }, + { + "name": "apple_push_registration_ok", + "operation_id": "registerApplePushDevice", + "scenario": "An iPhone registers for private push and receives a display credential; the receipt names the server so the app can key that credential by server and profile.", + "request": { + "method": "POST", + "path": "/api/v2/devices/push/apple", + "headers": { + "Authorization": "Bearer tok-events", + "X-Profile-Id": "p-owner", + "X-Push-Generation": "3", + "X-Push-Installation-Key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s" + }, + "body": "{\"device_id\":\"install\",\"apns_token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"apns_environment\":\"production\",\"apns_topic\":\"org.prairieserver.prairie\",\"push_mode\":\"private_push\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ApplePushRegistrationReceipt", + "body_file": "apple_push_registration_ok.json" + }, { "name": "approve_device_handoff_ok", "operation_id": "approveDeviceHandoff", @@ -4383,6 +4471,68 @@ "schema": "#/components/schemas/FileMarkers", "body_file": "clear_file_marker_segment_ok.json" }, + { + "name": "clear_resume_position_not_found", + "operation_id": "clearResumePosition", + "scenario": "An item outside the viewer's catalog has no resume position to clear.", + "request": { + "method": "DELETE", + "path": "/api/v2/progress/movie:missing/position", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 404, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "clear_resume_position_not_found.json" + }, + { + "name": "clear_resume_position_not_leaf", + "operation_id": "clearResumePosition", + "scenario": "A season or series has no single resume position: a validation failure naming the path parameter.", + "request": { + "method": "DELETE", + "path": "/api/v2/progress/series:heat/position", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 422, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "clear_resume_position_not_leaf.json" + }, + { + "name": "clear_resume_position_ok", + "operation_id": "clearResumePosition", + "scenario": "Clearing a movie's resume position: 204 with no body; its watched state and history stay.", + "request": { + "method": "DELETE", + "path": "/api/v2/progress/movie:heat-1995/position", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 204, + "response_headers": { + "Cache-Control": "no-store" + }, + "response_media_type": null, + "schema": null, + "body_file": null + }, { "name": "collection_group_unsupported", "operation_id": "updateCollectionGroup", @@ -7617,6 +7767,70 @@ "schema": "#/components/schemas/MetadataMatchQueueDetail", "body_file": "get_metadata_match_queue_ok.json" }, + { + "name": "get_not_interested_capability_ok", + "operation_id": "getNotInterestedCapability", + "scenario": "Profiles on this server can mark movies and series Not interested.", + "request": { + "method": "GET", + "path": "/api/v2/capabilities/not-interested", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json", + "ETag": "\"996fd28cc2dbccbf14bdf4956a1a604fa0d47ccabb65c1d747db33b60edf0d41\"" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/NotInterestedCapability", + "body_file": "get_not_interested_capability_ok.json" + }, + { + "name": "get_not_interested_not_found", + "operation_id": "getNotInterested", + "scenario": "The profile has not marked the title.", + "request": { + "method": "GET", + "path": "/api/v2/not-interested/movie:nope", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 404, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "get_not_interested_not_found.json" + }, + { + "name": "get_not_interested_ok", + "operation_id": "getNotInterested", + "scenario": "The profile marked the title Not interested: the mark with when it was made.", + "request": { + "method": "GET", + "path": "/api/v2/not-interested/movie:c", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/NotInterestedEntry", + "body_file": "get_not_interested_ok.json" + }, { "name": "get_oauth_handshake_capabilities_ok", "operation_id": "getOAuthHandshakeCapabilities", @@ -7829,6 +8043,28 @@ "schema": "#/components/schemas/ProfileSectionSettingCollection", "body_file": "get_profile_section_settings_ok.json" }, + { + "name": "get_progress_capabilities_ok", + "operation_id": "getProgressCapabilities", + "scenario": "The progress capability document: clearResumePosition is served.", + "request": { + "method": "GET", + "path": "/api/v2/progress/capabilities", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json", + "ETag": "\"2ff6c702f87c15b4122063ead741b7271dbbbc461a5a54a97379df4b16633a18\"" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ProgressCapabilities", + "body_file": "get_progress_capabilities_ok.json" + }, { "name": "get_provider_logout_api_key_empty", "operation_id": "getProviderLogout", @@ -8679,6 +8915,45 @@ "schema": null, "body_file": null }, + { + "name": "ignore_unmatched_item_not_found", + "operation_id": "ignoreUnmatchedItem", + "scenario": "Ignoring an item the catalog does not hold.", + "request": { + "method": "PUT", + "path": "/api/v2/libraries/unmatched-items/movie:missing/ignore", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 404, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "ignore_unmatched_item_not_found.json" + }, + { + "name": "ignore_unmatched_item_ok", + "operation_id": "ignoreUnmatchedItem", + "scenario": "An admin marks an item as intentionally unmatched; ignoring an ignored item again is a no-op. Success answers 204 with no body.", + "request": { + "method": "PUT", + "path": "/api/v2/libraries/unmatched-items/movie:d/ignore", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 204, + "response_headers": { + "Cache-Control": "no-store" + }, + "response_media_type": null, + "schema": null, + "body_file": null + }, { "name": "invitation_accepted", "operation_id": "acceptInvitation", @@ -9848,6 +10123,26 @@ "schema": "#/components/schemas/Problem", "body_file": "list_household_sessions_verification_required.json" }, + { + "name": "list_ignored_unmatched_items_ok", + "operation_id": "listUnmatchedItems", + "scenario": "The items an admin marked as intentionally unmatched, each with when it was ignored.", + "request": { + "method": "GET", + "path": "/api/v2/libraries/unmatched-items?ignored=true", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/UnmatchedItemCollection", + "body_file": "list_ignored_unmatched_items_ok.json" + }, { "name": "list_libraries_ok", "operation_id": "listLibraries", @@ -10091,6 +10386,27 @@ "schema": "#/components/schemas/MediaRequestCollection", "body_file": "list_my_requests_ok.json" }, + { + "name": "list_not_interested_ok", + "operation_id": "listNotInterested", + "scenario": "The first page of the titles a profile marked Not interested as catalog cards, newest mark first, with an opaque cursor for the next page.", + "request": { + "method": "GET", + "path": "/api/v2/not-interested?limit=1", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/NotInterestedCollection", + "body_file": "list_not_interested_ok.json" + }, { "name": "list_people_limit_out_of_range", "operation_id": "listPeople", @@ -10559,6 +10875,27 @@ "schema": "#/components/schemas/EpisodeCollection", "body_file": "list_season_episodes_ok.json" }, + { + "name": "list_seasonal_themes_ok", + "operation_id": "listSeasonalThemes", + "scenario": "The holidays a Seasonal Picks row can follow in calendar order, each with its curated vibes; a holiday that moves or recurs weekly has no start and end, and a weekly one cannot take a row's own dates.", + "request": { + "method": "GET", + "path": "/api/v2/sections/seasonal-themes", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/SeasonalThemeCatalog", + "body_file": "list_seasonal_themes_ok.json" + }, { "name": "list_section_recipe_candidates_account_scoped", "operation_id": "listSectionRecipeCandidates", @@ -11380,6 +11717,48 @@ "schema": "#/components/schemas/Problem", "body_file": "logout_permission_denied.json" }, + { + "name": "mark_not_interested_episode_rejected", + "operation_id": "markNotInterested", + "scenario": "An episode cannot be marked; the series can.", + "request": { + "method": "PUT", + "path": "/api/v2/not-interested/episode:x", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 422, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "mark_not_interested_episode_rejected.json" + }, + { + "name": "mark_not_interested_not_found", + "operation_id": "markNotInterested", + "scenario": "A title the viewer may not see cannot be marked.", + "request": { + "method": "PUT", + "path": "/api/v2/not-interested/movie:hidden", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 404, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "mark_not_interested_not_found.json" + }, { "name": "mark_watched_not_found", "operation_id": "markWatched", @@ -11729,6 +12108,27 @@ "schema": "#/components/schemas/NotificationCountOutputBody", "body_file": "notification_get_notification_unread_count.json" }, + { + "name": "notification_list_notification_series", + "operation_id": "listNotificationSeries", + "scenario": "Series that can notify the acting profile, with one the viewer may not see omitted.", + "request": { + "method": "GET", + "path": "/api/v2/notifications/series", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/NotificationSeriesCollection", + "body_file": "notification_list_notification_series.json" + }, { "name": "notification_list_notifications", "operation_id": "listNotifications", @@ -11791,6 +12191,47 @@ "schema": null, "body_file": null }, + { + "name": "notification_mute_notification_series", + "operation_id": "muteNotificationSeries", + "scenario": "The acting profile mutes one series: no new-episode notification for it on any channel until it unmutes. Repeating the call changes nothing.", + "request": { + "method": "PUT", + "path": "/api/v2/notifications/series/series:bear/mute", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 204, + "response_headers": { + "Cache-Control": "no-store" + }, + "response_media_type": null, + "schema": null, + "body_file": null + }, + { + "name": "notification_mute_notification_series_not_found", + "operation_id": "muteNotificationSeries", + "scenario": "A series the viewer may not see cannot be muted.", + "request": { + "method": "PUT", + "path": "/api/v2/notifications/series/series:hidden/mute", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 404, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "notification_mute_notification_series_not_found.json" + }, { "name": "notification_relay_clear", "operation_id": "clearAdminNotificationRelay", @@ -11913,6 +12354,26 @@ "schema": "#/components/schemas/NotificationSyncOutputBody", "body_file": "notification_sync_notifications.json" }, + { + "name": "notification_unmute_notification_series", + "operation_id": "unmuteNotificationSeries", + "scenario": "The acting profile unmutes a series; it notifies again from the next new episode. Unmuting a series that is not muted changes nothing.", + "request": { + "method": "DELETE", + "path": "/api/v2/notifications/series/series:wire/mute", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 204, + "response_headers": { + "Cache-Control": "no-store" + }, + "response_media_type": null, + "schema": null, + "body_file": null + }, { "name": "notification_update_notification_preferences", "operation_id": "updateNotificationPreferences", @@ -13451,7 +13912,7 @@ { "name": "server_connections_ok", "operation_id": "getServerConnections", - "scenario": "A signed-in client reads the addresses the deployment offers: the public URL, a connected provider with its overlay origin, and an installed provider whose plugin is not running.", + "scenario": "A signed-in client reads the addresses the deployment offers: the public URL, a connected provider with its overlay origin, and an installed provider whose plugin is not running, and the local route: the pin of the key the API listener presents over TLS and the admin-set local address.", "request": { "method": "GET", "path": "/api/v2/system/connections", @@ -13463,7 +13924,7 @@ "response_headers": { "Cache-Control": "private, no-cache", "Content-Type": "application/json", - "ETag": "\"251d40c6578ba4f568f974b66faed17c5fd6ea82251fdc08ebc30e12c2be477f\"" + "ETag": "\"477940d56c15a378ad29473fb403139b11e5e9d18bbbaf9a50c9a060b9843095\"" }, "response_media_type": "application/json", "schema": "#/components/schemas/ServerConnectionsDocument", @@ -14408,6 +14869,25 @@ "schema": "#/components/schemas/Problem", "body_file": "undismiss_home_item_unknown_surface.json" }, + { + "name": "unignore_unmatched_item_ok", + "operation_id": "unignoreUnmatchedItem", + "scenario": "Clearing the mark of an item that is not ignored leaves it as is. Success answers 204 with no body.", + "request": { + "method": "DELETE", + "path": "/api/v2/libraries/unmatched-items/movie:b/ignore", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 204, + "response_headers": { + "Cache-Control": "no-store" + }, + "response_media_type": null, + "schema": null, + "body_file": null + }, { "name": "unknown_query_parameter", "operation_id": "getSystemInfo", @@ -14425,6 +14905,27 @@ "schema": "#/components/schemas/Problem", "body_file": "unknown_query_parameter.json" }, + { + "name": "unmark_not_interested_locked_profile", + "operation_id": "unmarkNotInterested", + "scenario": "A PIN-locked profile without X-Profile-Token.", + "request": { + "method": "DELETE", + "path": "/api/v2/not-interested/movie:c", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-locked" + } + }, + "expected_status": 403, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "unmark_not_interested_locked_profile.json" + }, { "name": "unmark_watched_not_found", "operation_id": "unmarkWatched", diff --git a/contracts/api/v2/fixtures/link_account_identity_with_credentials_directory_refused.json b/contracts/api/v2/fixtures/link_account_identity_with_credentials_directory_refused.json index 49e013b85c..65fb2e4b78 100644 --- a/contracts/api/v2/fixtures/link_account_identity_with_credentials_directory_refused.json +++ b/contracts/api/v2/fixtures/link_account_identity_with_credentials_directory_refused.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:0000000000000000000002cb", + "instance": "urn:silo:request:0000000000000000000002cc", "errors": [ { "location": "body.directory_password", diff --git a/contracts/api/v2/fixtures/list_admin_users_exact_identity.json b/contracts/api/v2/fixtures/list_admin_users_exact_identity.json index 5b4a7785f3..20d5f3b5b6 100644 --- a/contracts/api/v2/fixtures/list_admin_users_exact_identity.json +++ b/contracts/api/v2/fixtures/list_admin_users_exact_identity.json @@ -15,7 +15,7 @@ "max_local_stream_bitrate_kbps": null, "transcode_allowed": null, "audio_transcode_allowed": null, - "max_profiles": 5, + "max_profiles": null, "download_allowed": true, "download_transcode_allowed": null, "requests_allowed": null, @@ -33,6 +33,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "transcode_allowed": true, "audio_transcode_allowed": false, "download_allowed": false, diff --git a/contracts/api/v2/fixtures/list_admin_users_ok.json b/contracts/api/v2/fixtures/list_admin_users_ok.json index a6187eadfb..331cf0d81a 100644 --- a/contracts/api/v2/fixtures/list_admin_users_ok.json +++ b/contracts/api/v2/fixtures/list_admin_users_ok.json @@ -15,7 +15,7 @@ "max_local_stream_bitrate_kbps": null, "transcode_allowed": null, "audio_transcode_allowed": null, - "max_profiles": 5, + "max_profiles": null, "download_allowed": true, "download_transcode_allowed": null, "requests_allowed": null, @@ -33,6 +33,7 @@ "max_transcodes": 0, "max_remote_stream_bitrate_kbps": 0, "max_local_stream_bitrate_kbps": 0, + "max_profiles": 5, "transcode_allowed": true, "audio_transcode_allowed": false, "download_allowed": false, diff --git a/contracts/api/v2/fixtures/list_because_watched_ok.json b/contracts/api/v2/fixtures/list_because_watched_ok.json index bcb704df29..0479aa7446 100644 --- a/contracts/api/v2/fixtures/list_because_watched_ok.json +++ b/contracts/api/v2/fixtures/list_because_watched_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_catalog_item_episodes_ok.json b/contracts/api/v2/fixtures/list_catalog_item_episodes_ok.json index 979ae69632..e1aac3a958 100644 --- a/contracts/api/v2/fixtures/list_catalog_item_episodes_ok.json +++ b/contracts/api/v2/fixtures/list_catalog_item_episodes_ok.json @@ -7,6 +7,20 @@ "title": "Good News About Hell", "air_date": "2022-02-18", "runtime": 57, + "ratings": [ + { + "source": "imdb", + "name": "IMDb", + "score": 87, + "display": "8.7" + }, + { + "source": "tmdb", + "name": "TMDB", + "score": 82.5, + "display": "8.3" + } + ], "user_data": { "watched_count": 1, "unplayed_count": 0, diff --git a/contracts/api/v2/fixtures/list_devices_ok.json b/contracts/api/v2/fixtures/list_devices_ok.json index 4e25f18694..165268f1f7 100644 --- a/contracts/api/v2/fixtures/list_devices_ok.json +++ b/contracts/api/v2/fixtures/list_devices_ok.json @@ -2,7 +2,8 @@ "items": [ { "device_id": "d-1", - "device_name": "", + "device_name": "Apple TV", + "custom_name": "Living Room", "device_platform": "", "last_seen_at": "2026-01-02T03:04:05.123Z", "profile_id": "p-owner", diff --git a/contracts/api/v2/fixtures/list_for_you_rows_ok.json b/contracts/api/v2/fixtures/list_for_you_rows_ok.json index 7d6906e3e8..83f886634e 100644 --- a/contracts/api/v2/fixtures/list_for_you_rows_ok.json +++ b/contracts/api/v2/fixtures/list_for_you_rows_ok.json @@ -31,7 +31,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_home_sections_ok.json b/contracts/api/v2/fixtures/list_home_sections_ok.json index 7de0b9cdcc..7ea40e39e9 100644 --- a/contracts/api/v2/fixtures/list_home_sections_ok.json +++ b/contracts/api/v2/fixtures/list_home_sections_ok.json @@ -35,7 +35,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_ignored_unmatched_items_ok.json b/contracts/api/v2/fixtures/list_ignored_unmatched_items_ok.json new file mode 100644 index 0000000000..f8d926f9c3 --- /dev/null +++ b/contracts/api/v2/fixtures/list_ignored_unmatched_items_ok.json @@ -0,0 +1,18 @@ +{ + "items": [ + { + "content_id": "movie:d", + "title": "Delta", + "year": 2004, + "content_type": "movie", + "library_id": "1", + "library_name": "Movies", + "status": "unmatched", + "ignored_at": "2026-01-02T03:04:05.678Z" + } + ], + "page": { + "has_more": false + }, + "total": 1 +} diff --git a/contracts/api/v2/fixtures/list_library_sections_ok.json b/contracts/api/v2/fixtures/list_library_sections_ok.json index 7de0b9cdcc..7ea40e39e9 100644 --- a/contracts/api/v2/fixtures/list_library_sections_ok.json +++ b/contracts/api/v2/fixtures/list_library_sections_ok.json @@ -35,7 +35,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_not_interested_ok.json b/contracts/api/v2/fixtures/list_not_interested_ok.json new file mode 100644 index 0000000000..479e6fc04b --- /dev/null +++ b/contracts/api/v2/fixtures/list_not_interested_ok.json @@ -0,0 +1,16 @@ +{ + "items": [ + { + "content_id": "movie:c", + "type": "movie", + "title": "Title movie:c", + "genres": [], + "keywords": [], + "status": "matched" + } + ], + "page": { + "next_cursor": "eyJ2IjoxLCJwIjp7ImEiOiIyMDI2LTAxLTAyVDAzOjA0OjA1WiIsIm0iOiJtb3ZpZTpjIn19.HPnfe2YAgiWJ6PnMl3t2asgqsLYYE4kIyWR17iWodjA", + "has_more": true + } +} diff --git a/contracts/api/v2/fixtures/list_popular_ok.json b/contracts/api/v2/fixtures/list_popular_ok.json index bcb704df29..0479aa7446 100644 --- a/contracts/api/v2/fixtures/list_popular_ok.json +++ b/contracts/api/v2/fixtures/list_popular_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_recently_added_ok.json b/contracts/api/v2/fixtures/list_recently_added_ok.json index bcb704df29..0479aa7446 100644 --- a/contracts/api/v2/fixtures/list_recently_added_ok.json +++ b/contracts/api/v2/fixtures/list_recently_added_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_season_episodes_ok.json b/contracts/api/v2/fixtures/list_season_episodes_ok.json index 979ae69632..e1aac3a958 100644 --- a/contracts/api/v2/fixtures/list_season_episodes_ok.json +++ b/contracts/api/v2/fixtures/list_season_episodes_ok.json @@ -7,6 +7,20 @@ "title": "Good News About Hell", "air_date": "2022-02-18", "runtime": 57, + "ratings": [ + { + "source": "imdb", + "name": "IMDb", + "score": 87, + "display": "8.7" + }, + { + "source": "tmdb", + "name": "TMDB", + "score": 82.5, + "display": "8.3" + } + ], "user_data": { "watched_count": 1, "unplayed_count": 0, diff --git a/contracts/api/v2/fixtures/list_seasonal_themes_ok.json b/contracts/api/v2/fixtures/list_seasonal_themes_ok.json new file mode 100644 index 0000000000..efafaab587 --- /dev/null +++ b/contracts/api/v2/fixtures/list_seasonal_themes_ok.json @@ -0,0 +1,316 @@ +{ + "themes": [ + { + "key": "valentines", + "name": "Valentine's Day", + "when": "Feb 7–14", + "start": "02-07", + "end": "02-14", + "dates_editable": true, + "vibes": [ + { + "key": "romcoms", + "name": "Rom-coms", + "title": "Valentine's Rom-Coms", + "description": "The best-reviewed romantic comedies.", + "list_url": "https://lists.example.test/lists/hdlists/romantic-comedies-rotten-tomatoes-top-rated" + }, + { + "key": "favorites", + "name": "Valentine's favorites", + "title": "Valentine's Favorites", + "description": "Popular date-night picks.", + "list_url": "https://lists.example.test/lists/linvo/valentines-day-popular-movies" + }, + { + "key": "romantic_dramas", + "name": "Romantic dramas", + "title": "Romantic Dramas", + "description": "Sweeping love stories with a few tears.", + "list_url": "https://lists.example.test/lists/themtsil/romantic-dramas" + }, + { + "key": "love_stories", + "name": "Classic love stories", + "title": "Classic Love Stories", + "description": "Casablanca, Titanic and the other all-time romances.", + "list_url": "https://lists.example.test/lists/tob-t/love-story" + } + ] + }, + { + "key": "st_patricks", + "name": "St. Patrick's Day", + "when": "Mar 15–17", + "start": "03-15", + "end": "03-17", + "dates_editable": true, + "vibes": [ + { + "key": "irish_film", + "name": "Irish film", + "title": "Irish Film", + "description": "Films from and about Ireland.", + "list_url": "https://lists.example.test/lists/irljidel/irish-interest-movies" + }, + { + "key": "irish_tv", + "name": "Irish TV", + "title": "Irish TV", + "description": "Series from and about Ireland.", + "list_url": "https://lists.example.test/lists/irljidel/irish-interest-tv-shows" + } + ] + }, + { + "key": "easter", + "name": "Easter", + "when": "Palm Sunday to Easter Monday", + "dates_editable": true, + "vibes": [ + { + "key": "family", + "name": "Easter movies", + "title": "Easter Movies", + "description": "Springtime family favorites and Easter classics.", + "list_url": "https://lists.example.test/lists/bsm-venger/easter-movies-e78nzwjk7k" + }, + { + "key": "faith", + "name": "Faith and Easter stories", + "title": "Easter Stories", + "description": "Biblical epics and stories of faith.", + "list_url": "https://lists.example.test/lists/bluejack92/easter-by-antares" + } + ] + }, + { + "key": "summer_blockbuster", + "name": "Summer", + "when": "June to August", + "start": "06-01", + "end": "08-31", + "dates_editable": true, + "vibes": [ + { + "key": "blockbusters", + "name": "Blockbusters", + "title": "Summer Blockbusters", + "description": "The biggest box-office hits.", + "list_url": "https://lists.example.test/lists/drovur/box-office-giants" + }, + { + "key": "classic_summer", + "name": "Classic summer movies", + "title": "Classic Summer Movies", + "description": "Summer releases that defined the season, from Jaws to Independence Day.", + "list_url": "https://lists.example.test/lists/heavymgs/summer-movies" + }, + { + "key": "nostalgia", + "name": "Summer nostalgia", + "title": "Summer Nostalgia", + "description": "Long days, summer camp and coming of age.", + "list_url": "https://lists.example.test/lists/reachingout/summer-movies" + } + ] + }, + { + "key": "halloween", + "name": "Halloween", + "when": "October", + "start": "10-01", + "end": "10-31", + "dates_editable": true, + "vibes": [ + { + "key": "favorites", + "name": "Halloween favorites", + "title": "Halloween Favorites", + "description": "The best-loved Halloween movies, spooky and scary.", + "list_url": "https://lists.example.test/lists/hdlists/halloween-movies-the-best-of-all-time" + }, + { + "key": "family", + "name": "Family Halloween", + "title": "Family Halloween", + "description": "Spooky fun for all ages.", + "list_url": "https://lists.example.test/lists/purple_smurf/halloween-family2" + }, + { + "key": "horror", + "name": "Halloween horror", + "title": "Halloween Horror", + "description": "Slashers, hauntings and horror classics.", + "list_url": "https://lists.example.test/lists/swansongfunds/horror-halloween" + }, + { + "key": "animated", + "name": "Animated Halloween", + "title": "Animated Halloween", + "description": "Animated spooky favorites.", + "list_url": "https://lists.example.test/lists/swansongfunds/halloween-animation" + }, + { + "key": "tv", + "name": "Halloween TV", + "title": "Halloween TV", + "description": "Spooky series to binge.", + "list_url": "https://lists.example.test/lists/rootednode/halloween-tv" + } + ] + }, + { + "key": "thanksgiving", + "name": "Thanksgiving", + "when": "Nov 22–30", + "start": "11-22", + "end": "11-30", + "dates_editable": true, + "vibes": [ + { + "key": "favorites", + "name": "Thanksgiving movies", + "title": "Thanksgiving Movies", + "description": "Films set around the Thanksgiving table.", + "list_url": "https://lists.example.test/lists/bluejack92/thanksgiving-by-antares" + }, + { + "key": "feel_good", + "name": "Feel-good favorites", + "title": "Feel-Good Favorites", + "description": "Heartwarming picks for the long weekend.", + "list_url": "https://lists.example.test/lists/netthier/thanksgiving-movies" + } + ] + }, + { + "key": "christmas", + "name": "Christmas", + "when": "December", + "start": "12-01", + "end": "12-31", + "dates_editable": true, + "vibes": [ + { + "key": "favorites", + "name": "Christmas favorites", + "title": "Christmas Favorites", + "description": "The most-watched Christmas movies.", + "list_url": "https://lists.example.test/lists/jbeasley74/christmas-movies" + }, + { + "key": "family", + "name": "Family Christmas", + "title": "Family Christmas", + "description": "Christmas movies for the whole family.", + "list_url": "https://lists.example.test/lists/paperarmour/christmas-kids-movies" + }, + { + "key": "classics", + "name": "Christmas classics", + "title": "Christmas Classics", + "description": "Golden-age holiday films.", + "list_url": "https://lists.example.test/lists/momanch/oldies-christmas-classics" + }, + { + "key": "romcoms", + "name": "Christmas rom-coms", + "title": "Christmas Rom-Coms", + "description": "Romantic comedies set at Christmas.", + "list_url": "https://lists.example.test/lists/momanch/christmas-romantic-comedies" + }, + { + "key": "comedies", + "name": "Christmas comedies", + "title": "Christmas Comedies", + "description": "Holiday laughs.", + "list_url": "https://lists.example.test/lists/momanch/christmas-movie-comedies" + }, + { + "key": "hallmark", + "name": "Hallmark Christmas", + "title": "Hallmark Christmas", + "description": "Hallmark's original Christmas movies.", + "list_url": "https://lists.example.test/lists/vectrexgamer/hallmark-original-christmas-movies" + } + ] + }, + { + "key": "new_year", + "name": "New Year's", + "when": "Dec 26–Jan 1", + "start": "12-26", + "end": "01-01", + "dates_editable": true, + "vibes": [ + { + "key": "favorites", + "name": "New Year's movies", + "title": "New Year's Movies", + "description": "Films that ring in the new year.", + "list_url": "https://lists.example.test/lists/netthier/new-year-movies" + } + ] + }, + { + "key": "saturday_morning", + "name": "Saturday Morning", + "when": "Saturdays before 1pm", + "dates_editable": false, + "vibes": [ + { + "key": "cartoons", + "name": "Saturday morning cartoons", + "title": "Saturday Morning Cartoons", + "description": "Classic weekend cartoons.", + "list_url": "https://lists.example.test/lists/arnx1922/saturday-morning-cartoons-b75bnxjjej" + }, + { + "key": "nineties", + "name": "90s cartoons", + "title": "90s Saturday Mornings", + "description": "The cartoons of the 1990s.", + "list_url": "https://lists.example.test/lists/smackdaddy17/saturday-morning-cartoons" + }, + { + "key": "modern", + "name": "Modern cartoons", + "title": "Saturday Morning Favorites", + "description": "Animated series from Batman to Gravity Falls.", + "list_url": "https://lists.example.test/lists/savini-andrew-gmail-com/saturday-morning-all-time" + } + ] + }, + { + "key": "family_movie_night", + "name": "Family Movie Night", + "when": "Friday and Saturday evenings", + "dates_editable": false, + "vibes": [ + { + "key": "new", + "name": "New and popular", + "title": "Family Movie Night", + "description": "Recent family hits.", + "list_url": "https://lists.example.test/lists/josh_bergeron/sunday-night-family-movies" + }, + { + "key": "favorites", + "name": "Family favorites", + "title": "Family Favorites", + "description": "Family films from The Goonies to Coco.", + "list_url": "https://lists.example.test/lists/jaredmecham/top-family-movies-1980" + }, + { + "key": "live_action", + "name": "Live-action family", + "title": "Family Movie Night", + "description": "Family films without the animation.", + "list_url": "https://lists.example.test/lists/paperarmour/family-movies-animation-excluded" + } + ] + } + ], + "max_custom_holidays": 12 +} diff --git a/contracts/api/v2/fixtures/list_series_seasons_invalid_artwork.json b/contracts/api/v2/fixtures/list_series_seasons_invalid_artwork.json index 61ef775d01..e109c0f6dd 100644 --- a/contracts/api/v2/fixtures/list_series_seasons_invalid_artwork.json +++ b/contracts/api/v2/fixtures/list_series_seasons_invalid_artwork.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:000000000000000000000284", + "instance": "urn:silo:request:000000000000000000000285", "errors": [ { "location": "query.include_artwork", diff --git a/contracts/api/v2/fixtures/list_similar_ok.json b/contracts/api/v2/fixtures/list_similar_ok.json index bcb704df29..0479aa7446 100644 --- a/contracts/api/v2/fixtures/list_similar_ok.json +++ b/contracts/api/v2/fixtures/list_similar_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_similar_users_liked_ok.json b/contracts/api/v2/fixtures/list_similar_users_liked_ok.json index bcb704df29..0479aa7446 100644 --- a/contracts/api/v2/fixtures/list_similar_users_liked_ok.json +++ b/contracts/api/v2/fixtures/list_similar_users_liked_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ] diff --git a/contracts/api/v2/fixtures/list_taste_seed_items_ok.json b/contracts/api/v2/fixtures/list_taste_seed_items_ok.json index bdc7435f00..f4617025c5 100644 --- a/contracts/api/v2/fixtures/list_taste_seed_items_ok.json +++ b/contracts/api/v2/fixtures/list_taste_seed_items_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false } } ], diff --git a/contracts/api/v2/fixtures/list_watch_tonight_cards_ok.json b/contracts/api/v2/fixtures/list_watch_tonight_cards_ok.json index fde37f9e77..c03b8b4b86 100644 --- a/contracts/api/v2/fixtures/list_watch_tonight_cards_ok.json +++ b/contracts/api/v2/fixtures/list_watch_tonight_cards_ok.json @@ -25,7 +25,8 @@ "user_state": { "played": false, "is_favorite": false, - "in_watchlist": true + "in_watchlist": true, + "not_interested": false }, "watch_tonight_source": "recommendation", "cast": [ diff --git a/contracts/api/v2/fixtures/login_account_required.json b/contracts/api/v2/fixtures/login_account_required.json index 3c325ae1ee..9c6a1a5ea1 100644 --- a/contracts/api/v2/fixtures/login_account_required.json +++ b/contracts/api/v2/fixtures/login_account_required.json @@ -3,5 +3,5 @@ "title": "Account required", "status": 403, "detail": "The sign-in provider admitted this person, but the server has no account for them; an administrator has to add one.", - "instance": "urn:silo:request:0000000000000000000002cf" + "instance": "urn:silo:request:0000000000000000000002d0" } diff --git a/contracts/api/v2/fixtures/login_provider_null.json b/contracts/api/v2/fixtures/login_provider_null.json index a3c920f2ad..517bc4a5cf 100644 --- a/contracts/api/v2/fixtures/login_provider_null.json +++ b/contracts/api/v2/fixtures/login_provider_null.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:00000000000000000000028a", + "instance": "urn:silo:request:00000000000000000000028b", "errors": [ { "location": "body.provider", diff --git a/contracts/api/v2/fixtures/mark_not_interested_episode_rejected.json b/contracts/api/v2/fixtures/mark_not_interested_episode_rejected.json new file mode 100644 index 0000000000..53d69606a8 --- /dev/null +++ b/contracts/api/v2/fixtures/mark_not_interested_episode_rejected.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/validation_failed", + "title": "Validation failed", + "status": 422, + "detail": "Only a movie or series can be marked Not interested", + "instance": "urn:silo:request:000000000000000000000307" +} diff --git a/contracts/api/v2/fixtures/mark_not_interested_not_found.json b/contracts/api/v2/fixtures/mark_not_interested_not_found.json new file mode 100644 index 0000000000..9a3cda3123 --- /dev/null +++ b/contracts/api/v2/fixtures/mark_not_interested_not_found.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/not_found", + "title": "Not found", + "status": 404, + "detail": "Item not found", + "instance": "urn:silo:request:000000000000000000000308" +} diff --git a/contracts/api/v2/fixtures/notification_get_notification_capabilities.json b/contracts/api/v2/fixtures/notification_get_notification_capabilities.json index db6c55a66c..1c5e9d7b7f 100644 --- a/contracts/api/v2/fixtures/notification_get_notification_capabilities.json +++ b/contracts/api/v2/fixtures/notification_get_notification_capabilities.json @@ -1,5 +1,5 @@ { - "revision": "b1406770e1cc06c7d72140247c907cd0796f12062ab858eda4d37a02de47c1a1", + "revision": "302a85c2ba6b66ff6c3326888ce8a646db429680e8060081bd639c7c70101ced", "state": "available", "allowed": true, "in_app": { @@ -36,5 +36,9 @@ "available": false, "modes": [], "digest_hour": 0 + }, + "series": { + "available": true, + "mute": true } } diff --git a/contracts/api/v2/fixtures/notification_list_notification_series.json b/contracts/api/v2/fixtures/notification_list_notification_series.json new file mode 100644 index 0000000000..2c3bdf5c79 --- /dev/null +++ b/contracts/api/v2/fixtures/notification_list_notification_series.json @@ -0,0 +1,107 @@ +{ + "items": [ + { + "item": { + "content_id": "series:andor", + "type": "series", + "title": "Title series:andor", + "genres": [], + "keywords": [], + "status": "matched" + }, + "reasons": { + "favorite": false, + "watchlist": false, + "continue_watching": false, + "next_up": true + }, + "active_reasons": { + "favorite": false, + "watchlist": false, + "continue_watching": false, + "next_up": true + }, + "notifies": true, + "muted": false, + "next_up_after": { + "season_number": 2, + "episode_number": 8 + } + }, + { + "item": { + "content_id": "series:bear", + "type": "series", + "title": "Title series:bear", + "genres": [], + "keywords": [], + "status": "matched" + }, + "reasons": { + "favorite": true, + "watchlist": false, + "continue_watching": true, + "next_up": false + }, + "active_reasons": { + "favorite": true, + "watchlist": false, + "continue_watching": true, + "next_up": false + }, + "notifies": true, + "muted": false + }, + { + "item": { + "content_id": "series:slow-horses", + "type": "series", + "title": "Title series:slow-horses", + "genres": [], + "keywords": [], + "status": "matched" + }, + "reasons": { + "favorite": false, + "watchlist": true, + "continue_watching": false, + "next_up": false + }, + "active_reasons": { + "favorite": false, + "watchlist": false, + "continue_watching": false, + "next_up": false + }, + "notifies": false, + "muted": false + }, + { + "item": { + "content_id": "series:wire", + "type": "series", + "title": "Title series:wire", + "genres": [], + "keywords": [], + "status": "matched" + }, + "reasons": { + "favorite": true, + "watchlist": false, + "continue_watching": false, + "next_up": false + }, + "active_reasons": { + "favorite": true, + "watchlist": false, + "continue_watching": false, + "next_up": false + }, + "notifies": false, + "muted": true + } + ], + "page": { + "has_more": false + } +} diff --git a/contracts/api/v2/fixtures/notification_mute_notification_series_not_found.json b/contracts/api/v2/fixtures/notification_mute_notification_series_not_found.json new file mode 100644 index 0000000000..00b31098ca --- /dev/null +++ b/contracts/api/v2/fixtures/notification_mute_notification_series_not_found.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/not_found", + "title": "Not found", + "status": 404, + "detail": "Series not found.", + "instance": "urn:silo:request:00000000000000000000030d" +} diff --git a/contracts/api/v2/fixtures/playback_installation_changed.json b/contracts/api/v2/fixtures/playback_installation_changed.json index 77cc2ac31f..057dee261a 100644 --- a/contracts/api/v2/fixtures/playback_installation_changed.json +++ b/contracts/api/v2/fixtures/playback_installation_changed.json @@ -3,5 +3,5 @@ "title": "Playback installation changed", "status": 409, "detail": "Playback installation changed; refresh capabilities", - "instance": "urn:silo:request:00000000000000000000027e" + "instance": "urn:silo:request:00000000000000000000027f" } diff --git a/contracts/api/v2/fixtures/playback_invalid_protocol.json b/contracts/api/v2/fixtures/playback_invalid_protocol.json index 5827502baf..48529a40e1 100644 --- a/contracts/api/v2/fixtures/playback_invalid_protocol.json +++ b/contracts/api/v2/fixtures/playback_invalid_protocol.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:00000000000000000000027f", + "instance": "urn:silo:request:000000000000000000000280", "errors": [ { "location": "body", diff --git a/contracts/api/v2/fixtures/playback_progress_conflict.json b/contracts/api/v2/fixtures/playback_progress_conflict.json index bba69d291f..31180ce894 100644 --- a/contracts/api/v2/fixtures/playback_progress_conflict.json +++ b/contracts/api/v2/fixtures/playback_progress_conflict.json @@ -3,5 +3,5 @@ "title": "Playback progress conflict", "status": 409, "detail": "The sequence already has different progress", - "instance": "urn:silo:request:00000000000000000000027d" + "instance": "urn:silo:request:00000000000000000000027e" } diff --git a/contracts/api/v2/fixtures/playback_replan_reanchored.json b/contracts/api/v2/fixtures/playback_replan_reanchored.json index beea15a658..e3a31b1415 100644 --- a/contracts/api/v2/fixtures/playback_replan_reanchored.json +++ b/contracts/api/v2/fixtures/playback_replan_reanchored.json @@ -146,15 +146,7 @@ "transformations": [], "applied_quirks": [], "runtime_corrections": [], - "available_qualities": [ - { - "label": "original", - "display_name": "Original", - "height": 1080, - "bitrate_kbps": 8000, - "preserves_source": true - } - ], + "available_qualities": [], "degradation_warnings": [], "decision_reason": "validated_original_playback", "requested_media_file_id": "42", diff --git a/contracts/api/v2/fixtures/playback_start_opaque_ids.json b/contracts/api/v2/fixtures/playback_start_opaque_ids.json index beea15a658..e3a31b1415 100644 --- a/contracts/api/v2/fixtures/playback_start_opaque_ids.json +++ b/contracts/api/v2/fixtures/playback_start_opaque_ids.json @@ -146,15 +146,7 @@ "transformations": [], "applied_quirks": [], "runtime_corrections": [], - "available_qualities": [ - { - "label": "original", - "display_name": "Original", - "height": 1080, - "bitrate_kbps": 8000, - "preserves_source": true - } - ], + "available_qualities": [], "degradation_warnings": [], "decision_reason": "validated_original_playback", "requested_media_file_id": "42", diff --git a/contracts/api/v2/fixtures/refresh_session_provider_unavailable.json b/contracts/api/v2/fixtures/refresh_session_provider_unavailable.json index d93338a5af..e5a41aec38 100644 --- a/contracts/api/v2/fixtures/refresh_session_provider_unavailable.json +++ b/contracts/api/v2/fixtures/refresh_session_provider_unavailable.json @@ -3,5 +3,5 @@ "title": "Provider unavailable", "status": 503, "detail": "The sign-in provider could not confirm the account. Try again later; the session stays valid.", - "instance": "urn:silo:request:0000000000000000000002c9" + "instance": "urn:silo:request:0000000000000000000002ca" } diff --git a/contracts/api/v2/fixtures/regenerate_admin_item_trickplay_disabled.json b/contracts/api/v2/fixtures/regenerate_admin_item_trickplay_disabled.json index ae3d149d4c..5adccfc74c 100644 --- a/contracts/api/v2/fixtures/regenerate_admin_item_trickplay_disabled.json +++ b/contracts/api/v2/fixtures/regenerate_admin_item_trickplay_disabled.json @@ -3,5 +3,5 @@ "title": "Capability disabled", "status": 409, "detail": "Seek-bar previews are turned off for this item's library.", - "instance": "urn:silo:request:0000000000000000000002b1" + "instance": "urn:silo:request:0000000000000000000002b2" } diff --git a/contracts/api/v2/fixtures/server_connections_ok.json b/contracts/api/v2/fixtures/server_connections_ok.json index dbb21bb184..f09b722140 100644 --- a/contracts/api/v2/fixtures/server_connections_ok.json +++ b/contracts/api/v2/fixtures/server_connections_ok.json @@ -1,5 +1,5 @@ { - "revision": "82278f19ff5dc619091bee7b0efe5f375a0b25ec07002a424a5d15efbdc66900", + "revision": "70a5e9f04966172f3401c59c9e389d3b05227e84a511077a349f92fb2765be94", "state": "available", "allowed": true, "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35", @@ -24,5 +24,9 @@ "display_name": "Stub Overlay", "state": "connected" } - ] + ], + "local_route": { + "spki_sha256": "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=", + "url": "http://prairie.lan.example.test:8080" + } } diff --git a/contracts/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json b/contracts/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json index a881c30a64..31c160dc98 100644 --- a/contracts/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json +++ b/contracts/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json @@ -3,5 +3,5 @@ "title": "Network identity required", "status": 403, "detail": "Open this server through the provider's network address to sign in this way", - "instance": "urn:silo:request:0000000000000000000002d2" + "instance": "urn:silo:request:0000000000000000000002d3" } diff --git a/contracts/api/v2/fixtures/start_account_identity_link_unknown_ticket.json b/contracts/api/v2/fixtures/start_account_identity_link_unknown_ticket.json index b197300e8d..b2aff8c2a4 100644 --- a/contracts/api/v2/fixtures/start_account_identity_link_unknown_ticket.json +++ b/contracts/api/v2/fixtures/start_account_identity_link_unknown_ticket.json @@ -3,5 +3,5 @@ "title": "Not found", "status": 404, "detail": "The link ticket is unknown, used or expired.", - "instance": "urn:silo:request:0000000000000000000002c5" + "instance": "urn:silo:request:0000000000000000000002c6" } diff --git a/contracts/api/v2/fixtures/start_device_login_temporary_null.json b/contracts/api/v2/fixtures/start_device_login_temporary_null.json index 9e0591b402..7f44ef1ec7 100644 --- a/contracts/api/v2/fixtures/start_device_login_temporary_null.json +++ b/contracts/api/v2/fixtures/start_device_login_temporary_null.json @@ -3,7 +3,7 @@ "title": "Validation failed", "status": 422, "detail": "The request did not pass validation; see errors.", - "instance": "urn:silo:request:00000000000000000000028b", + "instance": "urn:silo:request:00000000000000000000028c", "errors": [ { "location": "body.temporary", diff --git a/contracts/api/v2/fixtures/theme_songs_playback_not_acceptable.json b/contracts/api/v2/fixtures/theme_songs_playback_not_acceptable.json index 5eecc7a519..9bcf734ed5 100644 --- a/contracts/api/v2/fixtures/theme_songs_playback_not_acceptable.json +++ b/contracts/api/v2/fixtures/theme_songs_playback_not_acceptable.json @@ -3,5 +3,5 @@ "title": "Not acceptable", "status": 406, "detail": "The client decodes neither this theme's format nor its AAC conversion.", - "instance": "urn:silo:request:000000000000000000000298" + "instance": "urn:silo:request:000000000000000000000299" } diff --git a/contracts/api/v2/fixtures/theme_songs_playback_unauthorized.json b/contracts/api/v2/fixtures/theme_songs_playback_unauthorized.json index 72f32f4bea..090a8f67ee 100644 --- a/contracts/api/v2/fixtures/theme_songs_playback_unauthorized.json +++ b/contracts/api/v2/fixtures/theme_songs_playback_unauthorized.json @@ -3,5 +3,5 @@ "title": "Authentication required", "status": 401, "detail": "Authentication is required.", - "instance": "urn:silo:request:000000000000000000000299" + "instance": "urn:silo:request:00000000000000000000029a" } diff --git a/contracts/api/v2/fixtures/token_refresh_required.json b/contracts/api/v2/fixtures/token_refresh_required.json index a812957ddb..d321e40366 100644 --- a/contracts/api/v2/fixtures/token_refresh_required.json +++ b/contracts/api/v2/fixtures/token_refresh_required.json @@ -3,5 +3,5 @@ "title": "Token refresh required", "status": 401, "detail": "The account's role changed; refresh the session and retry the request.", - "instance": "urn:silo:request:0000000000000000000002ac" + "instance": "urn:silo:request:0000000000000000000002ad" } diff --git a/contracts/api/v2/fixtures/unmark_not_interested_locked_profile.json b/contracts/api/v2/fixtures/unmark_not_interested_locked_profile.json new file mode 100644 index 0000000000..29a6c390be --- /dev/null +++ b/contracts/api/v2/fixtures/unmark_not_interested_locked_profile.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/profile_verification_required", + "title": "Profile verification required", + "status": 403, + "detail": "The declared profile is locked; verify it and retry with X-Profile-Token.", + "instance": "urn:silo:request:000000000000000000000309" +} diff --git a/contracts/api/v2/migration.json b/contracts/api/v2/migration.json index 8f3982829e..5e9d1e205c 100644 --- a/contracts/api/v2/migration.json +++ b/contracts/api/v2/migration.json @@ -4051,19 +4051,17 @@ "section": "admin-collections", "release_flow": "other_admin", "tier": 2, - "disposition": "ported", - "disposition_rule": "default_ported", - "disposition_rationale": "No exclusion, removal, or ratified redesign applies; port to v2 in its section PR.", - "owner": null, + "disposition": "removed", + "disposition_rule": "v1_scope_removals_table", + "disposition_rationale": "Listed in docs/architecture/v1-scope.md removals (\"Trakt on every v1 route\"); still registered on the bridge surface, answering 410 unsupported_source, retired by the tombstone. No successor: Prairie no longer supports Trakt.", + "owner": "Quick104", "review_state": "ratified", "v2": { - "method": "POST", - "path": "/api/v2/admin/collections/import/trakt", - "operation_id": "importAdminTrakt" + "method": null, + "path": null, + "operation_id": null }, - "notes": "Administrator collection v2 operation uses shared services and typed string IDs. Bundled web consumes v2; legacy routes remain available. No existing Apple or Android administrator collection consumer was found. Jellyfin has no administrator editing surface.", - "retry_safety": "non_retryable", - "retry_safety_note": "No durable request identity is provided. Delayed replay can repeat side effects or overwrite a later action; clients must not retry automatically." + "notes": "Section admin-collections. The v2 importAdminTrakt operation was removed before the v2 lock when Prairie dropped Trakt support; it had refused every request with 410 unsupported_source since Trakt collection creation was turned off. No bundled web, native or Jellyfin consumer calls it. The frozen v1 route keeps answering 410 unsupported_source until the /api/v1 tombstone retires it. Removal ratified 2026-10-16 (owner decision, Quick104)." }, { "listener": "api", @@ -22756,20 +22754,18 @@ ], "section": "personal-collections", "release_flow": "browse_search", - "tier": 1, - "disposition": "ported", - "disposition_rule": "default_ported", - "disposition_rationale": "No exclusion, removal, or ratified redesign applies; port to v2 in its section PR.", - "owner": null, + "tier": 2, + "disposition": "removed", + "disposition_rule": "v1_scope_removals_table", + "disposition_rationale": "Listed in docs/architecture/v1-scope.md removals (\"Trakt on every v1 route\"); still registered on the bridge surface, answering 410 unsupported_source, retired by the tombstone. No successor: Prairie no longer supports Trakt.", + "owner": "Quick104", "review_state": "ratified", "v2": { - "method": "POST", - "path": "/api/v2/collections/import/trakt", - "operation_id": "importTraktCollection" + "method": null, + "path": null, + "operation_id": null }, - "notes": "Personal collection v2 operation uses the shared profile-scoped service. Storage capabilities distinguish unsupported SQLite imports, groups, artwork and reorder. Native Apple and Android adoption remains follow-up; bundled web consumes v2.", - "retry_safety": "non_retryable", - "retry_safety_note": "No durable request identity or mutation generation is implemented. Delayed replay can repeat side effects or overwrite a later action; clients must not retry automatically." + "notes": "Section personal-collections. The v2 importTraktCollection operation was removed before the v2 lock when Prairie dropped Trakt support; it had refused every request with 410 unsupported_source since Trakt collection creation was turned off. No bundled web, native or Jellyfin consumer calls it. The frozen v1 route keeps answering 410 unsupported_source until the /api/v1 tombstone retires it. Removal ratified 2026-10-16 (owner decision, Quick104)." }, { "listener": "api", @@ -26658,8 +26654,8 @@ "operation_id": "deleteFavorite" }, "notes": "Section personal-lists (profile_scoped). Not demo-restricted: v1's demo guard only blocks its listed routes, so this write passes in demo mode and v2 matches. Media kind resolved from the handler: no request body (request kind none) and a bodiless 204 answer; the inventory's unknown/json is heuristic. Naturally idempotent remove: 204 whether or not the entry existed, with no access check so a profile can drop an item that left its scope. Conditionally registered: authMiddleware != nil; personalDataHandler != nil && itemsHandler != nil. v2 registers the operation unconditionally and reports capability state instead.", - "retry_safety": "non_retryable", - "retry_safety_note": "DEFECT: the shared v1/v2 seam dispatches watch-provider list events and recommendation refresh even when membership did not change. Automatic retries remain disabled until the personal-lists section dispatches only for changed rows through a durable claim." + "retry_safety": "natural_idempotent", + "retry_safety_note": "Provider list, recommendation refresh and user-state events fire only when the write changed the row, detached from the request, so a retry repeats none. A crash between write and events loses them: scheduled export resends a missed add; a missed remove waits on a scheduled-sync fix." }, { "listener": "api", @@ -26798,8 +26794,8 @@ "operation_id": "addFavorite" }, "notes": "Section personal-lists (profile_scoped). Not demo-restricted: v1's demo guard only blocks its listed routes, so this write passes in demo mode and v2 matches. Media kind resolved from the handler: no request body (request kind none) and a bodiless 204 answer; the inventory's unknown/json is heuristic. Naturally idempotent add: a repeated PUT converges on the item being a favorite. An item the viewer may not see is 404. Conditionally registered: authMiddleware != nil; personalDataHandler != nil && itemsHandler != nil. v2 registers the operation unconditionally and reports capability state instead.", - "retry_safety": "non_retryable", - "retry_safety_note": "DEFECT: the shared v1/v2 seam dispatches watch-provider list events and recommendation refresh even when membership did not change. Automatic retries remain disabled until the personal-lists section dispatches only for changed rows through a durable claim." + "retry_safety": "natural_idempotent", + "retry_safety_note": "Provider list, recommendation refresh and user-state events fire only when the write changed the row, detached from the request, so a retry repeats none. A crash between write and events loses them: scheduled export resends a missed add; a missed remove waits on a scheduled-sync fix." }, { "listener": "api", @@ -43278,18 +43274,18 @@ ], "section": "settings-branding", "release_flow": "browse_search", - "tier": 1, - "disposition": "ported", - "disposition_rule": "default_ported", - "disposition_rationale": "No exclusion, removal, or ratified redesign applies; port to v2 in its section PR.", - "owner": null, + "tier": 2, + "disposition": "removed", + "disposition_rule": "v1_scope_removals_table", + "disposition_rationale": "Listed in docs/architecture/v1-scope.md removals (\"The admin theme overrides route\"); still registered on the bridge surface, retired by the tombstone. No successor: the web client applies only the branding accent color, with no admin token, font or CSS overrides.", + "owner": "Quick104", "review_state": "ratified", "v2": { - "method": "GET", - "path": "/api/v2/theme/admin-css", - "operation_id": "getThemeOverrides" + "method": null, + "path": null, + "operation_id": null }, - "notes": "Implemented public branding discovery/overrides and image GET/HEAD on reviewed raw foundation. Asset URLs target v2, exact content-version requests cache immutably, stale versions return404, unversioned reads revalidate. Security headers and conditional tags preserved. Bundled web uses v2 discovery/overrides. Capability GET /api/v2/theme/capabilities. Frozen v1 transport unchanged. Independently reviewed and accepted with coordinated Apple and Android public branding consumers; no native CSS caller." + "notes": "Section settings-branding. The v2 getThemeOverrides operation and the overrides_available capability field were removed before the v2 lock when the admin token and font overrides and custom CSS were removed from the web client; the accent color stays on getBranding and the web client applies it from there. The bundled web client no longer reads the overrides; no native or Jellyfin consumer exists. The frozen v1 route keeps reporting the stored ui.admin_theme_vars and ui.admin_custom_css values until the /api/v1 tombstone retires it. Removal ratified 2026-10-13 (owner decision, Quick104)." }, { "listener": "api", @@ -45445,8 +45441,8 @@ "operation_id": "deleteWatchlistEntry" }, "notes": "Section personal-lists (profile_scoped). Not demo-restricted: v1's demo guard only blocks its listed routes, so this write passes in demo mode and v2 matches. Media kind resolved from the handler: no request body (request kind none) and a bodiless 204 answer; the inventory's unknown/json is heuristic. Naturally idempotent remove: 204 whether or not the entry existed, with no access check so a profile can drop an item that left its scope. Conditionally registered: authMiddleware != nil; personalDataHandler != nil && itemsHandler != nil. v2 registers the operation unconditionally and reports capability state instead.", - "retry_safety": "non_retryable", - "retry_safety_note": "DEFECT: the shared v1/v2 seam dispatches watch-provider list events and recommendation refresh even when membership did not change. Automatic retries remain disabled until the personal-lists section dispatches only for changed rows through a durable claim." + "retry_safety": "natural_idempotent", + "retry_safety_note": "Provider list, recommendation refresh and user-state events fire only when the write changed the row, detached from the request, so a retry repeats none. A crash between write and events loses them: scheduled export resends a missed add; a missed remove waits on a scheduled-sync fix." }, { "listener": "api", @@ -45585,8 +45581,8 @@ "operation_id": "addToWatchlist" }, "notes": "Section personal-lists (profile_scoped). Not demo-restricted: v1's demo guard only blocks its listed routes, so this write passes in demo mode and v2 matches. Media kind resolved from the handler: no request body (request kind none) and a bodiless 204 answer; the inventory's unknown/json is heuristic. Naturally idempotent add: a repeated PUT converges on the item being on the watchlist. An item the viewer may not see is 404. Conditionally registered: authMiddleware != nil; personalDataHandler != nil && itemsHandler != nil. v2 registers the operation unconditionally and reports capability state instead.", - "retry_safety": "non_retryable", - "retry_safety_note": "DEFECT: the shared v1/v2 seam dispatches watch-provider list events and recommendation refresh even when membership did not change. Automatic retries remain disabled until the personal-lists section dispatches only for changed rows through a durable claim." + "retry_safety": "natural_idempotent", + "retry_safety_note": "Provider list, recommendation refresh and user-state events fire only when the write changed the row, detached from the request, so a retry repeats none. A crash between write and events loses them: scheduled export resends a missed add; a missed remove waits on a scheduled-sync fix." }, { "listener": "api", diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 70fbc7f18f..c6969e1663 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -864,6 +864,12 @@ "max_playback_quality": { "type": "string" }, + "max_profiles": { + "description": "Household profiles each member account may have, unless the account overrides it", + "format": "int64", + "minimum": 1, + "type": "integer" + }, "max_remote_stream_bitrate_kbps": { "description": "Remote per-stream bitrate ceiling in kbps; 0 means unlimited", "format": "int64", @@ -909,6 +915,7 @@ "max_transcodes", "max_remote_stream_bitrate_kbps", "max_local_stream_bitrate_kbps", + "max_profiles", "allowed_permissions", "requests_allowed", "is_default", @@ -966,6 +973,12 @@ "max_playback_quality": { "type": "string" }, + "max_profiles": { + "description": "Household profiles each member account may have; 5 when a new group omits it", + "format": "int64", + "minimum": 1, + "type": "integer" + }, "max_remote_stream_bitrate_kbps": { "format": "int64", "minimum": 0, @@ -1059,6 +1072,12 @@ "max_playback_quality": { "type": "string" }, + "max_profiles": { + "description": "Household profiles each member account may have, unless the account overrides it", + "format": "int64", + "minimum": 1, + "type": "integer" + }, "max_remote_stream_bitrate_kbps": { "description": "Remote per-stream bitrate ceiling in kbps; 0 means unlimited", "format": "int64", @@ -1109,6 +1128,7 @@ "max_transcodes", "max_remote_stream_bitrate_kbps", "max_local_stream_bitrate_kbps", + "max_profiles", "allowed_permissions", "requests_allowed", "is_default", @@ -1167,6 +1187,10 @@ "description": "Whether getAdminUserPolicyDefaults reports the policy an admin or a regular account with no access group uses for fields it does not override", "type": "boolean" }, + "profile_limits": { + "description": "Whether access groups and account policy overrides take max_profiles, the household profile limit; null on an account inherits its group's limit", + "type": "boolean" + }, "profile_sections": { "description": "Whether listAdminUserProfileSectionOverrides, getAdminUserProfileSectionSettings, replaceAdminUserProfileSectionOverrides and resetAdminUserProfileSectionOverrides can read and change one profile's page layout", "type": "boolean" @@ -1200,6 +1224,7 @@ "default_profile", "exact_identity_filter", "access_groups", + "profile_limits", "password_reset_link", "password_reset_email", "ownership_transfer", @@ -1289,9 +1314,13 @@ ] }, "max_profiles": { + "description": "Household profile limit override; null inherits the access group's limit", "format": "int64", "minimum": 1, - "type": "integer" + "type": [ + "integer", + "null" + ] }, "max_remote_stream_bitrate_kbps": { "format": "int64", @@ -1517,9 +1546,13 @@ ] }, "max_profiles": { + "description": "Household profile limit override; null inherits the access group's limit", "format": "int64", "minimum": 1, - "type": "integer" + "type": [ + "integer", + "null" + ] }, "max_remote_stream_bitrate_kbps": { "format": "int64", @@ -4348,6 +4381,9 @@ "downloads_stats": { "type": "boolean" }, + "egress_totals": { + "type": "boolean" + }, "health": { "type": "boolean" }, @@ -4393,6 +4429,7 @@ "log_level_list", "watch_providers", "downloads_stats", + "egress_totals", "revision", "state", "allowed" @@ -4661,6 +4698,14 @@ "AdminDashboardTimeseries": { "additionalProperties": false, "properties": { + "download_egress_bytes": { + "format": "int64", + "type": "integer" + }, + "egress_bytes": { + "format": "int64", + "type": "integer" + }, "from": { "description": "RFC 3339 instant in UTC with millisecond precision", "format": "date-time", @@ -4701,6 +4746,8 @@ "from", "to", "oldest_sample_at", + "egress_bytes", + "download_egress_bytes", "points" ], "type": "object" @@ -10803,6 +10850,14 @@ "minLength": 1, "type": "string" }, + "delivery_id": { + "description": "The target profile's newest notification, which the test push carried so the device shows its real text; absent when the profile has no notification and the device can show only generic text.", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + }, "failure_message": { "type": "string" }, @@ -12139,6 +12194,10 @@ ], "type": "string" }, + "request_router": { + "$ref": "#/components/schemas/AdminPluginRequestRouter", + "description": "On a request_router.v1 capability: which request server fields its plugin can do without. Omitted for other capability types" + }, "sign_in_mode": { "description": "How an auth_provider.v1 capability signs people in: oauth (a provider button and browser handshake, such as OIDC), credentials (a username and password form, such as LDAP) or network (a network access plugin, such as Tailscale, that signs in the owner of the device a request came from; one network binding may be on beside the one oauth or credentials binding). Omitted for other capability types", "enum": [ @@ -12958,6 +13017,24 @@ }, "type": "object" }, + "AdminPluginRequestRouter": { + "additionalProperties": false, + "properties": { + "api_key_optional": { + "description": "The plugin works without an API key: a request server for it can be saved and tested without one, and gets requests without one", + "type": "boolean" + }, + "base_url_optional": { + "description": "The plugin works without a base URL: a request server for it can be saved and tested without one", + "type": "boolean" + } + }, + "required": [ + "api_key_optional", + "base_url_optional" + ], + "type": "object" + }, "AdminPluginRuntime": { "additionalProperties": false, "properties": { @@ -12989,7 +13066,7 @@ "type": "integer" }, "state": { - "description": "Process state; backoff and failed occur only for resident plugins", + "description": "Process state; starting and backoff occur only for resident plugins, failed also marks a non-resident plugin whose last launch failed", "enum": [ "stopped", "starting", @@ -14017,6 +14094,80 @@ ], "type": "object" }, + "AdminRecommendationEmbeddingsReset": { + "additionalProperties": false, + "properties": { + "cached_rows": { + "description": "Per-profile cached recommendation rows deleted. Global rows are kept.", + "format": "int64", + "type": "integer" + }, + "embeddings": { + "description": "Item embeddings deleted.", + "format": "int64", + "type": "integer" + }, + "taste_clusters": { + "description": "Taste clusters deleted.", + "format": "int64", + "type": "integer" + }, + "taste_profiles": { + "description": "Taste profiles whose vectors were cleared. Each is rebuilt once the embedding job stores its titles' vectors again.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "embeddings", + "taste_profiles", + "taste_clusters", + "cached_rows" + ], + "type": "object" + }, + "AdminRecommendationJobRun": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "RFC 3339 instant in UTC with millisecond precision", + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, + "error": { + "description": "Why the run failed, with credentials masked. Present only when status is failed.", + "type": "string" + }, + "result": { + "additionalProperties": {}, + "description": "Counts the run reported. Keys differ by job and may grow.", + "type": "object" + }, + "started_at": { + "description": "RFC 3339 instant in UTC with millisecond precision", + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, + "status": { + "description": "completed also covers runs that finished with partial failures; result counts them.", + "enum": [ + "completed", + "failed" + ], + "type": "string" + } + }, + "required": [ + "status", + "started_at", + "completed_at" + ], + "type": "object" + }, "AdminRecommendationJobStatus": { "additionalProperties": false, "properties": { @@ -14024,6 +14175,14 @@ "format": "int64", "type": "integer" }, + "last_partial_run": { + "$ref": "#/components/schemas/AdminRecommendationJobRun", + "description": "The newest finished partial run on any server: the embedding catch-up pass for embeddings, the startup global-row build for recommendations. Absent for other jobs and until one finishes." + }, + "last_run": { + "$ref": "#/components/schemas/AdminRecommendationJobRun", + "description": "The newest finished full run of this job on any server: scheduled or triggered. Absent until one finishes." + }, "running": { "type": "boolean" }, @@ -14038,6 +14197,24 @@ ], "type": "object" }, + "AdminRecommendationStaleSweep": { + "additionalProperties": false, + "properties": { + "last_run": { + "$ref": "#/components/schemas/AdminRecommendationJobRun", + "description": "The newest sweep on any server that rebuilt or failed at least one profile. Absent until one does." + }, + "pending": { + "description": "Profiles whose recommendations wait for a rebuild. The sweep rebuilds up to 50 every 5 minutes; a profile whose rebuild fails stays pending.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "pending" + ], + "type": "object" + }, "AdminRecommendationStarted": { "additionalProperties": false, "properties": { @@ -14057,15 +14234,30 @@ "AdminRecommendationsStatus": { "additionalProperties": false, "properties": { + "cache_refreshed_at": { + "description": "When the newest cached recommendation row was written, by the cache job or a profile refresh. It is not job health: profile refreshes keep it recent while the cache job fails; read recommendations.last_run for that. Absent when nothing is cached.", + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, "cowatch": { "$ref": "#/components/schemas/AdminRecommendationJobStatus" }, "embeddings": { "$ref": "#/components/schemas/AdminRecommendationJobStatus" }, + "lock_conflict": { + "description": "Why the stored embedding lock rejects the embedding settings this server runs with; empty when it does not.", + "type": "string" + }, "recommendations": { "$ref": "#/components/schemas/AdminRecommendationJobStatus" }, + "stale_sweep": { + "$ref": "#/components/schemas/AdminRecommendationStaleSweep", + "description": "The stale profile sweep, which rebuilds profiles after their signals or access change." + }, "taste_profiles": { "$ref": "#/components/schemas/AdminRecommendationJobStatus" } @@ -14074,7 +14266,9 @@ "embeddings", "taste_profiles", "cowatch", - "recommendations" + "recommendations", + "lock_conflict", + "stale_sweep" ], "type": "object" }, @@ -18804,79 +18998,21 @@ ], "type": "object" }, - "AdminTraktImport": { + "AdminTranslateLibraryInputBody": { "additionalProperties": false, "properties": { - "description": { - "type": "string" - }, - "featured": { - "type": "boolean" - }, - "library_id": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - }, - "library_ids": { - "items": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - }, - "maxItems": 1000, - "type": "array" - }, - "limit": { - "format": "int64", - "type": "integer" - }, - "list_url": { - "type": "string" - }, - "management_key": { - "type": "string" - }, - "management_mode": { - "type": "string" - }, - "management_source": { - "type": "string" - }, - "media_type": { - "type": "string" - }, - "poster_url": { - "type": "string" - }, - "preset": { - "type": "string" - }, - "profile_id": { - "description": "Opaque identifier", + "target_language": { + "description": "BCP 47 tag of the language to fill in", "examples": [ - "1" + "de" ], - "minLength": 1, - "type": "string" - }, - "sort_config": {}, - "sync_schedule": { - "type": "string" - }, - "title": { + "maxLength": 16, "minLength": 1, "type": "string" } }, "required": [ - "title" + "target_language" ], "type": "object" }, @@ -18884,6 +19020,7 @@ "additionalProperties": false, "properties": { "force": { + "description": "Also replace text an earlier AI translation wrote. Provider and manual text is never replaced.", "type": "boolean" }, "include_children": { @@ -19276,12 +19413,16 @@ ] }, "max_profiles": { - "description": "Household profile limit", + "description": "Household profile limit override; null inherits the access group's limit", "examples": [ 5 ], "format": "int64", - "type": "integer" + "minimum": 1, + "type": [ + "integer", + "null" + ] }, "max_remote_stream_bitrate_kbps": { "description": "Remote per-stream bitrate override in kbps; null inherits, 0 means unlimited", @@ -20326,6 +20467,13 @@ ], "minLength": 1, "type": "string" + }, + "server_id": { + "description": "This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable.", + "examples": [ + "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" + ], + "type": "string" } }, "required": [ @@ -20458,6 +20606,13 @@ ], "minLength": 1, "type": "string" + }, + "server_id": { + "description": "This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable.", + "examples": [ + "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" + ], + "type": "string" } }, "required": [ @@ -21250,9 +21405,6 @@ "branding_available": { "type": "boolean" }, - "overrides_available": { - "type": "boolean" - }, "revision": { "description": "Opaque revision of this document", "type": "string" @@ -21273,7 +21425,6 @@ }, "required": [ "branding_available", - "overrides_available", "storage_available", "revision", "state" @@ -21971,6 +22122,17 @@ "description": "Presigned, short-lived", "type": "string" }, + "machine_translated_fields": { + "description": "Localized fields whose text was machine-translated by AI rather than supplied by a metadata provider or a person; absent when none were. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards. A card shows no tagline, so a card's overview label depends on overview alone.", + "items": { + "enum": [ + "overview", + "tagline" + ], + "type": "string" + }, + "type": "array" + }, "manga_chapter_count": { "format": "int64", "type": "integer" @@ -21998,6 +22160,10 @@ "overview": { "type": "string" }, + "pending_translation_language": { + "description": "The language this profile reads here when the descriptions this item's page shows are not available in it yet. That is the profile's metadata language after its per-language exceptions, or, when it sets none, the library's for a library's rows or a detail opened in that library. It is pending for the item's own overview, or for a season its overview or any of its episodes' overviews. Echo it to translateCatalogItemDescription to translate on view. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards; other listings never carry it, so its absence there says nothing about the language.", + "type": "string" + }, "play_content_id": { "description": "The item to play when the card is a series or season; absent when the item plays itself", "type": "string" @@ -22386,6 +22552,17 @@ "description": "Presigned, short-lived", "type": "string" }, + "machine_translated_fields": { + "description": "Localized fields whose text was machine-translated by AI rather than supplied by a metadata provider or a person; absent when none were. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards. A card shows no tagline, so a card's overview label depends on overview alone.", + "items": { + "enum": [ + "overview", + "tagline" + ], + "type": "string" + }, + "type": "array" + }, "manga": { "$ref": "#/components/schemas/MangaDetailExtension" }, @@ -22420,7 +22597,7 @@ "type": "string" }, "pending_translation_language": { - "description": "A translation of the overview is queued for this language", + "description": "The language this profile reads here when the descriptions this item's page shows are not available in it yet. That is the profile's metadata language after its per-language exceptions, or, when it sets none, the library's for a library's rows or a detail opened in that library. It is pending for the item's own overview, or for a season its overview or any of its episodes' overviews. Echo it to translateCatalogItemDescription to translate on view. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards; other listings never carry it, so its absence there says nothing about the language.", "type": "string" }, "play_content_id": { @@ -22826,6 +23003,13 @@ ], "type": "boolean" }, + "not_interested": { + "description": "The profile marked the movie or series Not interested: recommendations leave it out and rank similar titles lower", + "examples": [ + false + ], + "type": "boolean" + }, "played": { "examples": [ false @@ -22836,7 +23020,8 @@ "required": [ "played", "is_favorite", - "in_watchlist" + "in_watchlist", + "not_interested" ], "type": "object" }, @@ -25722,9 +25907,6 @@ }, "tmdb_list": { "$ref": "#/components/schemas/TMDBListSpec" - }, - "trakt": { - "$ref": "#/components/schemas/TraktSpec" } }, "required": [ @@ -26980,10 +27162,18 @@ "minimum": 0, "type": "integer" }, + "custom_name": { + "description": "Name the profile gave the device; show it in place of device_name. Absent when none was given", + "examples": [ + "Living Room" + ], + "type": "string" + }, "device_id": { "type": "string" }, "device_name": { + "description": "Name the device reports for itself", "type": "string" }, "device_platform": { @@ -27043,6 +27233,24 @@ ], "type": "object" }, + "DeviceUpdate": { + "additionalProperties": false, + "properties": { + "custom_name": { + "description": "Name to show for the device in place of the name it reports. Control characters and bidirectional overrides are removed and runs of spaces collapse; a name with no visible character is rejected. null removes it. The reported device_name is kept", + "examples": [ + "Living Room" + ], + "maxLength": 120, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "DiagnosticsCapabilities": { "additionalProperties": false, "properties": { @@ -28912,6 +29120,15 @@ ], "type": "string" }, + "max_profiles": { + "description": "Household profile limit", + "examples": [ + 5 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + }, "max_remote_stream_bitrate_kbps": { "description": "Remote per-stream bitrate limit in kbps; 0 means unlimited", "examples": [ @@ -28973,6 +29190,7 @@ "max_transcodes", "max_remote_stream_bitrate_kbps", "max_local_stream_bitrate_kbps", + "max_profiles", "transcode_allowed", "audio_transcode_allowed", "download_allowed", @@ -29525,12 +29743,33 @@ "imdb_id": { "type": "string" }, + "machine_translated_fields": { + "description": "Localized fields whose text was machine-translated by AI; absent when none were", + "items": { + "enum": [ + "overview" + ], + "type": "string" + }, + "type": "array" + }, "overlay_summary": { "$ref": "#/components/schemas/CatalogItemOverlay" }, "overview": { "type": "string" }, + "pending_translation_language": { + "description": "The language this profile reads here (its metadata language, or the library's when it sets none) when the episode's description is not available in it yet; translating the episode's season fills it", + "type": "string" + }, + "ratings": { + "description": "The episode's IMDb and TMDB ratings, in the shape a title page's ratings use. Absent when the episode has no rating.", + "items": { + "$ref": "#/components/schemas/CatalogRating" + }, + "type": "array" + }, "runtime": { "description": "Minutes", "format": "int64", @@ -30334,6 +30573,30 @@ ], "type": "object" }, + "HLSSubtitleRenditionV3": { + "additionalProperties": false, + "properties": { + "format": { + "type": "string" + }, + "group_id": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "group_id", + "name", + "format", + "mime_type" + ], + "type": "object" + }, "HistoryCard": { "additionalProperties": false, "properties": { @@ -30428,6 +30691,17 @@ "description": "Presigned, short-lived", "type": "string" }, + "machine_translated_fields": { + "description": "Localized fields whose text was machine-translated by AI rather than supplied by a metadata provider or a person; absent when none were. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards. A card shows no tagline, so a card's overview label depends on overview alone.", + "items": { + "enum": [ + "overview", + "tagline" + ], + "type": "string" + }, + "type": "array" + }, "manga_chapter_count": { "format": "int64", "type": "integer" @@ -30455,6 +30729,10 @@ "overview": { "type": "string" }, + "pending_translation_language": { + "description": "The language this profile reads here when the descriptions this item's page shows are not available in it yet. That is the profile's metadata language after its per-language exceptions, or, when it sets none, the library's for a library's rows or a detail opened in that library. It is pending for the item's own overview, or for a season its overview or any of its episodes' overviews. Echo it to translateCatalogItemDescription to translate on view. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards; other listings never carry it, so its absence there says nothing about the language.", + "type": "string" + }, "play_content_id": { "description": "The item to play when the card is a series or season; absent when the item plays itself", "type": "string" @@ -31346,6 +31624,13 @@ }, "type": "array" }, + "slot_width_params": { + "additionalProperties": { + "type": "string" + }, + "description": "Per-slot width query parameters, keyed by slot: poster (poster_url), backdrop (backdrop_url, backdrop_urls), logo (logo_url), still (an episode's still_url), photo (cast and crew photo_url). Each takes the width in device pixels the client draws that field at and overrides param for it; the server returns the narrowest cached size at least that wide, else the original. Accepted wherever param is", + "type": "object" + }, "state": { "description": "Support and configuration state, not health", "enum": [ @@ -31375,6 +31660,7 @@ "extended_item_artwork", "season_list_artwork_param", "param", + "slot_width_params", "sizes", "widths", "original_max_width_px", @@ -31453,6 +31739,10 @@ "format": "int64", "type": "integer" }, + "embeddings_skipped": { + "format": "int64", + "type": "integer" + }, "episodes_created": { "format": "int64", "type": "integer" @@ -31522,6 +31812,7 @@ "links_created", "credits_replaced", "embeddings_imported", + "embeddings_skipped", "skipped" ], "type": "object" @@ -32025,6 +32316,13 @@ "description": "Whether the current principal may use the capability", "type": "boolean" }, + "ignore_unmatched": { + "description": "This build supports marking unmatched items as intentionally unmatched with ignoreUnmatchedItem, and listing them with listUnmatchedItems' ignored filter", + "examples": [ + true + ], + "type": "boolean" + }, "realtime_monitoring": { "description": "This build supports real-time library monitoring and its status read", "examples": [ @@ -32065,6 +32363,7 @@ "realtime_monitoring", "trickplay", "trickplay_supported", + "ignore_unmatched", "revision", "state", "allowed" @@ -35490,7 +35789,7 @@ "type": "boolean" }, "on_view": { - "description": "Viewer-triggered translation mode: off, button (the detail page offers the action), or auto (the server queues it on view)", + "description": "Viewer-triggered translation mode: off, button (the detail page offers the action), or auto (the client requests the translation itself when it shows an item whose pending_translation_language is set)", "examples": [ "button" ], @@ -35778,6 +36077,7 @@ "type": "integer" }, "force": { + "description": "The job also replaces earlier AI text; provider and manual text is never replaced", "type": "boolean" }, "id": { @@ -35810,7 +36110,7 @@ "type": "string" }, "target_kind": { - "description": "item, season, or episode", + "description": "item, season, episode, or library (a prewarm, whose fields_done and fields_total count items)", "type": "string" }, "target_language": { @@ -36239,6 +36539,83 @@ ], "type": "object" }, + "NotInterestedCapability": { + "additionalProperties": false, + "properties": { + "allowed": { + "description": "Whether the current principal may use the capability", + "type": "boolean" + }, + "revision": { + "description": "Opaque revision of this document", + "type": "string" + }, + "state": { + "description": "Support and configuration state, not health", + "enum": [ + "available", + "disabled", + "not_configured", + "unsupported" + ], + "type": "string" + } + }, + "required": [ + "revision", + "state", + "allowed" + ], + "type": "object" + }, + "NotInterestedCollection": { + "additionalProperties": false, + "properties": { + "items": { + "description": "The page's items; empty, never null", + "items": { + "$ref": "#/components/schemas/CatalogItem" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/PageInfo", + "description": "Cursor state; absent for bounded unpaginated collections" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "NotInterestedEntry": { + "additionalProperties": false, + "properties": { + "item_id": { + "description": "The movie or series", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, + "type": "string" + }, + "marked_at": { + "description": "When the profile marked it", + "examples": [ + "2026-01-02T03:04:05.000Z" + ], + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + } + }, + "required": [ + "item_id", + "marked_at" + ], + "type": "object" + }, "NotificationAccountChannelCapability": { "additionalProperties": false, "properties": { @@ -36289,6 +36666,9 @@ "description": "Opaque revision of this document", "type": "string" }, + "series": { + "$ref": "#/components/schemas/NotificationSeriesCapability" + }, "state": { "description": "Support and configuration state, not health", "enum": [ @@ -36314,6 +36694,7 @@ "webhooks", "email", "discord", + "series", "revision", "state", "allowed" @@ -36569,6 +36950,30 @@ ], "type": "object" }, + "NotificationEpisodeRef": { + "additionalProperties": false, + "properties": { + "episode_number": { + "examples": [ + 9 + ], + "format": "int64", + "type": "integer" + }, + "season_number": { + "examples": [ + 2 + ], + "format": "int64", + "type": "integer" + } + }, + "required": [ + "season_number", + "episode_number" + ], + "type": "object" + }, "NotificationInAppCapability": { "additionalProperties": false, "properties": { @@ -36650,7 +37055,10 @@ ] }, "reason_flags": { - "description": "A JSON value whose shape is the setting's value_schema in the settings contract." + "additionalProperties": true, + "description": "Event-specific reason data. Its keys depend on the notification type; clients ignore keys they do not recognize.", + "type": "object", + "x-silo-extension-bag": "notification-reason-flags" }, "season_number": { "format": "int64", @@ -36892,6 +37300,109 @@ ], "type": "object" }, + "NotificationSeries": { + "additionalProperties": false, + "properties": { + "active_reasons": { + "$ref": "#/components/schemas/NotificationSeriesReasons", + "description": "The reasons the profile's notification preferences let notify; all false when they silence the series" + }, + "item": { + "$ref": "#/components/schemas/CatalogItem", + "description": "The series as a catalog card" + }, + "muted": { + "description": "The profile muted the series: it gets no new-episode notification for it, whatever the reasons, until it unmutes. active_reasons still names the reasons that would notify once unmuted.", + "type": "boolean" + }, + "next_up_after": { + "$ref": "#/components/schemas/NotificationEpisodeRef", + "description": "With an active Next Up reason, the last episode the profile finished: on that reason alone, only a new episode after it notifies" + }, + "notifies": { + "description": "True when at least one reason is active and the profile has not muted the series", + "type": "boolean" + }, + "reasons": { + "$ref": "#/components/schemas/NotificationSeriesReasons", + "description": "The profile's relationships to the series" + } + }, + "required": [ + "item", + "reasons", + "active_reasons", + "notifies", + "muted" + ], + "type": "object" + }, + "NotificationSeriesCapability": { + "additionalProperties": false, + "properties": { + "available": { + "description": "GET /notifications/series lists the series whose new episodes can notify the profile", + "type": "boolean" + }, + "mute": { + "description": "PUT and DELETE /notifications/series/{series_id}/mute mute and unmute one series, and list entries carry muted", + "type": "boolean" + } + }, + "required": [ + "available", + "mute" + ], + "type": "object" + }, + "NotificationSeriesCollection": { + "additionalProperties": false, + "properties": { + "items": { + "description": "The page's items; empty, never null", + "items": { + "$ref": "#/components/schemas/NotificationSeries" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/PageInfo", + "description": "Cursor state; absent for bounded unpaginated collections" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "NotificationSeriesReasons": { + "additionalProperties": false, + "properties": { + "continue_watching": { + "description": "The profile is partway through the series", + "type": "boolean" + }, + "favorite": { + "description": "The series is one of the profile's favorites", + "type": "boolean" + }, + "next_up": { + "description": "The profile finished an episode and waits for the next", + "type": "boolean" + }, + "watchlist": { + "description": "The series is on the profile's watchlist", + "type": "boolean" + } + }, + "required": [ + "favorite", + "watchlist", + "continue_watching", + "next_up" + ], + "type": "object" + }, "NotificationServerChannel": { "additionalProperties": false, "properties": { @@ -38426,7 +38937,7 @@ "additionalProperties": false, "properties": { "collection_type": { - "description": "manual, smart, or an import source (mdblist, tmdb, trakt)", + "description": "manual, smart, or an import source (mdblist, tmdb)", "examples": [ "manual" ], @@ -39227,6 +39738,7 @@ "type": "array" }, "available_qualities": { + "description": "Fixed quality rungs a quality_change replan may request, in menu order. The source is not listed: quality_preference auto returns to it. Empty when no rung applies.", "items": { "$ref": "#/components/schemas/AvailableQualityV3" }, @@ -39466,6 +39978,7 @@ "type": "integer" }, "bandwidth_estimate_kbps": { + "description": "Measured throughput to this server, such as a bandwidth probe result. auto sends a source whose bitrate fits 80% of it as-is and lowers only one that does not. Omit it rather than send a guess.", "format": "int64", "type": "integer" }, @@ -39505,6 +40018,8 @@ "type": "array" }, "metered": { + "deprecated": true, + "description": "Deprecated; ignored. A client on a metered link sends its cellular quality as quality_preference and bandwidth_cap_kbps instead.", "type": "boolean" }, "operation": { @@ -39543,6 +40058,7 @@ "type": "integer" }, "quality_preference": { + "description": "auto (the default), a resolution such as 1080p, or a rung label from available_qualities. auto plays the source unless the device cannot, a measured bandwidth_estimate_kbps cannot carry it, or it is above bandwidth_cap_kbps. original is a deprecated alias of auto.", "type": "string" }, "replan_request_id": { @@ -39567,7 +40083,6 @@ "attempt_count", "quality_preference", "position_seconds", - "metered", "selected_tracks", "client_capabilities", "client_playback_context" @@ -40403,6 +40918,7 @@ "type": "integer" }, "bandwidth_estimate_kbps": { + "description": "Measured throughput to this server, such as a bandwidth probe result. auto sends a source whose bitrate fits 80% of it as-is and lowers only one that does not. Omit it rather than send a guess.", "format": "int64", "type": "integer" }, @@ -40435,6 +40951,8 @@ "type": "string" }, "metered": { + "deprecated": true, + "description": "Deprecated; ignored. A client on a metered link sends its cellular quality as quality_preference and bandwidth_cap_kbps instead.", "type": "boolean" }, "playback_attempt_id": { @@ -40460,6 +40978,7 @@ "type": "integer" }, "quality_preference": { + "description": "auto (the default), a resolution such as 1080p, or a rung label from available_qualities. auto plays the source unless the device cannot, a measured bandwidth_estimate_kbps cannot carry it, or it is above bandwidth_cap_kbps. original is a deprecated alias of auto.", "type": "string" }, "start_position": { @@ -40486,7 +41005,6 @@ "playback_attempt_id", "quality_preference", "subtitle_fidelity_preference", - "metered", "client_capabilities", "client_playback_context" ], @@ -41175,6 +41693,15 @@ ], "type": "string" }, + "max_profiles": { + "description": "Household profile limit", + "examples": [ + 5 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + }, "max_remote_stream_bitrate_kbps": { "description": "Remote per-stream bitrate limit in kbps; 0 means unlimited", "examples": [ @@ -41220,6 +41747,7 @@ "max_transcodes", "max_remote_stream_bitrate_kbps", "max_local_stream_bitrate_kbps", + "max_profiles", "transcode_allowed", "audio_transcode_allowed", "download_allowed", @@ -41496,7 +42024,7 @@ "type": "string" }, "quality_preference": { - "description": "Free-form until the vocabulary is ratified (#135). Canonical values today: auto, original, 720p, 1080p, 2160p, 4k; empty when unset", + "description": "Free-form until the vocabulary is ratified (#135). Canonical values today: auto, 720p, 1080p, 2160p, 4k; original is a deprecated alias of auto; empty when unset", "examples": [ "auto" ], @@ -41754,7 +42282,7 @@ "type": "string" }, "quality_preference": { - "description": "Free-form until the vocabulary is ratified (#135); v1 never validated it. Canonical values today: auto, original, 720p, 1080p, 2160p, 4k", + "description": "Free-form until the vocabulary is ratified (#135); v1 never validated it. Canonical values today: auto, 720p, 1080p, 2160p, 4k; original is a deprecated alias of auto", "examples": [ "auto" ], @@ -42089,7 +42617,7 @@ ] }, "quality_preference": { - "description": "Free-form until the vocabulary is ratified (#135); v1 never validated it. Canonical values today: auto, original, 720p, 1080p, 2160p, 4k", + "description": "Free-form until the vocabulary is ratified (#135); v1 never validated it. Canonical values today: auto, 720p, 1080p, 2160p, 4k; original is a deprecated alias of auto", "examples": [ "auto" ], @@ -42238,6 +42766,43 @@ ], "type": "object" }, + "ProgressCapabilities": { + "additionalProperties": false, + "properties": { + "allowed": { + "description": "Whether the current principal may use the capability", + "type": "boolean" + }, + "clear_resume_position": { + "description": "Whether clearResumePosition is served: it clears a movie or episode's resume position and keeps its watched state and watch history", + "examples": [ + true + ], + "type": "boolean" + }, + "revision": { + "description": "Opaque revision of this document", + "type": "string" + }, + "state": { + "description": "Support and configuration state, not health", + "enum": [ + "available", + "disabled", + "not_configured", + "unsupported" + ], + "type": "string" + } + }, + "required": [ + "clear_resume_position", + "revision", + "state", + "allowed" + ], + "type": "object" + }, "ProgressCollection": { "additionalProperties": false, "properties": { @@ -44331,6 +44896,16 @@ "is_specials": { "type": "boolean" }, + "machine_translated_fields": { + "description": "Localized fields whose text was machine-translated by AI; absent when none were", + "items": { + "enum": [ + "overview" + ], + "type": "string" + }, + "type": "array" + }, "overview": { "type": "string" }, @@ -44387,6 +44962,135 @@ ], "type": "object" }, + "SeasonalTheme": { + "additionalProperties": false, + "properties": { + "dates_editable": { + "description": "Whether a row may set its own yearly window for this holiday in theme_dates. False for weekly themes.", + "type": "boolean" + }, + "end": { + "description": "Last day of the default window, MM-DD, inclusive; before start when the window wraps across New Year", + "examples": [ + "10-31" + ], + "type": "string" + }, + "key": { + "description": "Stored in the row's enabled_themes config; stable", + "examples": [ + "halloween" + ], + "type": "string" + }, + "name": { + "examples": [ + "Halloween" + ], + "type": "string" + }, + "start": { + "description": "First day of the default window, MM-DD. Absent when the holiday moves (Easter) or recurs weekly.", + "examples": [ + "10-01" + ], + "type": "string" + }, + "vibes": { + "description": "The first vibe is the default; never empty", + "items": { + "$ref": "#/components/schemas/SeasonalVibe" + }, + "type": "array" + }, + "when": { + "description": "When the holiday is in season by default, for display", + "examples": [ + "October" + ], + "type": "string" + } + }, + "required": [ + "key", + "name", + "when", + "dates_editable", + "vibes" + ], + "type": "object" + }, + "SeasonalThemeCatalog": { + "additionalProperties": false, + "properties": { + "max_custom_holidays": { + "description": "The most custom holidays one row may define", + "examples": [ + 12 + ], + "format": "int64", + "type": "integer" + }, + "themes": { + "description": "In calendar order, for display: dated holidays by their usual start, then the weekly ones", + "items": { + "$ref": "#/components/schemas/SeasonalTheme" + }, + "type": "array" + } + }, + "required": [ + "themes", + "max_custom_holidays" + ], + "type": "object" + }, + "SeasonalVibe": { + "additionalProperties": false, + "properties": { + "description": { + "examples": [ + "Spooky fun for all ages." + ], + "type": "string" + }, + "key": { + "description": "Stored in the row's vibes config; stable", + "examples": [ + "family" + ], + "type": "string" + }, + "list_url": { + "description": "The MDBList list behind the vibe", + "examples": [ + "https://mdblist.com/lists/purple_smurf/halloween-family2" + ], + "type": "string" + }, + "name": { + "examples": [ + "Family Halloween" + ], + "type": "string" + }, + "title": { + "description": "The row's title while this vibe is in season, unless the row sets its own", + "examples": [ + "Family Halloween" + ], + "type": "string" + } + }, + "required": [ + "key", + "name", + "title", + "description", + "list_url" + ], + "type": "object" + }, "Section": { "additionalProperties": false, "properties": { @@ -45103,6 +45807,10 @@ }, "type": "array" }, + "local_route": { + "$ref": "#/components/schemas/ServerLocalRoute", + "description": "How to reach the API server directly on the local network; absent on a server whose API port does not answer TLS." + }, "revision": { "description": "Opaque revision of this document", "type": "string" @@ -45212,6 +45920,29 @@ ], "type": "object" }, + "ServerLocalRoute": { + "additionalProperties": false, + "properties": { + "spki_sha256": { + "description": "Base64 SHA-256 of the SubjectPublicKeyInfo of the certificate the API listener presents over TLS on its own port. A client accepts a local-network address only when TLS there presents this key, and learns it only over an address it already trusts. Derived from SECRET_KEY, so it changes when that key is rotated.", + "examples": [ + "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=" + ], + "type": "string" + }, + "url": { + "description": "server.local_url: scheme://host[:port] of the API server on the local network, when an admin set one. Clients connect with https on the same host and port, whatever the scheme says, and require the pinned key. Absent when unset; clients may still find local addresses through LAN discovery.", + "examples": [ + "http://192.168.1.20:8080" + ], + "type": "string" + } + }, + "required": [ + "spki_sha256" + ], + "type": "object" + }, "SettingConstraint": { "additionalProperties": false, "properties": { @@ -45578,6 +46309,13 @@ ], "type": "boolean" }, + "supports_device_rename": { + "description": "Whether updateDevice can give a device a custom name, which listDevices returns as custom_name", + "examples": [ + true + ], + "type": "boolean" + }, "supports_idempotent_writes": { "description": "Whether repeating a value write converges on the same state", "examples": [ @@ -45596,6 +46334,7 @@ "supports_batched_effective", "supports_idempotent_writes", "supports_atomic_shortcuts", + "supports_device_rename", "revision", "state", "allowed" @@ -46658,6 +47397,9 @@ "embedded": { "$ref": "#/components/schemas/EmbeddedSubtitleV3" }, + "hls_rendition": { + "$ref": "#/components/schemas/HLSSubtitleRenditionV3" + }, "inventory": { "items": { "$ref": "#/components/schemas/SubtitleInventoryItemV3" @@ -48106,22 +48848,6 @@ ], "type": "object" }, - "ThemeOverrides": { - "additionalProperties": false, - "properties": { - "raw_css": { - "type": "string" - }, - "vars": { - "type": "string" - } - }, - "required": [ - "vars", - "raw_css" - ], - "type": "object" - }, "ThemePlayback": { "additionalProperties": false, "properties": { @@ -48467,114 +49193,6 @@ ], "type": "object" }, - "TraktCollectionImport": { - "additionalProperties": false, - "properties": { - "description": { - "examples": [ - "" - ], - "type": "string" - }, - "display_query_definition": { - "description": "Display filter fragment" - }, - "is_shared": { - "description": "Show the collection to every profile on the login; defaults to false", - "examples": [ - false - ], - "type": "boolean" - }, - "library_ids": { - "description": "Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted", - "examples": [ - [ - "1" - ] - ], - "items": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - }, - "type": "array" - }, - "limit": { - "description": "Cap on synced items; the server's own maximum applies when omitted", - "examples": [ - 50 - ], - "format": "int64", - "minimum": 1, - "type": "integer" - }, - "media_type": { - "description": "movie or show; the preset's default when omitted", - "examples": [ - "movie" - ], - "type": "string" - }, - "poster_url": { - "description": "A bundled template poster path or an image URL", - "examples": [ - "" - ], - "type": "string" - }, - "preset": { - "description": "A Trakt preset name", - "examples": [ - "trending" - ], - "minLength": 1, - "type": "string" - }, - "sort_config": { - "description": "Default sort document" - }, - "sync_schedule": { - "description": "Sync cadence name; the server default when omitted", - "examples": [ - "daily" - ], - "type": "string" - }, - "title": { - "description": "The new collection's name; trimmed", - "examples": [ - "Trending movies" - ], - "minLength": 1, - "type": "string" - } - }, - "required": [ - "preset", - "title" - ], - "type": "object" - }, - "TraktSpec": { - "additionalProperties": false, - "properties": { - "media_type": { - "type": "string" - }, - "preset": { - "type": "string" - } - }, - "required": [ - "preset", - "media_type" - ], - "type": "object" - }, "TransformationV3": { "additionalProperties": false, "properties": { @@ -48606,7 +49224,7 @@ "additionalProperties": false, "properties": { "target_language": { - "description": "BCP 47 tag of the wanted language, as the detail document's pending_translation_language reported it", + "description": "BCP 47 tag of the wanted language, exactly as this profile's detail document reported it in pending_translation_language. Any other language, or a target with nothing left to translate, is refused with 422 at body.target_language.", "examples": [ "de" ], @@ -48635,6 +49253,13 @@ ], "type": "string" }, + "ignored_at": { + "description": "When an admin marked the item as intentionally unmatched; absent unless it is ignored", + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, "library_id": { "description": "\"0\" when the item is in no library", "examples": [ @@ -49288,6 +49913,13 @@ "WatchDetail": { "additionalProperties": false, "properties": { + "backdrop_thumbhash": { + "type": "string" + }, + "backdrop_url": { + "description": "The item's backdrop, an episode's series backdrop, or an extra's parent backdrop, sized by image_size", + "type": "string" + }, "content_id": { "examples": [ "movie:heat-1995" @@ -49341,6 +49973,13 @@ }, "type": "array" }, + "poster_thumbhash": { + "type": "string" + }, + "poster_url": { + "description": "The item's poster, an episode's still, or an extra's parent poster, sized by image_size; for system media controls", + "type": "string" + }, "preview": { "$ref": "#/components/schemas/WatchMarker" }, @@ -51464,6 +52103,17 @@ "description": "Presigned, short-lived", "type": "string" }, + "machine_translated_fields": { + "description": "Localized fields whose text was machine-translated by AI rather than supplied by a metadata provider or a person; absent when none were. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards. A card shows no tagline, so a card's overview label depends on overview alone.", + "items": { + "enum": [ + "overview", + "tagline" + ], + "type": "string" + }, + "type": "array" + }, "manga_chapter_count": { "format": "int64", "type": "integer" @@ -51491,6 +52141,10 @@ "overview": { "type": "string" }, + "pending_translation_language": { + "description": "The language this profile reads here when the descriptions this item's page shows are not available in it yet. That is the profile's metadata language after its per-language exceptions, or, when it sets none, the library's for a library's rows or a detail opened in that library. It is pending for the item's own overview, or for a season its overview or any of its episodes' overviews. Echo it to translateCatalogItemDescription to translate on view. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards; other listings never carry it, so its absence there says nothing about the language.", + "type": "string" + }, "play_content_id": { "description": "The item to play when the card is a series or season; absent when the item plays itself", "type": "string" @@ -51808,6 +52462,17 @@ "description": "Presigned, short-lived", "type": "string" }, + "machine_translated_fields": { + "description": "Localized fields whose text was machine-translated by AI rather than supplied by a metadata provider or a person; absent when none were. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards. A card shows no tagline, so a card's overview label depends on overview alone.", + "items": { + "enum": [ + "overview", + "tagline" + ], + "type": "string" + }, + "type": "array" + }, "manga_chapter_count": { "format": "int64", "type": "integer" @@ -51835,6 +52500,10 @@ "overview": { "type": "string" }, + "pending_translation_language": { + "description": "The language this profile reads here when the descriptions this item's page shows are not available in it yet. That is the profile's metadata language after its per-language exceptions, or, when it sets none, the library's for a library's rows or a detail opened in that library. It is pending for the item's own overview, or for a season its overview or any of its episodes' overviews. Echo it to translateCatalogItemDescription to translate on view. Set only on item details, the cards built from them (shuffles, the Watch Together picker), and Home and library section cards; other listings never carry it, so its absence there says nothing about the language.", + "type": "string" + }, "play_content_id": { "description": "The item to play when the card is a series or season; absent when the item plays itself", "type": "string" @@ -65602,10 +66271,203 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/collections/import/trakt": { - "post": { - "operationId": "importAdminTrakt", + "/api/v2/admin/collections/order": { + "get": { + "operationId": "getAdminCollectionOrder", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Opaque identifier", + "explode": false, + "in": "query", + "name": "library_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Opaque identifier", + "explode": false, + "in": "query", + "name": "group_id", + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCollectionOrder" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read complete manual ordering for one library and group.", + "tags": [ + "admin-collections" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + }, + "put": { + "operationId": "reorderAdminCollections", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -65633,24 +66495,25 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminTraktImport" + "$ref": "#/components/schemas/AdminCollectionOrderBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminCollectionImportResult" + "$ref": "#/components/schemas/AdminCollectionOrder" } } }, - "description": "Created", + "description": "OK", "headers": { - "Location": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { "type": "string" } @@ -65717,6 +66580,24 @@ }, "description": "Request Timeout" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "413": { "content": { "application/problem+json": { @@ -65747,6 +66628,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -65783,19 +66674,20 @@ "bearerAuth": [] } ], - "summary": "Import a Trakt collection.", + "summary": "Replace collection order within a library group.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, + "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/order": { - "get": { - "operationId": "getAdminCollectionOrder", + "/api/v2/admin/collections/preview": { + "post": { + "operationId": "previewAdminCollection", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -65818,54 +66710,28 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "explode": false, - "in": "query", - "name": "library_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "description": "Opaque identifier", - "explode": false, - "in": "query", - "name": "group_id", - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalCollectionPreviewInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminCollectionOrder" + "$ref": "#/components/schemas/AdminCollectionPreviewOutputBody" } } }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -65917,6 +66783,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -65963,33 +66859,20 @@ "bearerAuth": [] } ], - "summary": "Read complete manual ordering for one library and group.", + "summary": "Preview an administrator smart collection query.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, - "put": { - "operationId": "reorderAdminCollections", + } + }, + "/api/v2/admin/collections/template-bundles": { + "get": { + "operationId": "listAdminCollectionTemplateBundles", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -66013,34 +66896,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminCollectionOrderBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminCollectionOrder" + "$ref": "#/components/schemas/BundleCatalog" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -66092,54 +66957,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -66150,16 +66967,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -66196,20 +67003,17 @@ "bearerAuth": [] } ], - "summary": "Replace collection order within a library group.", + "summary": "List collection template bundles, each with a summary of its templates.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/preview": { + "/api/v2/admin/collections/template-bundles/{bundle_id}/apply": { "post": { - "operationId": "previewAdminCollection", + "operationId": "applyAdminCollectionTemplateBundle", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -66232,13 +67036,21 @@ ], "type": "string" } + }, + { + "in": "path", + "name": "bundle_id", + "required": true, + "schema": { + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersonalCollectionPreviewInputBody" + "$ref": "#/components/schemas/AdminTemplateApply" } } }, @@ -66249,7 +67061,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminCollectionPreviewOutputBody" + "$ref": "#/components/schemas/AdminTemplateResult" } } }, @@ -66381,19 +67193,19 @@ "bearerAuth": [] } ], - "summary": "Preview an administrator smart collection query.", + "summary": "Preview or synchronously apply a template bundle, preserving featured-section behavior.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/template-bundles": { - "get": { - "operationId": "listAdminCollectionTemplateBundles", + "/api/v2/admin/collections/template-bundles/{bundle_id}/apply-job": { + "post": { + "operationId": "startAdminCollectionTemplateBundleJob", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -66416,18 +67228,49 @@ ], "type": "string" } + }, + { + "in": "path", + "name": "bundle_id", + "required": true, + "schema": { + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminTemplateApply" + } + } + }, + "required": true + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BundleCatalog" + "$ref": "#/components/schemas/AdminJob" } } }, - "description": "OK" + "description": "Accepted", + "headers": { + "Location": { + "schema": { + "description": "URI of the queued job", + "type": "string" + } + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -66479,6 +67322,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -66525,17 +67398,19 @@ "bearerAuth": [] } ], - "summary": "List collection template bundles, each with a summary of its templates.", + "summary": "Queue durable template application; poll its collection-job Location.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/template-bundles/{bundle_id}/apply": { - "post": { - "operationId": "applyAdminCollectionTemplateBundle", + "/api/v2/admin/collections/templates": { + "get": { + "operationId": "listAdminCollectionTemplates", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -66558,32 +67433,14 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "bundle_id", - "required": true, - "schema": { - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminTemplateApply" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminTemplateResult" + "$ref": "#/components/schemas/CollectionTemplateCatalog" } } }, @@ -66639,36 +67496,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -66715,20 +67542,35 @@ "bearerAuth": [] } ], - "summary": "Preview or synchronously apply a template bundle, preserving featured-section behavior.", + "summary": "List the collection templates with an mdblist, tmdb or tmdb_list source.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/template-bundles/{bundle_id}/apply-job": { - "post": { - "operationId": "startAdminCollectionTemplateBundleJob", + "/api/v2/admin/collections/{id}": { + "delete": { + "operationId": "deleteAdminCollection", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -66752,47 +67594,23 @@ } }, { + "description": "Opaque identifier", "in": "path", - "name": "bundle_id", + "name": "id", "required": true, "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminTemplateApply" - } - } - }, - "required": true - }, "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminJob" - } - } - }, - "description": "Accepted", - "headers": { - "Location": { - "schema": { - "description": "URI of the queued job", - "type": "string" - } - }, - "Retry-After": { - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -66844,7 +67662,7 @@ }, "description": "Not Acceptable" }, - "408": { + "412": { "content": { "application/problem+json": { "schema": { @@ -66852,19 +67670,17 @@ } } }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { - "$ref": "#/components/schemas/Problem" + "type": "string" } } - }, - "description": "Request Entity Too Large" + } }, - "415": { + "422": { "content": { "application/problem+json": { "schema": { @@ -66872,9 +67688,9 @@ } } }, - "description": "Unsupported Media Type" + "description": "Unprocessable Entity" }, - "422": { + "428": { "content": { "application/problem+json": { "schema": { @@ -66882,7 +67698,7 @@ } } }, - "description": "Unprocessable Entity" + "description": "Precondition Required" }, "429": { "content": { @@ -66920,19 +67736,18 @@ "bearerAuth": [] } ], - "summary": "Queue durable template application; poll its collection-job Location.", + "summary": "Delete an unused collection using its observed validator.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, + "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/collections/templates": { + }, "get": { - "operationId": "listAdminCollectionTemplates", + "operationId": "getAdminCollection", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -66955,6 +67770,34 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -66962,11 +67805,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionTemplateCatalog" + "$ref": "#/components/schemas/AdminCollection" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -67064,17 +67914,15 @@ "bearerAuth": [] } ], - "summary": "List the collection templates with an mdblist, tmdb or tmdb_list source.", + "summary": "Read the canonical editor and strong validator.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - } - }, - "/api/v2/admin/collections/{id}": { - "delete": { - "operationId": "deleteAdminCollection", + }, + "patch": { + "operationId": "updateAdminCollection", "parameters": [ { "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", @@ -67130,9 +67978,34 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCollectionUpdate" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCollection" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -67184,6 +68057,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "412": { "content": { "application/problem+json": { @@ -67202,6 +68085,26 @@ } } }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -67258,7 +68161,7 @@ "bearerAuth": [] } ], - "summary": "Delete an unused collection using its observed validator.", + "summary": "Update a library collection using its observed validator.", "tags": [ "admin-collections" ], @@ -67267,9 +68170,11 @@ "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "get": { - "operationId": "getAdminCollection", + } + }, + "/api/v2/admin/collections/{id}/backdrop": { + "put": { + "operationId": "uploadAdminCollectionBackdrop", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -67306,22 +68211,26 @@ "minLength": 1, "type": "string" } - }, - { - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], + "requestBody": { + "content": { + "multipart/form-data": { + "encoding": { + "image": { + "contentType": "image/jpeg,image/png,image/webp" + }, + "source_url": { + "contentType": "text/plain" + } + }, + "schema": { + "$ref": "#/components/schemas/AdminCollectionBackdropForm" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -67390,6 +68299,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -67436,33 +68375,20 @@ "bearerAuth": [] } ], - "summary": "Read the canonical editor and strong validator.", + "summary": "Upload bounded backdrop artwork or download its source after definition save.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "patch": { - "operationId": "updateAdminCollection", + } + }, + "/api/v2/admin/collections/{id}/image": { + "delete": { + "operationId": "deleteAdminCollectionImage", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -67498,36 +68424,24 @@ "minLength": 1, "type": "string" } + }, + { + "explode": false, + "in": "query", + "name": "type", + "required": true, + "schema": { + "enum": [ + "poster", + "backdrop" + ], + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminCollectionUpdate" - } - } - }, - "required": true - }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminCollection" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -67579,54 +68493,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -67637,16 +68503,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -67683,20 +68539,19 @@ "bearerAuth": [] } ], - "summary": "Update a library collection using its observed validator.", + "summary": "Clear poster or backdrop artwork.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/{id}/backdrop": { - "put": { - "operationId": "uploadAdminCollectionBackdrop", + "/api/v2/admin/collections/{id}/items": { + "get": { + "operationId": "getAdminCollectionItems", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -67733,43 +68588,43 @@ "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "encoding": { - "image": { - "contentType": "image/jpeg,image/png,image/webp" - }, - "source_url": { - "contentType": "text/plain" - } - }, - "schema": { - "$ref": "#/components/schemas/AdminCollectionBackdropForm" - } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" } }, - "required": true - }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminCollection" + "$ref": "#/components/schemas/CollectionAdminCollectionMember" } } }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -67821,36 +68676,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -67897,19 +68722,17 @@ "bearerAuth": [] } ], - "summary": "Upload bounded backdrop artwork or download its source after definition save.", + "summary": "Page manual membership with durable signed continuation.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/{id}/image": { - "delete": { - "operationId": "deleteAdminCollectionImage", + "/api/v2/admin/collections/{id}/items/order": { + "get": { + "operationId": "getAdminCollectionItemsOrder", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -67948,22 +68771,37 @@ } }, { - "explode": false, - "in": "query", - "name": "type", - "required": true, + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", "schema": { - "enum": [ - "poster", - "backdrop" - ], "type": "string" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCollectionOrder" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -68061,20 +68899,33 @@ "bearerAuth": [] } ], - "summary": "Clear poster or backdrop artwork.", + "summary": "Read at most 200 manual memberships. A partial window cannot be reordered.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/collections/{id}/items": { - "get": { - "operationId": "getAdminCollectionItems", + }, + "put": { + "operationId": "reorderAdminCollectionItems", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -68110,43 +68961,36 @@ "minLength": 1, "type": "string" } - }, - { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCollectionIDsOrder" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminCollectionMember" + "$ref": "#/components/schemas/AdminCollectionOrder" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -68198,6 +69042,54 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -68208,6 +69100,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -68244,17 +69146,20 @@ "bearerAuth": [] } ], - "summary": "Page manual membership with durable signed continuation.", + "summary": "Replace the complete manual membership order.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-guarded": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/{id}/items/order": { - "get": { - "operationId": "getAdminCollectionItemsOrder", + "/api/v2/admin/collections/{id}/items/{item_id}": { + "delete": { + "operationId": "removeAdminCollectionItem", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -68293,37 +69198,23 @@ } }, { - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", + "description": "Opaque identifier", + "in": "path", + "name": "item_id", + "required": true, "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminCollectionOrder" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -68421,33 +69312,18 @@ "bearerAuth": [] } ], - "summary": "Read at most 200 manual memberships. A partial window cannot be reordered.", + "summary": "Remove a manual membership.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true }, "put": { - "operationId": "reorderAdminCollectionItems", + "operationId": "addAdminCollectionItem", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -68483,36 +69359,35 @@ "minLength": 1, "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminCollectionIDsOrder" + "$ref": "#/components/schemas/AdminCollectionMemberInputBody" } } }, "required": true }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminCollectionOrder" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -68574,24 +69449,6 @@ }, "description": "Request Timeout" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "413": { "content": { "application/problem+json": { @@ -68622,16 +69479,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -68668,20 +69515,19 @@ "bearerAuth": [] } ], - "summary": "Replace the complete manual membership order.", + "summary": "Add a catalog item if absent; existing positions are preserved.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/{id}/items/{item_id}": { - "delete": { - "operationId": "removeAdminCollectionItem", + "/api/v2/admin/collections/{id}/poster": { + "put": { + "operationId": "uploadAdminCollectionPoster", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -68718,25 +69564,43 @@ "minLength": 1, "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "item_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "multipart/form-data": { + "encoding": { + "image": { + "contentType": "image/jpeg,image/png,image/webp" + }, + "source_url": { + "contentType": "text/plain" + } + }, + "schema": { + "$ref": "#/components/schemas/AdminCollectionArtworkForm" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminCollection" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -68788,6 +69652,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -68834,7 +69728,7 @@ "bearerAuth": [] } ], - "summary": "Remove a manual membership.", + "summary": "Upload bounded poster artwork or download its source after definition save.", "tags": [ "admin-collections" ], @@ -68842,9 +69736,11 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "addAdminCollectionItem", + } + }, + "/api/v2/admin/collections/{id}/sections": { + "get": { + "operationId": "listAdminCollectionSections", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -68883,33 +69779,30 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "item_id", - "required": true, + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminCollectionMemberInputBody" - } - } - }, - "required": true - }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionAdminCollectionSection" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -68961,36 +69854,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -69037,19 +69900,17 @@ "bearerAuth": [] } ], - "summary": "Add a catalog item if absent; existing positions are preserved.", + "summary": "List the administrator Home and library page rows that show a collection. Rows profiles added themselves are not listed.", "tags": [ "admin-collections" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/collections/{id}/poster": { - "put": { - "operationId": "uploadAdminCollectionPoster", + "/api/v2/admin/collections/{id}/sync": { + "post": { + "operationId": "syncAdminCollection", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -69086,43 +69947,32 @@ "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "encoding": { - "image": { - "contentType": "image/jpeg,image/png,image/webp" - }, - "source_url": { - "contentType": "text/plain" - } - }, - "schema": { - "$ref": "#/components/schemas/AdminCollectionArtworkForm" - } + }, + { + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" } }, - "required": true - }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminCollection" + "$ref": "#/components/schemas/AdminCollectionSyncRun" } } }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -69174,36 +70024,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -69250,7 +70070,7 @@ "bearerAuth": [] } ], - "summary": "Upload bounded poster artwork or download its source after definition save.", + "summary": "Synchronize an imported collection once. Automatic retry is unsafe.", "tags": [ "admin-collections" ], @@ -69260,10 +70080,18 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/collections/{id}/sections": { + "/api/v2/admin/dashboard/capabilities": { "get": { - "operationId": "listAdminCollectionSections", + "operationId": "getAdminDashboardCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -69286,27 +70114,6 @@ "type": "string" } }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "in": "header", "name": "If-None-Match", @@ -69320,11 +70127,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminCollectionSection" + "$ref": "#/components/schemas/AdminDashboardCapabilities" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -69376,6 +70207,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -69422,17 +70271,17 @@ "bearerAuth": [] } ], - "summary": "List the administrator Home and library page rows that show a collection. Rows profiles added themselves are not listed.", + "summary": "Discover dashboard features supported by v2 in this build. Support does not promise dependency readiness; layout storage is not yet available on v2.", "tags": [ - "admin-collections" + "admin-observability" ], "x-silo-class": "acting_admin", - "x-silo-service-backed": true + "x-silo-conditional": true } }, - "/api/v2/admin/collections/{id}/sync": { - "post": { - "operationId": "syncAdminCollection", + "/api/v2/admin/dashboard/layout": { + "delete": { + "operationId": "resetAdminDashboardLayout", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -69455,46 +70304,11 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminCollectionSyncRun" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -69592,19 +70406,17 @@ "bearerAuth": [] } ], - "summary": "Synchronize an imported collection once. Automatic retry is unsafe.", + "summary": "Delete this administrator account's stored layout. Already absent is success; no revision or durable receipt. Clients must not automatically replay across another layout write.", "tags": [ - "admin-collections" + "admin-observability" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/admin/dashboard/capabilities": { + }, "get": { - "operationId": "getAdminDashboardCapabilities", + "operationId": "getAdminDashboardLayout", "parameters": [ { "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", @@ -69649,17 +70461,12 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDashboardCapabilities" + "$ref": "#/components/schemas/AdminDashboardLayout" } } }, "description": "OK", "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, "ETag": { "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { @@ -69793,18 +70600,34 @@ "bearerAuth": [] } ], - "summary": "Discover dashboard features supported by v2 in this build. Support does not promise dependency readiness; layout storage is not yet available on v2.", + "summary": "Read this administrator account's stored layout. Null means retain the local/default arrangement. The validator includes the stored generation, including resets, and is required by layout saves.", "tags": [ "admin-observability" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true - } - }, - "/api/v2/admin/dashboard/layout": { - "delete": { - "operationId": "resetAdminDashboardLayout", + "x-silo-conditional": true, + "x-silo-service-backed": true + }, + "put": { + "operationId": "saveAdminDashboardLayout", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -69828,9 +70651,27 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminDashboardLayoutSaveBody" + } + } + }, + "required": true + }, "responses": { "204": { - "description": "No Content" + "description": "No Content", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -69882,6 +70723,54 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -69892,6 +70781,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -69928,26 +70827,21 @@ "bearerAuth": [] } ], - "summary": "Delete this administrator account's stored layout. Already absent is success; no revision or durable receipt. Clients must not automatically replay across another layout write.", + "summary": "Store this administrator account's client-owned layout object. Requires the original read validator; successful ETag acknowledges this committed write. No automatic replay.", "tags": [ "admin-observability" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, + "x-silo-guarded": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/devices": { "get": { - "operationId": "getAdminDashboardLayout", + "operationId": "listAdminDevices", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -69971,9 +70865,28 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, "type": "string" } } @@ -69983,30 +70896,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDashboardLayout" + "$ref": "#/components/schemas/CollectionAdminDeviceMetadata" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -70058,24 +70952,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -70122,30 +70998,22 @@ "bearerAuth": [] } ], - "summary": "Read this administrator account's stored layout. Null means retain the local/default arrangement. The validator includes the stored generation, including resets, and is required by layout saves.", + "summary": "Read device registration and settings metadata across account stores.", "tags": [ - "admin-observability" + "admin" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, "x-silo-service-backed": true - }, - "put": { - "operationId": "saveAdminDashboardLayout", + } + }, + "/api/v2/admin/devices/capabilities": { + "get": { + "operationId": "getAdminDeviceCapabilities", "parameters": [ { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", "in": "header", "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", "schema": { "type": "string" } @@ -70171,21 +71039,41 @@ ], "type": "string" } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminDashboardLayoutSaveBody" + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminDeviceCapabilitiesOutputBody" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } } } }, - "required": true - }, - "responses": { - "204": { - "description": "No Content", + "304": { + "description": "The representation named by If-None-Match is current; no body.", "headers": { "ETag": { "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", @@ -70245,16 +71133,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "412": { "content": { "application/problem+json": { @@ -70273,26 +71151,6 @@ } } }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -70303,16 +71161,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -70349,20 +71197,18 @@ "bearerAuth": [] } ], - "summary": "Store this administrator account's client-owned layout object. Requires the original read validator; successful ETag acknowledges this committed write. No automatic replay.", + "summary": "Read device registration and settings metadata across account stores.", "tags": [ - "admin-observability" + "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/admin/devices": { + "/api/v2/admin/devices/{user_id}/{device_id}": { "get": { - "operationId": "listAdminDevices", + "operationId": "getAdminDevice", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -70387,28 +71233,26 @@ } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", + "description": "Opaque identifier", + "in": "path", + "name": "user_id", + "required": true, "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", + "description": "Opaque identifier", "examples": [ - 50 + "1" ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" + "minLength": 1, + "type": "string" } }, { - "explode": false, - "in": "query", - "name": "cursor", + "in": "path", + "name": "device_id", + "required": true, "schema": { - "maxLength": 8192, + "maxLength": 256, + "minLength": 1, "type": "string" } } @@ -70418,7 +71262,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminDeviceMetadata" + "$ref": "#/components/schemas/AdminDeviceDetail" } } }, @@ -70528,18 +71372,10 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/devices/capabilities": { + "/api/v2/admin/diagnostics/reports": { "get": { - "operationId": "getAdminDeviceCapabilities", + "operationId": "listAdminDiagnosticReports", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -70563,9 +71399,82 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "user_id", + "schema": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "platform", + "schema": { + "maxLength": 64, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "report_type", + "schema": { + "maxLength": 64, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "from", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "to", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "short_id", "schema": { + "maxLength": 64, "type": "string" } } @@ -70575,35 +71484,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDeviceCapabilitiesOutputBody" + "$ref": "#/components/schemas/CollectionAdminDiagnosticSummary" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -70655,24 +71540,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -70719,18 +71586,17 @@ "bearerAuth": [] } ], - "summary": "Read device registration and settings metadata across account stores.", + "summary": "Page diagnostic report summaries without loading their manifests.", "tags": [ - "admin" + "admin-observability" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/admin/devices/{user_id}/{device_id}": { - "get": { - "operationId": "getAdminDevice", + "/api/v2/admin/diagnostics/reports/{id}": { + "delete": { + "operationId": "deleteAdminDiagnosticReport", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -70755,40 +71621,19 @@ } }, { - "description": "Opaque identifier", "in": "path", - "name": "user_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - }, - { - "in": "path", - "name": "device_id", + "name": "id", "required": true, "schema": { - "maxLength": 256, + "maxLength": 128, "minLength": 1, "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminDeviceDetail" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -70886,17 +71731,17 @@ "bearerAuth": [] } ], - "summary": "Read device registration and settings metadata across account stores.", + "summary": "Delete report metadata synchronously, with best-effort stored bundle cleanup. An already absent report is success.", "tags": [ - "admin" + "admin-observability" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/admin/diagnostics/reports": { + }, "get": { - "operationId": "listAdminDiagnosticReports", + "operationId": "getAdminDiagnosticReport", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -70921,82 +71766,12 @@ } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "user_id", - "schema": { - "pattern": "^[1-9][0-9]*$", - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "platform", - "schema": { - "maxLength": 64, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "report_type", - "schema": { - "maxLength": 64, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "from", - "schema": { - "format": "date-time", - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "to", - "schema": { - "format": "date-time", - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "short_id", + "in": "path", + "name": "id", + "required": true, "schema": { - "maxLength": 64, + "maxLength": 128, + "minLength": 1, "type": "string" } } @@ -71006,7 +71781,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminDiagnosticSummary" + "$ref": "#/components/schemas/AdminDiagnosticDetail" } } }, @@ -71108,7 +71883,7 @@ "bearerAuth": [] } ], - "summary": "Page diagnostic report summaries without loading their manifests.", + "summary": "Read diagnostic report metadata and its original manifest.", "tags": [ "admin-observability" ], @@ -71116,10 +71891,20 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/diagnostics/reports/{id}": { - "delete": { - "operationId": "deleteAdminDiagnosticReport", + "/api/v2/admin/diagnostics/reports/{id}/download": { + "get": { + "operationId": "downloadAdminDiagnosticReport", "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -71141,21 +71926,37 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "type": "string" - } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/gzip": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Complete gzip-compressed diagnostic bundle", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "Content-Disposition": { + "schema": { + "type": "string" + } + }, + "Content-Length": { + "schema": { + "type": "integer" + } + } + } }, "400": { "content": { @@ -71197,7 +71998,7 @@ }, "description": "Not Found" }, - "406": { + "409": { "content": { "application/problem+json": { "schema": { @@ -71205,7 +72006,7 @@ } } }, - "description": "Not Acceptable" + "description": "Conflict" }, "422": { "content": { @@ -71253,17 +72054,19 @@ "bearerAuth": [] } ], - "summary": "Delete report metadata synchronously, with best-effort stored bundle cleanup. An already absent report is success.", + "summary": "Stream a ready diagnostic bundle through the API host. Range requests receive the complete bundle; no presigned URL is returned.", "tags": [ "admin-observability" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-raw-protocol": "diagnostic-bundle", + "x-silo-raw-reason": "The existing administrator consumer downloads a gzip archive; bytes must not pass through JSON encoding or response buffering.", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/downloads/devices": { "get": { - "operationId": "getAdminDiagnosticReport", + "operationId": "listAdminDownloadDevices", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -71288,12 +72091,73 @@ } }, { - "in": "path", - "name": "id", - "required": true, + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", "schema": { - "maxLength": 128, - "minLength": 1, + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, + "type": "string" + } + }, + { + "description": "Account, profile or device name", + "explode": false, + "in": "query", + "name": "q", + "schema": { + "description": "Account, profile or device name", + "maxLength": 200, + "type": "string" + } + }, + { + "description": "Only devices not seen within stale_device_days", + "explode": false, + "in": "query", + "name": "stale", + "schema": { + "description": "Only devices not seen within stale_device_days", + "type": "boolean" + } + }, + { + "explode": false, + "in": "query", + "name": "platform", + "schema": { + "maxLength": 64, + "type": "string" + } + }, + { + "description": "last_seen: longest unseen first. size: most bytes on the device first.", + "explode": false, + "in": "query", + "name": "sort", + "schema": { + "default": "last_seen", + "description": "last_seen: longest unseen first. size: most bytes on the device first.", + "enum": [ + "last_seen", + "size" + ], "type": "string" } } @@ -71303,7 +72167,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDiagnosticDetail" + "$ref": "#/components/schemas/CollectionAdminDownloadDevice" } } }, @@ -71405,28 +72269,18 @@ "bearerAuth": [] } ], - "summary": "Read diagnostic report metadata and its original manifest.", + "summary": "List every device that holds managed downloads, across accounts.", "tags": [ - "admin-observability" + "admin" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true } }, - "/api/v2/admin/diagnostics/reports/{id}/download": { + "/api/v2/admin/downloads/entries": { "get": { - "operationId": "downloadAdminDiagnosticReport", + "operationId": "listAdminDownloadEntries", "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -71448,37 +72302,81 @@ ], "type": "string" } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "user_id", + "schema": { + "maxLength": 10, + "pattern": "^([1-9][0-9]*)?$", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "profile_id", + "schema": { + "maxLength": 1024, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "device_id", + "schema": { + "maxLength": 128, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "status", + "schema": { + "maxLength": 32, + "type": "string" + } } ], "responses": { "200": { "content": { - "application/gzip": { + "application/json": { "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" + "$ref": "#/components/schemas/CollectionAdminDownloadEntry" } } }, - "description": "Complete gzip-compressed diagnostic bundle", - "headers": { - "Accept-Ranges": { - "schema": { - "type": "string" - } - }, - "Content-Disposition": { - "schema": { - "type": "string" - } - }, - "Content-Length": { - "schema": { - "type": "integer" - } - } - } + "description": "OK" }, "400": { "content": { @@ -71520,7 +72418,7 @@ }, "description": "Not Found" }, - "409": { + "406": { "content": { "application/problem+json": { "schema": { @@ -71528,7 +72426,7 @@ } } }, - "description": "Conflict" + "description": "Not Acceptable" }, "422": { "content": { @@ -71576,19 +72474,17 @@ "bearerAuth": [] } ], - "summary": "Stream a ready diagnostic bundle through the API host. Range requests receive the complete bundle; no presigned URL is returned.", + "summary": "List managed device downloads across accounts, newest first, optionally for one account, profile, device or status.", "tags": [ - "admin-observability" + "admin" ], "x-silo-class": "acting_admin", - "x-silo-raw-protocol": "diagnostic-bundle", - "x-silo-raw-reason": "The existing administrator consumer downloads a gzip archive; bytes must not pass through JSON encoding or response buffering.", "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/devices": { + "/api/v2/admin/downloads/preparations": { "get": { - "operationId": "listAdminDownloadDevices", + "operationId": "listAdminDownloadPreparations", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -71613,75 +72509,18 @@ } }, { - "description": "Page size; default 50, maximum 200", + "description": "Maximum items; counts always cover every listed job", "explode": false, "in": "query", "name": "limit", "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], + "default": 200, + "description": "Maximum items; counts always cover every listed job", "format": "int64", - "maximum": 200, + "maximum": 500, "minimum": 1, "type": "integer" } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, - "type": "string" - } - }, - { - "description": "Account, profile or device name", - "explode": false, - "in": "query", - "name": "q", - "schema": { - "description": "Account, profile or device name", - "maxLength": 200, - "type": "string" - } - }, - { - "description": "Only devices not seen within stale_device_days", - "explode": false, - "in": "query", - "name": "stale", - "schema": { - "description": "Only devices not seen within stale_device_days", - "type": "boolean" - } - }, - { - "explode": false, - "in": "query", - "name": "platform", - "schema": { - "maxLength": 64, - "type": "string" - } - }, - { - "description": "last_seen: longest unseen first. size: most bytes on the device first.", - "explode": false, - "in": "query", - "name": "sort", - "schema": { - "default": "last_seen", - "description": "last_seen: longest unseen first. size: most bytes on the device first.", - "enum": [ - "last_seen", - "size" - ], - "type": "string" - } } ], "responses": { @@ -71689,7 +72528,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminDownloadDevice" + "$ref": "#/components/schemas/AdminDownloadPreparationsOutputBody" } } }, @@ -71791,7 +72630,7 @@ "bearerAuth": [] } ], - "summary": "List every device that holds managed downloads, across accounts.", + "summary": "Read the offline-download preparation queue: running, queued and retrying jobs with live progress, and failures from the last 24 hours.", "tags": [ "admin" ], @@ -71799,9 +72638,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/entries": { - "get": { - "operationId": "listAdminDownloadEntries", + "/api/v2/admin/downloads/preparations/cancel": { + "post": { + "operationId": "cancelAdminDownloadPreparations", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -71824,77 +72663,24 @@ ], "type": "string" } - }, - { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "user_id", - "schema": { - "maxLength": 10, - "pattern": "^([1-9][0-9]*)?$", - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "profile_id", - "schema": { - "maxLength": 1024, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "device_id", - "schema": { - "maxLength": 128, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "status", - "schema": { - "maxLength": 32, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminDownloadPreparationActionInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionAdminDownloadEntry" + "$ref": "#/components/schemas/AdminDownloadPreparationActionOutputBody" } } }, @@ -71950,6 +72736,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -71996,18 +72812,28 @@ "bearerAuth": [] } ], - "summary": "List managed device downloads across accounts, newest first, optionally for one account, profile, device or status.", + "summary": "Cancel offline-download preparation jobs, including failed ones. A running encode stops, the job is removed, and every download still waiting on it fails with the message 'Canceled by an administrator'. A job that already finished preparing is not affected.", "tags": [ "admin" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/preparations": { + "/api/v2/admin/downloads/preparations/capabilities": { "get": { - "operationId": "listAdminDownloadPreparations", + "operationId": "getAdminDownloadPreparationCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -72031,17 +72857,10 @@ } }, { - "description": "Maximum items; counts always cover every listed job", - "explode": false, - "in": "query", - "name": "limit", + "in": "header", + "name": "If-None-Match", "schema": { - "default": 200, - "description": "Maximum items; counts always cover every listed job", - "format": "int64", - "maximum": 500, - "minimum": 1, - "type": "integer" + "type": "string" } } ], @@ -72050,11 +72869,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDownloadPreparationsOutputBody" + "$ref": "#/components/schemas/AdminDownloadPreparationCapabilitiesOutputBody" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -72106,6 +72949,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -72152,17 +73013,18 @@ "bearerAuth": [] } ], - "summary": "Read the offline-download preparation queue: running, queued and retrying jobs with live progress, and failures from the last 24 hours.", + "summary": "Discover the admin view of offline-download preparation.", "tags": [ "admin" ], "x-silo-class": "acting_admin", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/preparations/cancel": { + "/api/v2/admin/downloads/preparations/pause": { "post": { - "operationId": "cancelAdminDownloadPreparations", + "operationId": "pauseAdminDownloadPreparations", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -72334,7 +73196,7 @@ "bearerAuth": [] } ], - "summary": "Cancel offline-download preparation jobs, including failed ones. A running encode stops, the job is removed, and every download still waiting on it fails with the message 'Canceled by an administrator'. A job that already finished preparing is not affected.", + "summary": "Pause offline-download preparation jobs. A paused job keeps its place in the queue and no worker claims it until it is resumed. A running encode stops and restarts from the beginning when resumed; the stopped attempt does not count against the job. Waiting downloads stay preparing.", "tags": [ "admin" ], @@ -72344,18 +73206,10 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/preparations/capabilities": { - "get": { - "operationId": "getAdminDownloadPreparationCapabilities", + "/api/v2/admin/downloads/preparations/resume": { + "post": { + "operationId": "resumeAdminDownloadPreparations", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -72377,49 +73231,28 @@ ], "type": "string" } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminDownloadPreparationActionInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDownloadPreparationCapabilitiesOutputBody" + "$ref": "#/components/schemas/AdminDownloadPreparationActionOutputBody" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -72471,7 +73304,7 @@ }, "description": "Not Acceptable" }, - "412": { + "408": { "content": { "application/problem+json": { "schema": { @@ -72479,15 +73312,27 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -72535,18 +73380,19 @@ "bearerAuth": [] } ], - "summary": "Discover the admin view of offline-download preparation.", + "summary": "Resume paused offline-download preparation jobs. Each job returns to the queue at its original position, or waits out a pending retry backoff.", "tags": [ "admin" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/preparations/pause": { + "/api/v2/admin/downloads/revoke": { "post": { - "operationId": "pauseAdminDownloadPreparations", + "operationId": "revokeAdminDownloads", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -72575,7 +73421,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDownloadPreparationActionInputBody" + "$ref": "#/components/schemas/AdminDownloadRevokeInputBody" } } }, @@ -72586,7 +73432,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDownloadPreparationActionOutputBody" + "$ref": "#/components/schemas/AdminDownloadRevokeOutputBody" } } }, @@ -72718,7 +73564,7 @@ "bearerAuth": [] } ], - "summary": "Pause offline-download preparation jobs. A paused job keeps its place in the queue and no worker claims it until it is resumed. A running encode stops and restarts from the beginning when resumed; the stopped attempt does not count against the job. Waiting downloads stay preparing.", + "summary": "Revoke device downloads. The Prairie app deletes each copy the next time the device syncs and confirms by deleting the download. The server stops serving the file at once. A revoked episode is excluded from its series monitor; a preparation only revoked downloads waited on is canceled.", "tags": [ "admin" ], @@ -72728,9 +73574,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/preparations/resume": { - "post": { - "operationId": "resumeAdminDownloadPreparations", + "/api/v2/admin/downloads/storage": { + "get": { + "operationId": "getAdminDownloadStorage", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -72755,22 +73601,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminDownloadPreparationActionInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDownloadPreparationActionOutputBody" + "$ref": "#/components/schemas/AdminDownloadStorage" } } }, @@ -72826,36 +73662,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -72902,20 +73708,26 @@ "bearerAuth": [] } ], - "summary": "Resume paused offline-download preparation jobs. Each job returns to the queue at its original position, or waits out a pending retry backoff.", + "summary": "Read prepared-download storage at the server and every transcode node: what is on disk, what Prairie's records say, budgets, and files nothing accounts for.", "tags": [ "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/revoke": { - "post": { - "operationId": "revokeAdminDownloads", + "/api/v2/admin/downloads/storage/capabilities": { + "get": { + "operationId": "getAdminDownloadStorageCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -72937,28 +73749,49 @@ ], "type": "string" } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminDownloadRevokeInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDownloadRevokeOutputBody" + "$ref": "#/components/schemas/AdminDownloadStorageCapabilitiesOutputBody" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -73010,17 +73843,7 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { @@ -73028,17 +73851,15 @@ } } }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { - "$ref": "#/components/schemas/Problem" + "type": "string" } } - }, - "description": "Unsupported Media Type" + } }, "422": { "content": { @@ -73086,19 +73907,18 @@ "bearerAuth": [] } ], - "summary": "Revoke device downloads. The Prairie app deletes each copy the next time the device syncs and confirms by deleting the download. The server stops serving the file at once. A revoked episode is excluded from its series monitor; a preparation only revoked downloads waited on is canceled.", + "summary": "Discover the admin view of prepared-download storage.", "tags": [ "admin" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/admin/downloads/storage": { + "/api/v2/admin/downloads/storage/events": { "get": { - "operationId": "getAdminDownloadStorage", + "operationId": "listAdminDownloadStorageEvents", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -73121,6 +73941,66 @@ ], "type": "string" } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "maxLength": 8192, + "type": "string" + } + }, + { + "description": "Only this reason", + "explode": false, + "in": "query", + "name": "reason", + "schema": { + "description": "Only this reason", + "maxLength": 32, + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "location", + "schema": { + "maxLength": 32, + "pattern": "^(server|device|node:[1-9][0-9]*)?$", + "type": "string" + } + }, + { + "description": "Only the last N days; 0 for all kept history (90 days)", + "explode": false, + "in": "query", + "name": "days", + "schema": { + "description": "Only the last N days; 0 for all kept history (90 days)", + "format": "int64", + "maximum": 90, + "minimum": 0, + "type": "integer" + } } ], "responses": { @@ -73128,409 +74008,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDownloadStorage" - } - } - }, - "description": "OK" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Read prepared-download storage at the server and every transcode node: what is on disk, what Prairie's records say, budgets, and files nothing accounts for.", - "tags": [ - "admin" - ], - "x-silo-class": "acting_admin", - "x-silo-service-backed": true - } - }, - "/api/v2/admin/downloads/storage/capabilities": { - "get": { - "operationId": "getAdminDownloadStorageCapabilities", - "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", - "in": "header", - "name": "X-Profile-Id", - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminDownloadStorageCapabilitiesOutputBody" - } - } - }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Discover the admin view of prepared-download storage.", - "tags": [ - "admin" - ], - "x-silo-class": "acting_admin", - "x-silo-conditional": true, - "x-silo-service-backed": true - } - }, - "/api/v2/admin/downloads/storage/events": { - "get": { - "operationId": "listAdminDownloadStorageEvents", - "parameters": [ - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", - "in": "header", - "name": "X-Profile-Id", - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - }, - { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "maxLength": 8192, - "type": "string" - } - }, - { - "description": "Only this reason", - "explode": false, - "in": "query", - "name": "reason", - "schema": { - "description": "Only this reason", - "maxLength": 32, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "location", - "schema": { - "maxLength": 32, - "pattern": "^(server|device|node:[1-9][0-9]*)?$", - "type": "string" - } - }, - { - "description": "Only the last N days; 0 for all kept history (90 days)", - "explode": false, - "in": "query", - "name": "days", - "schema": { - "description": "Only the last N days; 0 for all kept history (90 days)", - "format": "int64", - "maximum": 90, - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CollectionAdminDownloadStorageEvent" + "$ref": "#/components/schemas/CollectionAdminDownloadStorageEvent" } } }, @@ -85961,7 +86439,7 @@ } } }, - "description": "The job was already canceled." + "description": "The job is canceled: it was already canceled, or it was a queued image cache cleanup that this request canceled at once." }, "202": { "content": { @@ -86868,9 +87346,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/literary-works/items/{content_id}/candidates": { - "get": { - "operationId": "listAdminLiteraryCandidates", + "/api/v2/admin/libraries/{library_id}/metadata-translation": { + "post": { + "operationId": "translateLibraryMetadata", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -86895,38 +87373,47 @@ } }, { + "description": "The library", "in": "path", - "name": "content_id", + "name": "library_id", "required": true, "schema": { - "maxLength": 512, + "description": "The library", + "examples": [ + "1" + ], "minLength": 1, "type": "string" } - }, - { - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 20, - "format": "int64", - "maximum": 100, - "minimum": 1, - "type": "integer" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminTranslateLibraryInputBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminLiteraryCandidates" + "$ref": "#/components/schemas/MetadataTranslationJob" } } }, - "description": "OK" + "description": "Accepted", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -86978,6 +87465,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -87024,17 +87541,19 @@ "bearerAuth": [] } ], - "summary": "Read bounded ranked literary match candidates.", + "summary": "Prewarm a library: persist or reuse an active job that translates the missing descriptions of every item in the library, with their seasons and episodes, into a language. Existing provider, manual and AI text is kept. Progress counts items.", "tags": [ "admin-catalog" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/literary-works/link": { - "post": { - "operationId": "linkAdminLiteraryItems", + "/api/v2/admin/libraries/{library_id}/metadata-translation/jobs": { + "get": { + "operationId": "listLibraryMetadataTranslationJobs", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87057,24 +87576,28 @@ ], "type": "string" } + }, + { + "description": "The library", + "in": "path", + "name": "library_id", + "required": true, + "schema": { + "description": "The library", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminLiteraryLinkInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminLiteraryLink" + "$ref": "#/components/schemas/AdminMetadataTranslationJobs" } } }, @@ -87130,36 +87653,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -87206,19 +87699,17 @@ "bearerAuth": [] } ], - "summary": "Link literary editions using the existing catalog service.", + "summary": "Read the latest 50 prewarm jobs of a library.", "tags": [ "admin-catalog" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/literary-works/matches/confirm": { + "/api/v2/admin/libraries/{library_id}/metadata-translation/jobs/{job_id}/cancel": { "post": { - "operationId": "confirmAdminLiteraryMatch", + "operationId": "cancelLibraryMetadataTranslation", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87241,28 +87732,39 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminLiteraryDecisionInputBody" - } + }, + { + "description": "The library", + "in": "path", + "name": "library_id", + "required": true, + "schema": { + "description": "The library", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" } }, - "required": true - }, + { + "description": "Opaque identifier", + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminLiteraryDecision" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -87314,36 +87816,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -87390,7 +87862,7 @@ "bearerAuth": [] } ], - "summary": "Link editions then record an administrator match decision.", + "summary": "Request cancellation only for a prewarm job of the library.", "tags": [ "admin-catalog" ], @@ -87400,9 +87872,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/literary-works/matches/ignore": { - "post": { - "operationId": "ignoreAdminLiteraryMatch", + "/api/v2/admin/literary-works/items/{content_id}/candidates": { + "get": { + "operationId": "listAdminLiteraryCandidates", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87425,24 +87897,36 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminLiteraryDecisionInputBody" - } + }, + { + "in": "path", + "name": "content_id", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" } }, - "required": true - }, + { + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 20, + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminLiteraryDecision" + "$ref": "#/components/schemas/AdminLiteraryCandidates" } } }, @@ -87498,36 +87982,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -87574,19 +88028,17 @@ "bearerAuth": [] } ], - "summary": "Record an administrator decision to ignore a literary match.", + "summary": "Read bounded ranked literary match candidates.", "tags": [ "admin-catalog" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/literary-works/{work_id}/items/{content_id}": { - "delete": { - "operationId": "unlinkAdminLiteraryItem", + "/api/v2/admin/literary-works/link": { + "post": { + "operationId": "linkAdminLiteraryItems", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87609,31 +88061,28 @@ ], "type": "string" } - }, - { - "in": "path", - "name": "work_id", - "required": true, - "schema": { - "maxLength": 512, - "minLength": 1, - "type": "string" - } - }, - { - "in": "path", - "name": "content_id", - "required": true, - "schema": { - "maxLength": 512, - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminLiteraryLinkInputBody" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminLiteraryLink" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -87685,6 +88134,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -87731,7 +88210,7 @@ "bearerAuth": [] } ], - "summary": "Remove an edition from a literary work.", + "summary": "Link literary editions using the existing catalog service.", "tags": [ "admin-catalog" ], @@ -87741,9 +88220,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/channels/{channel_id}": { - "patch": { - "operationId": "updateAdminLiveTVChannel", + "/api/v2/admin/literary-works/matches/confirm": { + "post": { + "operationId": "confirmAdminLiteraryMatch", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87766,27 +88245,13 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "channel_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVChannelUpdate" + "$ref": "#/components/schemas/AdminLiteraryDecisionInputBody" } } }, @@ -87797,7 +88262,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVChannel" + "$ref": "#/components/schemas/AdminLiteraryDecision" } } }, @@ -87863,16 +88328,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "413": { "content": { "application/problem+json": { @@ -87923,16 +88378,6 @@ }, "description": "Internal Server Error" }, - "501": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Implemented" - }, "503": { "content": { "application/problem+json": { @@ -87949,19 +88394,19 @@ "bearerAuth": [] } ], - "summary": "Enable or disable a channel, override its number, or map it to a guide station. Omitted members are unchanged.", + "summary": "Link editions then record an administrator match decision.", "tags": [ - "admin-livetv" + "admin-catalog" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/guide-sources": { - "get": { - "operationId": "listAdminLiveTVGuideSources", + "/api/v2/admin/literary-works/matches/ignore": { + "post": { + "operationId": "ignoreAdminLiteraryMatch", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -87986,12 +88431,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminLiteraryDecisionInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionLiveTVGuideSource" + "$ref": "#/components/schemas/AdminLiteraryDecision" } } }, @@ -88047,7 +88502,7 @@ }, "description": "Not Acceptable" }, - "409": { + "408": { "content": { "application/problem+json": { "schema": { @@ -88055,7 +88510,27 @@ } } }, - "description": "Conflict" + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -88103,15 +88578,19 @@ "bearerAuth": [] } ], - "summary": "List guide data sources in priority order; secrets are redacted.", + "summary": "Record an administrator decision to ignore a literary match.", "tags": [ - "admin-livetv" + "admin-catalog" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "post": { - "operationId": "createAdminLiveTVGuideSource", + } + }, + "/api/v2/admin/literary-works/{work_id}/items/{content_id}": { + "delete": { + "operationId": "unlinkAdminLiteraryItem", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -88134,28 +88613,31 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LiveTVGuideSourceBody" - } + }, + { + "in": "path", + "name": "work_id", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" } }, - "required": true - }, + { + "in": "path", + "name": "content_id", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + } + ], "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LiveTVGuideSource" - } - } - }, - "description": "Created" + "204": { + "description": "No Content" }, "400": { "content": { @@ -88207,46 +88689,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -88277,16 +88719,6 @@ }, "description": "Internal Server Error" }, - "501": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Implemented" - }, "503": { "content": { "application/problem+json": { @@ -88303,9 +88735,9 @@ "bearerAuth": [] } ], - "summary": "Add a guide data source (Schedules Direct or Gracenote XML sync). At most three may be enabled. Never retried automatically.", + "summary": "Remove an edition from a literary work.", "tags": [ - "admin-livetv" + "admin-catalog" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, @@ -88313,9 +88745,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/guide-sources/{source_id}": { - "delete": { - "operationId": "deleteAdminLiveTVGuideSource", + "/api/v2/admin/livetv/channels/{channel_id}": { + "patch": { + "operationId": "updateAdminLiveTVChannel", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -88342,7 +88774,7 @@ { "description": "Opaque identifier", "in": "path", - "name": "source_id", + "name": "channel_id", "required": true, "schema": { "description": "Opaque identifier", @@ -88354,9 +88786,26 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVChannelUpdate" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVChannel" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -88408,6 +88857,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -88418,6 +88877,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -88474,7 +88953,7 @@ "bearerAuth": [] } ], - "summary": "Remove a guide source.", + "summary": "Enable or disable a channel, override its number, or map it to a guide station. Omitted members are unchanged.", "tags": [ "admin-livetv" ], @@ -88482,9 +88961,11 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, - "patch": { - "operationId": "updateAdminLiveTVGuideSource", + } + }, + "/api/v2/admin/livetv/guide-sources": { + "get": { + "operationId": "listAdminLiveTVGuideSources", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -88507,38 +88988,14 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "source_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LiveTVGuideSourceUpdate" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVGuideSource" + "$ref": "#/components/schemas/CollectionLiveTVGuideSource" } } }, @@ -88594,16 +89051,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -88614,26 +89061,6 @@ }, "description": "Conflict" }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -88664,16 +89091,6 @@ }, "description": "Internal Server Error" }, - "501": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Implemented" - }, "503": { "content": { "application/problem+json": { @@ -88690,19 +89107,15 @@ "bearerAuth": [] } ], - "summary": "Update a guide source. Omitted members are unchanged; config replaces the whole map.", + "summary": "List guide data sources in priority order; secrets are redacted.", "tags": [ "admin-livetv" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/admin/livetv/guide-sources/{source_id}/sync": { + }, "post": { - "operationId": "syncAdminLiveTVGuideSource", + "operationId": "createAdminLiveTVGuideSource", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -88725,24 +89138,20 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "source_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVGuideSourceBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -88750,7 +89159,7 @@ } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -88802,6 +89211,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -88812,6 +89231,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -88868,19 +89307,19 @@ "bearerAuth": [] } ], - "summary": "Sync a guide source now and return it with its refreshed sync state.", + "summary": "Add a guide data source (Schedules Direct or Gracenote XML sync). At most three may be enabled. Never retried automatically.", "tags": [ "admin-livetv" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/schedules-direct/lineups": { - "post": { - "operationId": "lookupAdminLiveTVSchedulesDirectLineups", + "/api/v2/admin/livetv/guide-sources/{source_id}": { + "delete": { + "operationId": "deleteAdminLiveTVGuideSource", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -88903,13 +89342,196 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "source_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Remove a guide source.", + "tags": [ + "admin-livetv" + ], + "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + }, + "patch": { + "operationId": "updateAdminLiveTVGuideSource", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "source_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVSchedulesDirectLineupsBody" + "$ref": "#/components/schemas/LiveTVGuideSourceUpdate" } } }, @@ -88920,7 +89542,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionLiveTVLineup" + "$ref": "#/components/schemas/LiveTVGuideSource" } } }, @@ -89072,7 +89694,7 @@ "bearerAuth": [] } ], - "summary": "Look up the Schedules Direct lineups available at a postal code with the given account. Nothing is saved.", + "summary": "Update a guide source. Omitted members are unchanged; config replaces the whole map.", "tags": [ "admin-livetv" ], @@ -89082,9 +89704,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/tuners": { - "get": { - "operationId": "listAdminLiveTVTuners", + "/api/v2/admin/livetv/guide-sources/{source_id}/sync": { + "post": { + "operationId": "syncAdminLiveTVGuideSource", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89107,6 +89729,20 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "source_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -89114,7 +89750,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionLiveTVTuner" + "$ref": "#/components/schemas/LiveTVGuideSource" } } }, @@ -89210,6 +89846,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -89226,15 +89872,19 @@ "bearerAuth": [] } ], - "summary": "List configured Live TV tuners.", + "summary": "Sync a guide source now and return it with its refreshed sync state.", "tags": [ "admin-livetv" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, + } + }, + "/api/v2/admin/livetv/schedules-direct/lineups": { "post": { - "operationId": "addAdminLiveTVTuner", + "operationId": "lookupAdminLiveTVSchedulesDirectLineups", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89263,22 +89913,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVTunerBody" + "$ref": "#/components/schemas/LiveTVSchedulesDirectLineupsBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVTuner" + "$ref": "#/components/schemas/CollectionLiveTVLineup" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -89426,19 +90076,169 @@ "bearerAuth": [] } ], - "summary": "Add an HDHomeRun-compatible tuner by address and scan its lineup. Adding a tuner that is already configured returns it.", + "summary": "Look up the Schedules Direct lineups available at a postal code with the given account. Nothing is saved.", "tags": [ "admin-livetv" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "unique_constraint", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/tuners/discover": { - "post": { - "operationId": "discoverAdminLiveTVTuners", + "/api/v2/admin/livetv/tuners": { + "get": { + "operationId": "listAdminLiveTVTuners", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionLiveTVTuner" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List configured Live TV tuners.", + "tags": [ + "admin-livetv" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + }, + "post": { + "operationId": "addAdminLiveTVTuner", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89467,22 +90267,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVTunerDiscoveryBody", - "description": "Absent runs UDP discovery with the default window" + "$ref": "#/components/schemas/LiveTVTunerBody" } } - } + }, + "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVTunerDiscovery" + "$ref": "#/components/schemas/LiveTVTuner" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -89630,19 +90430,19 @@ "bearerAuth": [] } ], - "summary": "Discover tuners on the network (HDHomeRun UDP broadcast and URL probes) without saving them.", + "summary": "Add an HDHomeRun-compatible tuner by address and scan its lineup. Adding a tuner that is already configured returns it.", "tags": [ "admin-livetv" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "unique_constraint", "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/tuners/{tuner_id}": { - "delete": { - "operationId": "deleteAdminLiveTVTuner", + "/api/v2/admin/livetv/tuners/discover": { + "post": { + "operationId": "discoverAdminLiveTVTuners", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89665,25 +90465,28 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "tuner_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVTunerDiscoveryBody", + "description": "Absent runs UDP discovery with the default window" + } + } + } + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVTunerDiscovery" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -89735,6 +90538,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -89745,6 +90558,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -89801,7 +90634,7 @@ "bearerAuth": [] } ], - "summary": "Remove a tuner and its channels.", + "summary": "Discover tuners on the network (HDHomeRun UDP broadcast and URL probes) without saving them.", "tags": [ "admin-livetv" ], @@ -89811,9 +90644,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/tuners/{tuner_id}/scan": { - "post": { - "operationId": "scanAdminLiveTVTuner", + "/api/v2/admin/livetv/tuners/{tuner_id}": { + "delete": { + "operationId": "deleteAdminLiveTVTuner", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -89853,15 +90686,8 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LiveTVTuner" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -89979,7 +90805,7 @@ "bearerAuth": [] } ], - "summary": "Rescan a tuner's channel lineup now and return the refreshed tuner.", + "summary": "Remove a tuner and its channels.", "tags": [ "admin-livetv" ], @@ -89989,9 +90815,187 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/livetv/xml-sync/lineups": { + "/api/v2/admin/livetv/tuners/{tuner_id}/scan": { "post": { - "operationId": "lookupAdminLiveTVXMLSyncLineups", + "operationId": "scanAdminLiveTVTuner", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "tuner_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVTuner" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Rescan a tuner's channel lineup now and return the refreshed tuner.", + "tags": [ + "admin-livetv" + ], + "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/livetv/xml-sync/lineups": { + "post": { + "operationId": "lookupAdminLiveTVXMLSyncLineups", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -95546,7 +96550,7 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "summary": "Dispatch one test push carrying the target profile's newest notification and report its current delivery outcome.", "tags": [ "admin" ], @@ -95730,7 +96734,7 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "summary": "Dispatch one test push carrying the target profile's newest notification and report its current delivery outcome.", "tags": [ "admin" ], @@ -105866,9 +106870,10 @@ "x-silo-conditional": true } }, - "/api/v2/admin/recommendations/status": { - "get": { - "operationId": "getAdminRecommendationsStatus", + "/api/v2/admin/recommendations/embeddings/reset": { + "post": { + "description": "Recovers a server whose embedding lock pins a model it can no longer use, or switches embedding models. It refuses with 409 while the embedding, taste profile or recommendation job, or a stale profile sweep, runs on any server, while a catalog import with embeddings runs, and while saved embedding settings wait for a server restart. Run the embedding job afterwards to build the new embedding space.", + "operationId": "resetAdminRecommendationEmbeddings", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -105898,7 +106903,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationsStatus" + "$ref": "#/components/schemas/AdminRecommendationEmbeddingsReset" } } }, @@ -105954,6 +106959,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -106000,17 +107015,19 @@ "bearerAuth": [] } ], - "summary": "Read persisted counts and this process's running flags.", + "summary": "Delete the embedding lock, every item embedding, taste clusters and per-profile cached rows, and clear taste profile vectors, in one transaction.", "tags": [ "admin-recommendations" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/cowatch": { - "post": { - "operationId": "triggerAdminRecommendationCowatch", + "/api/v2/admin/recommendations/status": { + "get": { + "operationId": "getAdminRecommendationsStatus", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -106040,7 +107057,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRecommendationStarted" + "$ref": "#/components/schemas/AdminRecommendationsStatus" } } }, @@ -106142,19 +107159,17 @@ "bearerAuth": [] } ], - "summary": "Start process-local recommendation work without a durable job receipt.", + "summary": "Read persisted counts, each job's last run, the embedding lock conflict and this process's running flags.", "tags": [ "admin-recommendations" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/embeddings": { + "/api/v2/admin/recommendations/trigger/cowatch": { "post": { - "operationId": "triggerAdminRecommendationEmbeddings", + "operationId": "triggerAdminRecommendationCowatch", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -106296,9 +107311,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/recommendations": { + "/api/v2/admin/recommendations/trigger/embeddings": { "post": { - "operationId": "triggerAdminRecommendationRefresh", + "operationId": "triggerAdminRecommendationEmbeddings", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -106440,9 +107455,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/recommendations/trigger/taste-profiles": { + "/api/v2/admin/recommendations/trigger/recommendations": { "post": { - "operationId": "triggerAdminRecommendationTasteProfiles", + "operationId": "triggerAdminRecommendationRefresh", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -106584,9 +107599,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/request-groups/{group_id}/limit": { - "get": { - "operationId": "getAdminRequestGroupLimit", + "/api/v2/admin/recommendations/trigger/taste-profiles": { + "post": { + "operationId": "triggerAdminRecommendationTasteProfiles", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -106609,21 +107624,6 @@ ], "type": "string" } - }, - { - "description": "The access group", - "in": "path", - "name": "group_id", - "required": true, - "schema": { - "description": "The access group", - "examples": [ - "2" - ], - "minLength": 1, - "pattern": "^[1-9][0-9]*$", - "type": "string" - } } ], "responses": { @@ -106631,18 +107631,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminRequestGroupLimit" + "$ref": "#/components/schemas/AdminRecommendationStarted" } } }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -106740,33 +107733,199 @@ "bearerAuth": [] } ], - "summary": "Get an access group's request approval and limit.", + "summary": "Start process-local recommendation work without a durable job receipt.", "tags": [ - "admin" + "admin-recommendations" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateAdminRequestGroupLimit", + } + }, + "/api/v2/admin/request-groups/{group_id}/limit": { + "get": { + "operationId": "getAdminRequestGroupLimit", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The access group", + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "description": "The access group", + "examples": [ + "2" + ], + "minLength": 1, + "pattern": "^[1-9][0-9]*$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminRequestGroupLimit" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get an access group's request approval and limit.", + "tags": [ + "admin" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + }, + "put": { + "operationId": "updateAdminRequestGroupLimit", + "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -136469,9 +137628,9 @@ "x-silo-service-backed": true } }, - "/api/v2/capabilities/password-reset": { + "/api/v2/capabilities/not-interested": { "get": { - "operationId": "getPasswordResetCapability", + "operationId": "getNotInterestedCapability", "parameters": [ { "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", @@ -136481,6 +137640,29 @@ "type": "string" } }, + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, { "in": "header", "name": "If-None-Match", @@ -136494,7 +137676,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PasswordResetCapability" + "$ref": "#/components/schemas/NotInterestedCapability" } } }, @@ -136534,6 +137716,36 @@ }, "description": "Bad Request" }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, "406": { "content": { "application/problem+json": { @@ -136572,6 +137784,16 @@ }, "description": "Unprocessable Entity" }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, "500": { "content": { "application/problem+json": { @@ -136593,18 +137815,23 @@ "description": "Service Unavailable" } }, - "summary": "Discover whether the sign-in page may offer self-service password reset.", + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Whether the acting profile can mark movies and series Not interested.", "tags": [ - "auth" + "not-interested" ], - "x-silo-class": "public", + "x-silo-class": "profile_scoped", "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/capabilities/ratings": { + "/api/v2/capabilities/password-reset": { "get": { - "operationId": "getRatingsCapability", + "operationId": "getPasswordResetCapability", "parameters": [ { "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", @@ -136614,29 +137841,6 @@ "type": "string" } }, - { - "description": "The household profile acting for this request; it must belong to the authenticated account.", - "in": "header", - "name": "X-Profile-Id", - "required": true, - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - }, { "in": "header", "name": "If-None-Match", @@ -136650,7 +137854,163 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RatingsCapability" + "$ref": "#/components/schemas/PasswordResetCapability" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Discover whether the sign-in page may offer self-service password reset.", + "tags": [ + "auth" + ], + "x-silo-class": "public", + "x-silo-conditional": true, + "x-silo-service-backed": true + } + }, + "/api/v2/capabilities/ratings": { + "get": { + "operationId": "getRatingsCapability", + "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RatingsCapability" } } }, @@ -137271,21 +138631,104 @@ } }, { - "description": "Artwork variant to presign", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "Page size; default 50, maximum 200", "explode": false, @@ -137560,19 +139003,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "Page size; default 50, maximum 200", "explode": false, @@ -138356,19 +139884,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "The library the item is being viewed in; picks its presentation when the item is in several", "explode": false, @@ -138554,19 +140167,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "The library the item is being viewed in; picks its presentation when the item is in several", "explode": false, @@ -138752,19 +140450,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "The library the item is being viewed in; picks its presentation when the item is in several", "explode": false, @@ -140064,7 +141847,7 @@ "bearerAuth": [] } ], - "summary": "Queue a translation of the item's descriptions into the language the detail document reported missing; answers 202 with the job.", + "summary": "Queue a translation of the descriptions a page shows into the language its detail document reported missing: a movie's or series' own overview and tagline, a season's overview and its episodes' overviews, or one episode. Answers 202 with the job.", "tags": [ "catalog" ], @@ -140127,19 +141910,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "The library the item is being viewed in; picks its presentation when the item is in several", "explode": false, @@ -141010,18 +142878,103 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "requestBody": { @@ -141422,19 +143375,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "The library the series is being viewed in", "explode": false, @@ -141617,19 +143655,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "The library the series is being viewed in", "explode": false, @@ -141816,19 +143939,104 @@ } }, { + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "The library the series is being viewed in", "explode": false, @@ -145033,9 +147241,9 @@ "x-silo-service-backed": true } }, - "/api/v2/collections/import/trakt": { - "post": { - "operationId": "importTraktCollection", + "/api/v2/collections/order": { + "get": { + "operationId": "getCollectionOrder", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -145059,32 +147267,31 @@ ], "type": "string" } + }, + { + "description": "Omit: personal collection groups are no longer supported, and any value is a validation failure", + "explode": false, + "in": "query", + "name": "group_id", + "schema": { + "description": "Omit: personal collection groups are no longer supported, and any value is a validation failure", + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TraktCollectionImport" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionImportResult" + "$ref": "#/components/schemas/CollectionOrder" } } }, - "description": "Created", + "description": "OK", "headers": { - "Location": { + "ETag": { "schema": { - "description": "The created collection's resource path", "type": "string" } } @@ -145140,36 +147347,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -145216,20 +147393,33 @@ "bearerAuth": [] } ], - "summary": "Import a Trakt preset as a synced collection. Creates the collection and runs its first sync. Not idempotent: a retry after a lost response creates a second collection.", + "summary": "Read the canonical collection editor state and its strong validator.", "tags": [ "collections" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/collections/order": { - "get": { - "operationId": "getCollectionOrder", + }, + "put": { + "operationId": "reorderCollections", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -145252,18 +147442,18 @@ ], "type": "string" } - }, - { - "description": "Omit: personal collection groups are no longer supported, and any value is a validation failure", - "explode": false, - "in": "query", - "name": "group_id", - "schema": { - "description": "Omit: personal collection groups are no longer supported, and any value is a validation failure", - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionOrder" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -145276,6 +147466,7 @@ "description": "OK", "headers": { "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { "type": "string" } @@ -145332,6 +147523,54 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -145342,6 +147581,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -145378,33 +147627,21 @@ "bearerAuth": [] } ], - "summary": "Read the canonical collection editor state and its strong validator.", + "summary": "Replace the order of the acting profile's own collections. Retries are not safe after an intervening mutation.", "tags": [ "collections" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-guarded": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "reorderCollections", + } + }, + "/api/v2/collections/preview": { + "post": { + "operationId": "previewCollection", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -145433,7 +147670,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionOrder" + "$ref": "#/components/schemas/PersonalCollectionPreviewInputBody" } } }, @@ -145444,19 +147681,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionOrder" + "$ref": "#/components/schemas/PersonalCollectionPreviewOutputBody" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -145518,24 +147747,6 @@ }, "description": "Request Timeout" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "413": { "content": { "application/problem+json": { @@ -145566,16 +147777,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -145612,20 +147813,19 @@ "bearerAuth": [] } ], - "summary": "Replace the order of the acting profile's own collections. Retries are not safe after an intervening mutation.", + "summary": "Preview a smart query within the acting profile's access.", "tags": [ "collections" ], "x-silo-class": "profile_scoped", "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/collections/preview": { - "post": { - "operationId": "previewCollection", + "/api/v2/collections/server": { + "get": { + "operationId": "listServerCollections", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -145651,22 +147851,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PersonalCollectionPreviewInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersonalCollectionPreviewOutputBody" + "$ref": "#/components/schemas/ServerCollectionsOutputBody" } } }, @@ -145722,36 +147912,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -145798,19 +147958,17 @@ "bearerAuth": [] } ], - "summary": "Preview a smart query within the acting profile's access.", + "summary": "List visible server collections grouped by library.", "tags": [ "collections" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/collections/server": { - "get": { - "operationId": "listServerCollections", + "/api/v2/collections/sort-preference": { + "delete": { + "operationId": "clearCollectionSortPreference", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -145834,18 +147992,40 @@ ], "type": "string" } + }, + { + "explode": false, + "in": "query", + "name": "collection_kind", + "required": true, + "schema": { + "enum": [ + "library", + "user", + "watchlist", + "favorites" + ], + "type": "string" + } + }, + { + "description": "Opaque identifier", + "explode": false, + "in": "query", + "name": "collection_id", + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerCollectionsOutputBody" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -145943,17 +148123,17 @@ "bearerAuth": [] } ], - "summary": "List visible server collections grouped by library.", + "summary": "Clear the acting profile's collection sort preference.", "tags": [ "collections" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/collections/sort-preference": { - "delete": { - "operationId": "clearCollectionSortPreference", + }, + "put": { + "operationId": "setCollectionSortPreference", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -145977,40 +148157,28 @@ ], "type": "string" } - }, - { - "explode": false, - "in": "query", - "name": "collection_kind", - "required": true, - "schema": { - "enum": [ - "library", - "user", - "watchlist", - "favorites" - ], - "type": "string" - } - }, - { - "description": "Opaque identifier", - "explode": false, - "in": "query", - "name": "collection_id", - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionSortPreference" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionSortPreference" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -146062,6 +148230,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -146108,7 +148306,7 @@ "bearerAuth": [] } ], - "summary": "Clear the acting profile's collection sort preference.", + "summary": "Save the acting profile's collection sort preference.", "tags": [ "collections" ], @@ -146116,9 +148314,11 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "setCollectionSortPreference", + } + }, + "/api/v2/collections/templates": { + "get": { + "operationId": "listCollectionTemplates", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -146144,22 +148344,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CollectionSortPreference" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionSortPreference" + "$ref": "#/components/schemas/CollectionTemplateCatalog" } } }, @@ -146215,36 +148405,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -146291,20 +148451,35 @@ "bearerAuth": [] } ], - "summary": "Save the acting profile's collection sort preference.", + "summary": "List supported collection import templates.", "tags": [ "collections" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/collections/templates": { - "get": { - "operationId": "listCollectionTemplates", + "/api/v2/collections/{id}": { + "delete": { + "operationId": "deleteCollection", "parameters": [ + { + "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -146327,18 +148502,25 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CollectionTemplateCatalog" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -146390,6 +148572,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -146400,6 +148600,16 @@ }, "description": "Unprocessable Entity" }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Required" + }, "429": { "content": { "application/problem+json": { @@ -146436,55 +148646,53 @@ "bearerAuth": [] } ], - "summary": "List supported collection import templates.", + "summary": "Delete the creator's collection.", "tags": [ "collections" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-guarded": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/collections/{id}": { - "delete": { - "operationId": "deleteCollection", + }, + "get": { + "operationId": "getCollection", "parameters": [ { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", - "name": "If-Match", + "name": "X-Profile-Id", "required": true, "schema": { + "examples": [ + "1" + ], "type": "string" } }, { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", "in": "header", - "name": "If-None-Match", + "name": "X-Profile-Token", "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], "type": "string" } }, { - "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", - "name": "X-Profile-Id", - "required": true, + "name": "If-Match", "schema": { - "examples": [ - "1" - ], "type": "string" } }, { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", "in": "header", - "name": "X-Profile-Token", + "name": "If-None-Match", "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], "type": "string" } }, @@ -146504,215 +148712,22 @@ } ], "responses": { - "204": { - "description": "No Content" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Delete the creator's collection.", - "tags": [ - "collections" - ], - "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "non_retryable", - "x-silo-service-backed": true - }, - "get": { - "operationId": "getCollection", - "parameters": [ - { - "description": "The household profile acting for this request; it must belong to the authenticated account.", - "in": "header", - "name": "X-Profile-Id", - "required": true, - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - }, - { - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PersonalCollection" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - } - } + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalCollection" + } + } + }, + "description": "OK", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -149476,11 +151491,9 @@ "x-silo-class": "profile_scoped", "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/devices/{device_id}/settings": { - "delete": { - "operationId": "clearDeviceSettings", + }, + "patch": { + "operationId": "updateDevice", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -149528,11 +151541,227 @@ "minLength": 1, "type": "string" } + }, + { + "in": "header", + "name": "X-Prairie-Device-Id", + "schema": { + "maxLength": 128, + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceUpdate" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceSettings" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update a device's entry for the acting profile, or another household profile, and return it; omitted members are unchanged. Settings and login sessions are untouched.", + "tags": [ + "devices" + ], + "x-silo-class": "profile_scoped", + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/devices/{device_id}/settings": { + "delete": { + "operationId": "clearDeviceSettings", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Another profile on the account; requires household management authority", + "explode": false, + "in": "query", + "name": "profile_id", + "schema": { + "description": "Another profile on the account; requires household management authority", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" }, "400": { "content": { @@ -161274,12 +163503,12 @@ } }, { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -161292,6 +163521,86 @@ "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "Page size; default 50, maximum 200", "explode": false, @@ -161580,12 +163889,12 @@ "bearerAuth": [] } ], - "summary": "Remove the item from the acting profile's favorites; an absent entry succeeds, but automatic retries can repeat provider and refresh effects.", + "summary": "Remove the item from the acting profile's favorites. An absent entry succeeds and changes nothing, so a retry repeats no change or notification; it cannot resend notifications an interrupted earlier call never sent.", "tags": [ "favorites" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true }, "get": { @@ -161884,12 +164193,12 @@ "bearerAuth": [] } ], - "summary": "Add the item to the acting profile's favorites. Automatic retries are unsafe because provider and refresh effects are not change-gated.", + "summary": "Add the item to the acting profile's favorites. Adding an item already there succeeds and changes nothing, so a retry repeats no change or notification; it cannot resend notifications an interrupted earlier call never sent.", "tags": [ "favorites" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, @@ -161921,12 +164230,12 @@ } }, { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -161939,6 +164248,86 @@ "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "Page size; default 50, maximum 200", "explode": false, @@ -164290,12 +166679,12 @@ } }, { - "description": "Artwork variant to presign for included_sections, as on getHomeSectionItems; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign for included_sections, as on getHomeSectionItems; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -164307,6 +166696,86 @@ ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -164452,12 +166921,12 @@ } }, { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -164469,6 +166938,86 @@ ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -164628,12 +167177,12 @@ } }, { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -164645,6 +167194,86 @@ ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -167714,6 +170343,17 @@ "type": "string" } }, + { + "description": "List the items an admin marked as intentionally unmatched instead of the ones awaiting a match", + "explode": false, + "in": "query", + "name": "ignored", + "schema": { + "default": false, + "description": "List the items an admin marked as intentionally unmatched instead of the ones awaiting a match", + "type": "boolean" + } + }, { "description": "Opaque cursor from page.next_cursor", "explode": false, @@ -167835,7 +170475,7 @@ "bearerAuth": [] } ], - "summary": "Page the catalog items awaiting a metadata match, in title order.", + "summary": "Page the catalog items awaiting a metadata match, in title order. Items an admin marked as intentionally unmatched are left out unless ignored is set, which lists only those.", "tags": [ "libraries" ], @@ -167843,9 +170483,9 @@ "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}": { + "/api/v2/libraries/unmatched-items/{content_id}/ignore": { "delete": { - "operationId": "deleteLibrary", + "operationId": "unignoreUnmatchedItem", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -167870,14 +170510,14 @@ } }, { - "description": "The library", + "description": "The catalog item", "in": "path", - "name": "id", + "name": "content_id", "required": true, "schema": { - "description": "The library", + "description": "The catalog item", "examples": [ - "1" + "local-ad5847acdd9590cc9d353da12446" ], "minLength": 1, "type": "string" @@ -167885,28 +170525,8 @@ } ], "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminJob" - } - } - }, - "description": "Accepted", - "headers": { - "Location": { - "schema": { - "description": "URI of the queued job", - "type": "string" - } - }, - "Retry-After": { - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -167958,16 +170578,6 @@ }, "description": "Not Acceptable" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "422": { "content": { "application/problem+json": { @@ -168014,7 +170624,7 @@ "bearerAuth": [] } ], - "summary": "Disable a library and queue the job that deletes it and its items; answers 202 with the job.", + "summary": "Clear an item's intentionally-unmatched mark so it returns to the unmatched list and automatic matching tries it again; an unmarked item is left as is.", "tags": [ "libraries" ], @@ -168023,8 +170633,8 @@ "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true }, - "patch": { - "operationId": "updateLibrary", + "put": { + "operationId": "ignoreUnmatchedItem", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -168049,40 +170659,23 @@ } }, { - "description": "The library to update", + "description": "The catalog item", "in": "path", - "name": "id", + "name": "content_id", "required": true, "schema": { - "description": "The library to update", + "description": "The catalog item", "examples": [ - "1" + "local-ad5847acdd9590cc9d353da12446" ], "minLength": 1, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryUpdate" - } - } - }, - "required": true - }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Library" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -168134,16 +170727,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -168154,26 +170737,6 @@ }, "description": "Conflict" }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -168220,19 +170783,19 @@ "bearerAuth": [] } ], - "summary": "Update a library; omitted members are unchanged. A changed path set queues a rescan and a changed language a quick metadata refresh.", + "summary": "Mark an unmatched, pending or ambiguous movie or series as intentionally unmatched: it leaves the unmatched list and the matcher queues and automatic matching leaves it alone, while it stays playable with its local metadata. Match Item still works and clears the mark. Books cannot be ignored.", "tags": [ "libraries" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}/check-mount": { - "post": { - "operationId": "checkLibraryMount", + "/api/v2/libraries/{id}": { + "delete": { + "operationId": "deleteLibrary", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -168272,15 +170835,28 @@ } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LibraryMountCheck" + "$ref": "#/components/schemas/AdminJob" } } }, - "description": "OK" + "description": "Accepted", + "headers": { + "Location": { + "schema": { + "description": "URI of the queued job", + "type": "string" + } + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -168332,6 +170908,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -168378,7 +170964,7 @@ "bearerAuth": [] } ], - "summary": "Probe every root of a library; a healthy result clears an outstanding empty-root or dead-root warning.", + "summary": "Disable a library and queue the job that deletes it and its items; answers 202 with the job.", "tags": [ "libraries" ], @@ -168386,11 +170972,9 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/libraries/{id}/confirm-empty-root-cleanup": { - "post": { - "operationId": "confirmEmptyRootCleanup", + }, + "patch": { + "operationId": "updateLibrary", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -168415,12 +170999,12 @@ } }, { - "description": "The library", + "description": "The library to update", "in": "path", "name": "id", "required": true, "schema": { - "description": "The library", + "description": "The library to update", "examples": [ "1" ], @@ -168429,12 +171013,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryUpdate" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmptyRootCleanup" + "$ref": "#/components/schemas/Library" } } }, @@ -168490,6 +171084,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -168536,7 +171170,7 @@ "bearerAuth": [] } ], - "summary": "Arm the library's next scan to clean up an empty root once instead of treating it as a lost mount.", + "summary": "Update a library; omitted members are unchanged. A changed path set queues a rescan and a changed language a quick metadata refresh.", "tags": [ "libraries" ], @@ -168546,9 +171180,9 @@ "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}/metadata-match-queue": { - "get": { - "operationId": "getMetadataMatchQueue", + "/api/v2/libraries/{id}/check-mount": { + "post": { + "operationId": "checkLibraryMount", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -168573,48 +171207,18 @@ } }, { - "description": "Library identifier", + "description": "The library", "in": "path", "name": "id", "required": true, "schema": { - "description": "Library identifier", + "description": "The library", "examples": [ "1" ], "minLength": 1, "type": "string" } - }, - { - "description": "Rows per list; default 10, maximum 50", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "description": "Rows per list; default 10, maximum 50", - "examples": [ - 10 - ], - "format": "int64", - "maximum": 50, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Opaque cursor from the previous page", - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "description": "Opaque cursor from the previous page", - "examples": [ - "" - ], - "type": "string" - } } ], "responses": { @@ -168622,7 +171226,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataMatchQueueDetail" + "$ref": "#/components/schemas/LibraryMountCheck" } } }, @@ -168724,17 +171328,19 @@ "bearerAuth": [] } ], - "summary": "One library's matcher backlog counts with a page of its queued movies, series roots and raw files.", + "summary": "Probe every root of a library; a healthy result clears an outstanding empty-root or dead-root warning.", "tags": [ "libraries" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}/metadata-match-queue/cancel": { + "/api/v2/libraries/{id}/confirm-empty-root-cleanup": { "post": { - "operationId": "cancelMetadataMatchQueue", + "operationId": "confirmEmptyRootCleanup", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -168778,7 +171384,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataMatchQueueAction" + "$ref": "#/components/schemas/EmptyRootCleanup" } } }, @@ -168880,7 +171486,7 @@ "bearerAuth": [] } ], - "summary": "Drop every queued matcher entry of the library and suppress its raw backlog; answers what was canceled.", + "summary": "Arm the library's next scan to clean up an empty root once instead of treating it as a lost mount.", "tags": [ "libraries" ], @@ -168890,9 +171496,9 @@ "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}/metadata-match-queue/retry": { - "post": { - "operationId": "retryMetadataMatchQueue", + "/api/v2/libraries/{id}/metadata-match-queue": { + "get": { + "operationId": "getMetadataMatchQueue", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -168917,18 +171523,48 @@ } }, { - "description": "The library", + "description": "Library identifier", "in": "path", "name": "id", "required": true, "schema": { - "description": "The library", + "description": "Library identifier", "examples": [ "1" ], "minLength": 1, "type": "string" } + }, + { + "description": "Rows per list; default 10, maximum 50", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 10, + "description": "Rows per list; default 10, maximum 50", + "examples": [ + 10 + ], + "format": "int64", + "maximum": 50, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Opaque cursor from the previous page", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Opaque cursor from the previous page", + "examples": [ + "" + ], + "type": "string" + } } ], "responses": { @@ -168936,7 +171572,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataMatchQueueAction" + "$ref": "#/components/schemas/MetadataMatchQueueDetail" } } }, @@ -169038,19 +171674,17 @@ "bearerAuth": [] } ], - "summary": "Re-sync and immediately retry every queued matcher entry of the library; answers the counts afterwards.", + "summary": "One library's matcher backlog counts with a page of its queued movies, series roots and raw files.", "tags": [ "libraries" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}/poster": { - "delete": { - "operationId": "deleteLibraryPoster", + "/api/v2/libraries/{id}/metadata-match-queue/cancel": { + "post": { + "operationId": "cancelMetadataMatchQueue", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -169090,8 +171724,15 @@ } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataMatchQueueAction" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -169189,7 +171830,7 @@ "bearerAuth": [] } ], - "summary": "Remove the library's poster; a library without one is left as is.", + "summary": "Drop every queued matcher entry of the library and suppress its raw backlog; answers what was canceled.", "tags": [ "libraries" ], @@ -169197,9 +171838,11 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "uploadLibraryPoster", + } + }, + "/api/v2/libraries/{id}/metadata-match-queue/retry": { + "post": { + "operationId": "retryMetadataMatchQueue", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -169224,12 +171867,12 @@ } }, { - "description": "Library identifier", + "description": "The library", "in": "path", "name": "id", "required": true, "schema": { - "description": "Library identifier", + "description": "The library", "examples": [ "1" ], @@ -169238,27 +171881,12 @@ } } ], - "requestBody": { - "content": { - "multipart/form-data": { - "encoding": { - "poster": { - "contentType": "image/jpeg,image/png,image/webp" - } - }, - "schema": { - "$ref": "#/components/schemas/LibraryPosterForm" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Library" + "$ref": "#/components/schemas/MetadataMatchQueueAction" } } }, @@ -169314,36 +171942,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -169390,7 +171988,7 @@ "bearerAuth": [] } ], - "summary": "Store a poster for the library from a multipart upload (JPEG, PNG or WebP, at most 10 MiB) and answer the library with its new poster URL.", + "summary": "Re-sync and immediately retry every queued matcher entry of the library; answers the counts afterwards.", "tags": [ "libraries" ], @@ -169400,9 +171998,9 @@ "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}/providers": { - "get": { - "operationId": "getLibraryProviders", + "/api/v2/libraries/{id}/poster": { + "delete": { + "operationId": "deleteLibraryPoster", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -169442,15 +172040,8 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryProviders" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -169548,15 +172139,17 @@ "bearerAuth": [] } ], - "summary": "The library's metadata provider chain, per content level. Legacy unlevelled rows (content_level '') that an upgraded database keeps are not exposed; setLibraryProviders preserves them.", + "summary": "Remove the library's poster; a library without one is left as is.", "tags": [ "libraries" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true }, "put": { - "operationId": "setLibraryProviders", + "operationId": "uploadLibraryPoster", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -169597,17 +172190,29 @@ ], "requestBody": { "content": { - "application/json": { + "multipart/form-data": { + "encoding": { + "poster": { + "contentType": "image/jpeg,image/png,image/webp" + } + }, "schema": { - "$ref": "#/components/schemas/LibraryProvidersSet" + "$ref": "#/components/schemas/LibraryPosterForm" } } }, "required": true }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Library" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -169735,7 +172340,7 @@ "bearerAuth": [] } ], - "summary": "Replace the library's whole provider chain and wake the matcher. Legacy unlevelled rows (content_level '') are kept as they are; a level not listed ends up with no providers. The local metadata entry is stored first in its level whatever priority it is sent with.", + "summary": "Store a poster for the library from a multipart upload (JPEG, PNG or WebP, at most 10 MiB) and answer the library with its new poster URL.", "tags": [ "libraries" ], @@ -169745,9 +172350,9 @@ "x-silo-service-backed": true } }, - "/api/v2/libraries/{id}/refresh-metadata": { - "post": { - "operationId": "refreshLibraryMetadata", + "/api/v2/libraries/{id}/providers": { + "get": { + "operationId": "getLibraryProviders", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -169772,12 +172377,12 @@ } }, { - "description": "Library identifier", + "description": "The library", "in": "path", "name": "id", "required": true, "schema": { - "description": "Library identifier", + "description": "The library", "examples": [ "1" ], @@ -169786,39 +172391,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryRefresh", - "description": "Absent picks a quick refresh" - } - } - } - }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminJob" + "$ref": "#/components/schemas/LibraryProviders" } } }, - "description": "Accepted", - "headers": { - "Location": { - "schema": { - "description": "URI of the queued job", - "type": "string" - } - }, - "Retry-After": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -169870,46 +172452,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -169956,30 +172498,18 @@ "bearerAuth": [] } ], - "summary": "Queue a metadata refresh of the library's items; answers 202 with the job.", + "summary": "The library's metadata provider chain, per content level. Legacy unlevelled rows (content_level '') that an upgraded database keeps are not exposed; setLibraryProviders preserves them.", "tags": [ "libraries" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "coalescing", "x-silo-service-backed": true - } - }, - "/api/v2/library-jobs/{job_id}": { - "get": { - "operationId": "getLibraryJob", + }, + "put": { + "operationId": "setLibraryProviders", "parameters": [ { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", "name": "X-Profile-Id", "schema": { @@ -170001,60 +172531,33 @@ } }, { + "description": "Library identifier", "in": "path", - "name": "job_id", + "name": "id", "required": true, "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { + "description": "Library identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminJob" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - }, - "Location": { - "schema": { - "type": "string" - } - }, - "Retry-After": { - "schema": { - "type": "string" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryProvidersSet" } } }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "required": true + }, + "responses": { + "204": { + "description": "No Content" }, "400": { "content": { @@ -170106,7 +172609,7 @@ }, "description": "Not Acceptable" }, - "412": { + "408": { "content": { "application/problem+json": { "schema": { @@ -170114,15 +172617,27 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -170170,18 +172685,19 @@ "bearerAuth": [] } ], - "summary": "Poll accepted library work. Terminal jobs remain available for at least 24 hours.", + "summary": "Replace the library's whole provider chain and wake the matcher. Legacy unlevelled rows (content_level '') are kept as they are; a level not listed ends up with no providers. The local metadata entry is stored first in its level whatever priority it is sent with.", "tags": [ "libraries" ], - "x-silo-class": "profile_scoped", - "x-silo-conditional": true, + "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/library-jobs/{job_id}/cancel": { + "/api/v2/libraries/{id}/refresh-metadata": { "post": { - "operationId": "cancelLibraryJob", + "operationId": "refreshLibraryMetadata", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -170206,37 +172722,31 @@ } }, { + "description": "Library identifier", "in": "path", - "name": "job_id", + "name": "id", "required": true, "schema": { + "description": "Library identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminJob" - } - } - }, - "description": "The job was already canceled.", - "headers": { - "ETag": { - "schema": { - "type": "string" - } - }, - "Location": { - "schema": { - "type": "string" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryRefresh", + "description": "Absent picks a quick refresh" } } - }, + } + }, + "responses": { "202": { "content": { "application/json": { @@ -170247,13 +172757,9 @@ }, "description": "Accepted", "headers": { - "ETag": { - "schema": { - "type": "string" - } - }, "Location": { "schema": { + "description": "URI of the queued job", "type": "string" } }, @@ -170314,6 +172820,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -170324,6 +172840,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -170370,7 +172906,7 @@ "bearerAuth": [] } ], - "summary": "Request best-effort cancellation of metadata refresh. Completed metadata changes remain. Deletion cannot be canceled.", + "summary": "Queue a metadata refresh of the library's items; answers 202 with the job.", "tags": [ "libraries" ], @@ -170380,15 +172916,22 @@ "x-silo-service-backed": true } }, - "/api/v2/library-playback-prefs": { + "/api/v2/library-jobs/{job_id}": { "get": { - "operationId": "listLibraryPlaybackPreferences", + "operationId": "getLibraryJob", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -170406,6 +172949,21 @@ ], "type": "string" } + }, + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -170413,11 +172971,40 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LibraryPlaybackPreferenceCollection" + "$ref": "#/components/schemas/AdminJob" } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -170469,6 +173056,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -170515,23 +173120,23 @@ "bearerAuth": [] } ], - "summary": "List the acting profile's per-library playback overrides.", + "summary": "Poll accepted library work. Terminal jobs remain available for at least 24 hours.", "tags": [ - "preferences" + "libraries" ], "x-silo-class": "profile_scoped", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/library-playback-prefs/{library_id}": { - "delete": { - "operationId": "deleteLibraryPlaybackPreference", + "/api/v2/library-jobs/{job_id}/cancel": { + "post": { + "operationId": "cancelLibraryJob", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -170551,23 +173156,63 @@ } }, { - "description": "The library", "in": "path", - "name": "library_id", + "name": "job_id", "required": true, "schema": { - "description": "The library", - "examples": [ - "1" - ], - "minLength": 1, "type": "string" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminJob" + } + } + }, + "description": "The job was already canceled.", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminJob" + } + } + }, + "description": "Accepted", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Location": { + "schema": { + "type": "string" + } + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -170619,6 +173264,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -170665,17 +173320,19 @@ "bearerAuth": [] } ], - "summary": "Remove the acting profile's playback overrides for a library.", + "summary": "Request best-effort cancellation of metadata refresh. Completed metadata changes remain. Deletion cannot be canceled.", "tags": [ - "preferences" + "libraries" ], - "x-silo-class": "profile_scoped", + "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "coalescing", "x-silo-service-backed": true - }, - "patch": { - "operationId": "updateLibraryPlaybackPreference", + } + }, + "/api/v2/library-playback-prefs": { + "get": { + "operationId": "listLibraryPlaybackPreferences", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -170699,35 +173356,18 @@ ], "type": "string" } - }, - { - "description": "The library", - "in": "path", - "name": "library_id", - "required": true, - "schema": { - "description": "The library", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryPlaybackPreferenceUpdate" - } - } - }, - "required": true - }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryPlaybackPreferenceCollection" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -170779,36 +173419,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -170855,19 +173465,17 @@ "bearerAuth": [] } ], - "summary": "Change the acting profile's playback overrides for a library: omitted members are unchanged, null clears one.", + "summary": "List the acting profile's per-library playback overrides.", "tags": [ "preferences" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/library/{id}/collections": { - "get": { - "operationId": "getLibraryCollections", + "/api/v2/library-playback-prefs/{library_id}": { + "delete": { + "operationId": "deleteLibraryPlaybackPreference", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -170893,12 +173501,12 @@ } }, { - "description": "Library identifier", + "description": "The library", "in": "path", - "name": "id", + "name": "library_id", "required": true, "schema": { - "description": "Library identifier", + "description": "The library", "examples": [ "1" ], @@ -170908,15 +173516,8 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryCollectionTab" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -171014,17 +173615,17 @@ "bearerAuth": [] } ], - "summary": "The library's Collections tab: curated collections, their groups, and the viewer's opted-in personal collections.", + "summary": "Remove the acting profile's playback overrides for a library.", "tags": [ - "libraries" + "preferences" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/library/{id}/collections/{collection_id}/items": { - "get": { - "operationId": "getLibraryCollectionItems", + }, + "patch": { + "operationId": "updateLibraryPlaybackPreference", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -171050,55 +173651,208 @@ } }, { - "description": "Opaque identifier", + "description": "The library", "in": "path", - "name": "id", + "name": "library_id", "required": true, "schema": { - "description": "Opaque identifier", + "description": "The library", "examples": [ "1" ], "minLength": 1, "type": "string" } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryPlaybackPreferenceUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ { - "description": "Opaque identifier", - "in": "path", - "name": "collection_id", + "bearerAuth": [] + } + ], + "summary": "Change the acting profile's playback overrides for a library: omitted members are unchanged, null clears one.", + "tags": [ + "preferences" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/library/{id}/collections": { + "get": { + "operationId": "getLibraryCollections", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", "required": true, "schema": { - "description": "Opaque identifier", "examples": [ "1" ], - "minLength": 1, "type": "string" } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", "examples": [ - 50 + "pvt_5f3a9c1e7b2d4e8fa0c6" ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" + "type": "string" } }, { - "explode": false, - "in": "query", - "name": "cursor", + "description": "Library identifier", + "in": "path", + "name": "id", + "required": true, "schema": { + "description": "Library identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } @@ -171108,7 +173862,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CatalogItemCollection" + "$ref": "#/components/schemas/LibraryCollectionTab" } } }, @@ -171210,7 +173964,7 @@ "bearerAuth": [] } ], - "summary": "Page visible library collection items in stable order. Restart after a collection change.", + "summary": "The library's Collections tab: curated collections, their groups, and the viewer's opted-in personal collections.", "tags": [ "libraries" ], @@ -171218,9 +173972,9 @@ "x-silo-service-backed": true } }, - "/api/v2/library/{id}/layout": { + "/api/v2/library/{id}/collections/{collection_id}/items": { "get": { - "operationId": "getLibraryLayout", + "operationId": "getLibraryCollectionItems", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -171246,18 +174000,57 @@ } }, { - "description": "Library identifier", + "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "description": "Library identifier", + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "collection_id", + "required": true, + "schema": { + "description": "Opaque identifier", "examples": [ "1" ], "minLength": 1, "type": "string" } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } } ], "responses": { @@ -171265,7 +174058,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SectionLayout" + "$ref": "#/components/schemas/CatalogItemCollection" } } }, @@ -171367,7 +174160,7 @@ "bearerAuth": [] } ], - "summary": "The library's section layout for the acting profile, without items.", + "summary": "Page visible library collection items in stable order. Restart after a collection change.", "tags": [ "libraries" ], @@ -171375,9 +174168,9 @@ "x-silo-service-backed": true } }, - "/api/v2/library/{id}/sections": { + "/api/v2/library/{id}/layout": { "get": { - "operationId": "listLibrarySections", + "operationId": "getLibraryLayout", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -171415,25 +174208,6 @@ "minLength": 1, "type": "string" } - }, - { - "description": "Artwork variant to presign; absent picks each surface's default", - "explode": false, - "in": "query", - "name": "image_size", - "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", - "enum": [ - "small", - "medium", - "large", - "original" - ], - "examples": [ - "medium" - ], - "type": "string" - } } ], "responses": { @@ -171441,7 +174215,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SectionCollection" + "$ref": "#/components/schemas/SectionLayout" } } }, @@ -171543,7 +174317,7 @@ "bearerAuth": [] } ], - "summary": "The library's sections with their cards, as the acting profile sees them.", + "summary": "The library's section layout for the acting profile, without items.", "tags": [ "libraries" ], @@ -171551,9 +174325,9 @@ "x-silo-service-backed": true } }, - "/api/v2/library/{id}/sections/{section_id}/items": { + "/api/v2/library/{id}/sections": { "get": { - "operationId": "getLibrarySectionItems", + "operationId": "listLibrarySections", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -171593,26 +174367,12 @@ } }, { - "description": "Section identifier from the layout", - "in": "path", - "name": "section_id", - "required": true, - "schema": { - "description": "Section identifier from the layout", - "examples": [ - "recently_added" - ], - "minLength": 1, - "type": "string" - } - }, - { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -171624,6 +174384,86 @@ ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -171631,7 +174471,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Section" + "$ref": "#/components/schemas/SectionCollection" } } }, @@ -171733,7 +174573,7 @@ "bearerAuth": [] } ], - "summary": "One section of the library with its cards.", + "summary": "The library's sections with their cards, as the acting profile sees them.", "tags": [ "libraries" ], @@ -171741,9 +174581,9 @@ "x-silo-service-backed": true } }, - "/api/v2/library/{id}/user-collections": { + "/api/v2/library/{id}/sections/{section_id}/items": { "get": { - "operationId": "listLibraryUserCollections", + "operationId": "getLibrarySectionItems", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -171781,6 +174621,119 @@ "minLength": 1, "type": "string" } + }, + { + "description": "Section identifier from the layout", + "in": "path", + "name": "section_id", + "required": true, + "schema": { + "description": "Section identifier from the layout", + "examples": [ + "recently_added" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -171788,7 +174741,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserCollectionCollection" + "$ref": "#/components/schemas/Section" } } }, @@ -171890,7 +174843,7 @@ "bearerAuth": [] } ], - "summary": "The viewer's own personal collections opted into this library's tab.", + "summary": "One section of the library with its cards.", "tags": [ "libraries" ], @@ -171898,14 +174851,15 @@ "x-silo-service-backed": true } }, - "/api/v2/livetv/channels": { + "/api/v2/library/{id}/user-collections": { "get": { - "operationId": "listLiveTVChannels", + "operationId": "listLibraryUserCollections", "parameters": [ { - "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -171925,13 +174879,16 @@ } }, { - "description": "Only channels of this tuner", - "explode": false, - "in": "query", - "name": "tuner_id", + "description": "Library identifier", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Only channels of this tuner", - "maxLength": 256, + "description": "Library identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } @@ -171941,7 +174898,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionLiveTVChannel" + "$ref": "#/components/schemas/UserCollectionCollection" } } }, @@ -171997,16 +174954,6 @@ }, "description": "Not Acceptable" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "422": { "content": { "application/problem+json": { @@ -172053,23 +175000,22 @@ "bearerAuth": [] } ], - "summary": "List Live TV channels across every tuner, or one tuner's.", + "summary": "The viewer's own personal collections opted into this library's tab.", "tags": [ - "livetv" + "libraries" ], "x-silo-class": "profile_scoped", "x-silo-service-backed": true } }, - "/api/v2/livetv/channels/{channel_id}/sessions": { - "post": { - "operationId": "startLiveTVSession", + "/api/v2/livetv/channels": { + "get": { + "operationId": "listLiveTVChannels", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -172089,40 +175035,27 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "channel_id", - "required": true, + "description": "Only channels of this tuner", + "explode": false, + "in": "query", + "name": "tuner_id", "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, + "description": "Only channels of this tuner", + "maxLength": 256, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LiveTVSessionStartBody", - "description": "Absent copies the broadcast streams" - } - } - } - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVSession" + "$ref": "#/components/schemas/CollectionLiveTVChannel" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -172174,16 +175107,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -172194,26 +175117,6 @@ }, "description": "Conflict" }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -172260,24 +175163,23 @@ "bearerAuth": [] } ], - "summary": "Claim a tuner and start watching a channel. The reply carries an opaque play URL and the transport to play it with.", + "summary": "List Live TV channels across every tuner, or one tuner's.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/livetv/guide": { - "get": { - "operationId": "getLiveTVGuide", + "/api/v2/livetv/channels/{channel_id}/sessions": { + "post": { + "operationId": "startLiveTVSession", "parameters": [ { - "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -172297,52 +175199,40 @@ } }, { - "description": "Restrict to these channels, repeated (channel_id=a\u0026channel_id=b); absent lists every channel", - "explode": true, - "in": "query", + "description": "Opaque identifier", + "in": "path", "name": "channel_id", + "required": true, "schema": { - "description": "Restrict to these channels, repeated (channel_id=a\u0026channel_id=b); absent lists every channel", - "items": { - "type": "string" - }, - "maxItems": 500, - "type": "array" - } - }, - { - "description": "Window start; default now", - "explode": false, - "in": "query", - "name": "start", - "schema": { - "description": "Window start; default now", - "format": "date-time", - "type": "string" - } - }, - { - "description": "Window end; default start plus 12 hours, at most 7 days after start", - "explode": false, - "in": "query", - "name": "end", - "schema": { - "description": "Window end; default start plus 12 hours, at most 7 days after start", - "format": "date-time", + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVSessionStartBody", + "description": "Absent copies the broadcast streams" + } + } + } + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVGuide" + "$ref": "#/components/schemas/LiveTVSession" } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -172394,6 +175284,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -172404,6 +175304,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -172450,17 +175370,19 @@ "bearerAuth": [] } ], - "summary": "Read the programme guide for a time window, optionally for chosen channels.", + "summary": "Claim a tuner and start watching a channel. The reply carries an opaque play URL and the transport to play it with.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/livetv/programs/{program_id}": { + "/api/v2/livetv/guide": { "get": { - "operationId": "getLiveTVProgram", + "operationId": "getLiveTVGuide", "parameters": [ { "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", @@ -172485,16 +175407,38 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "program_id", - "required": true, + "description": "Restrict to these channels, repeated (channel_id=a\u0026channel_id=b); absent lists every channel", + "explode": true, + "in": "query", + "name": "channel_id", "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, + "description": "Restrict to these channels, repeated (channel_id=a\u0026channel_id=b); absent lists every channel", + "items": { + "type": "string" + }, + "maxItems": 500, + "type": "array" + } + }, + { + "description": "Window start; default now", + "explode": false, + "in": "query", + "name": "start", + "schema": { + "description": "Window start; default now", + "format": "date-time", + "type": "string" + } + }, + { + "description": "Window end; default start plus 12 hours, at most 7 days after start", + "explode": false, + "in": "query", + "name": "end", + "schema": { + "description": "Window end; default start plus 12 hours, at most 7 days after start", + "format": "date-time", "type": "string" } } @@ -172504,7 +175448,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVProgram" + "$ref": "#/components/schemas/LiveTVGuide" } } }, @@ -172616,7 +175560,7 @@ "bearerAuth": [] } ], - "summary": "Read one guide programme.", + "summary": "Read the programme guide for a time window, optionally for chosen channels.", "tags": [ "livetv" ], @@ -172624,9 +175568,9 @@ "x-silo-service-backed": true } }, - "/api/v2/livetv/recordings": { + "/api/v2/livetv/programs/{program_id}": { "get": { - "operationId": "listLiveTVRecordings", + "operationId": "getLiveTVProgram", "parameters": [ { "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", @@ -172651,19 +175595,16 @@ } }, { - "description": "Only recordings in this state", - "explode": false, - "in": "query", - "name": "status", + "description": "Opaque identifier", + "in": "path", + "name": "program_id", + "required": true, "schema": { - "description": "Only recordings in this state", - "enum": [ - "scheduled", - "recording", - "completed", - "failed", - "cancelled" + "description": "Opaque identifier", + "examples": [ + "1" ], + "minLength": 1, "type": "string" } } @@ -172673,7 +175614,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionLiveTVRecording" + "$ref": "#/components/schemas/LiveTVProgram" } } }, @@ -172785,21 +175726,22 @@ "bearerAuth": [] } ], - "summary": "List this profile's DVR recordings; administrators see every recording.", + "summary": "Read one guide programme.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", "x-silo-service-backed": true - }, - "post": { - "operationId": "scheduleLiveTVRecording", + } + }, + "/api/v2/livetv/recordings": { + "get": { + "operationId": "listLiveTVRecordings", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -172817,28 +175759,35 @@ ], "type": "string" } + }, + { + "description": "Only recordings in this state", + "explode": false, + "in": "query", + "name": "status", + "schema": { + "description": "Only recordings in this state", + "enum": [ + "scheduled", + "recording", + "completed", + "failed", + "cancelled" + ], + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LiveTVRecordingBody" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVRecording" + "$ref": "#/components/schemas/CollectionLiveTVRecording" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -172890,16 +175839,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -172910,26 +175849,6 @@ }, "description": "Conflict" }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -172976,19 +175895,15 @@ "bearerAuth": [] } ], - "summary": "Schedule a DVR recording for this profile. Never retried automatically.", + "summary": "List this profile's DVR recordings; administrators see every recording.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/livetv/recordings/{recording_id}": { - "delete": { - "operationId": "cancelLiveTVRecording", + }, + "post": { + "operationId": "scheduleLiveTVRecording", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -173012,24 +175927,20 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVRecordingBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -173037,7 +175948,7 @@ } } }, - "description": "OK" + "description": "Created" }, "400": { "content": { @@ -173089,6 +176000,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -173099,6 +176020,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -173145,24 +176086,25 @@ "bearerAuth": [] } ], - "summary": "Cancel a recording this profile owns; administrators may cancel any.", + "summary": "Schedule a DVR recording for this profile. Never retried automatically.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/livetv/series-rules": { - "get": { - "operationId": "listLiveTVSeriesRules", + "/api/v2/livetv/recordings/{recording_id}": { + "delete": { + "operationId": "cancelLiveTVRecording", "parameters": [ { - "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -173180,6 +176122,20 @@ ], "type": "string" } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -173187,7 +176143,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionLiveTVSeriesRule" + "$ref": "#/components/schemas/LiveTVRecording" } } }, @@ -173299,21 +176255,24 @@ "bearerAuth": [] } ], - "summary": "List this profile's series recording rules; administrators see every rule.", + "summary": "Cancel a recording this profile owns; administrators may cancel any.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, - "post": { - "operationId": "createLiveTVSeriesRule", + } + }, + "/api/v2/livetv/series-rules": { + "get": { + "operationId": "listLiveTVSeriesRules", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -173333,26 +176292,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LiveTVSeriesRuleBody" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveTVSeriesRule" + "$ref": "#/components/schemas/CollectionLiveTVSeriesRule" } } }, - "description": "Created" + "description": "OK" }, "400": { "content": { @@ -173404,16 +176353,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -173424,26 +176363,6 @@ }, "description": "Conflict" }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -173490,19 +176409,15 @@ "bearerAuth": [] } ], - "summary": "Create a series recording rule for this profile. Never retried automatically.", + "summary": "List this profile's series recording rules; administrators see every rule.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/livetv/series-rules/{rule_id}": { - "delete": { - "operationId": "deleteLiveTVSeriesRule", + }, + "post": { + "operationId": "createLiveTVSeriesRule", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -173526,25 +176441,28 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "rule_id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVSeriesRuleBody" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveTVSeriesRule" + } + } + }, + "description": "Created" }, "400": { "content": { @@ -173596,6 +176514,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -173606,6 +176534,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -173652,19 +176600,19 @@ "bearerAuth": [] } ], - "summary": "Delete a series recording rule this profile owns; administrators may delete any.", + "summary": "Create a series recording rule for this profile. Never retried automatically.", "tags": [ "livetv" ], "x-silo-class": "profile_scoped", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/livetv/sessions/{session_id}": { + "/api/v2/livetv/series-rules/{rule_id}": { "delete": { - "operationId": "stopLiveTVSession", + "operationId": "deleteLiveTVSeriesRule", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -173692,7 +176640,7 @@ { "description": "Opaque identifier", "in": "path", - "name": "session_id", + "name": "rule_id", "required": true, "schema": { "description": "Opaque identifier", @@ -173814,7 +176762,7 @@ "bearerAuth": [] } ], - "summary": "Release a session and its tuner. Administrators may release any session.", + "summary": "Delete a series recording rule this profile owns; administrators may delete any.", "tags": [ "livetv" ], @@ -173824,9 +176772,9 @@ "x-silo-service-backed": true } }, - "/api/v2/livetv/sessions/{session_id}/heartbeat": { - "post": { - "operationId": "heartbeatLiveTVSession", + "/api/v2/livetv/sessions/{session_id}": { + "delete": { + "operationId": "stopLiveTVSession", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -173976,7 +176924,7 @@ "bearerAuth": [] } ], - "summary": "Keep an open session's tuner claimed while the player is open, including while paused.", + "summary": "Release a session and its tuner. Administrators may release any session.", "tags": [ "livetv" ], @@ -173986,14 +176934,15 @@ "x-silo-service-backed": true } }, - "/api/v2/markers/files/{file_id}": { - "get": { - "operationId": "getFileMarkers", + "/api/v2/livetv/sessions/{session_id}/heartbeat": { + "post": { + "operationId": "heartbeatLiveTVSession", "parameters": [ { - "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -174015,7 +176964,7 @@ { "description": "Opaque identifier", "in": "path", - "name": "file_id", + "name": "session_id", "required": true, "schema": { "description": "Opaque identifier", @@ -174028,15 +176977,176 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileMarkers" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Keep an open session's tuner claimed while the player is open, including while paused.", + "tags": [ + "livetv" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/markers/files/{file_id}": { + "get": { + "operationId": "getFileMarkers", + "parameters": [ + { + "description": "Optional while no profile on the authenticated account is PIN-protected or access-restricted (content-rating, advisory-age or library limits). Otherwise it is required: a request without it, or with an empty value, is refused with 422 validation_failed at header.x-profile-id; API keys are exempt. When present, it must name a profile of the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "file_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileMarkers" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -175041,9 +178151,9 @@ "x-silo-service-backed": true } }, - "/api/v2/notifications": { + "/api/v2/not-interested": { "get": { - "operationId": "listNotifications", + "operationId": "listNotInterested", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -175068,6 +178178,105 @@ "type": "string" } }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "Page size; default 50, maximum 200", "explode": false, @@ -175086,22 +178295,14 @@ } }, { + "description": "Opaque cursor from page.next_cursor", "explode": false, "in": "query", "name": "cursor", "schema": { - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "status", - "schema": { - "default": "all", - "enum": [ - "all", - "unread" + "description": "Opaque cursor from page.next_cursor", + "examples": [ + "eyJvIjo1MH0" ], "type": "string" } @@ -175112,7 +178313,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationListOutputBody" + "$ref": "#/components/schemas/NotInterestedCollection" } } }, @@ -175214,26 +178415,18 @@ "bearerAuth": [] } ], - "summary": "Manage the acting profile's notification inbox.", + "summary": "List the movies and series the acting profile marked Not interested as catalog cards, newest mark first; items the viewer may not see are omitted.", "tags": [ - "notifications" + "not-interested" ], "x-silo-class": "profile_scoped", "x-silo-service-backed": true } }, - "/api/v2/notifications/capabilities": { - "get": { - "operationId": "getNotificationCapabilities", + "/api/v2/not-interested/{item_id}": { + "delete": { + "operationId": "unmarkNotInterested", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -175258,47 +178451,23 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Catalog item identifier of a movie or series", + "in": "path", + "name": "item_id", + "required": true, "schema": { + "description": "Catalog item identifier of a movie or series", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationCapabilities" - } - } - }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -175350,24 +178519,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -175414,23 +178565,22 @@ "bearerAuth": [] } ], - "summary": "Manage the acting profile's notification inbox.", + "summary": "Clear the acting profile's Not interested mark on the item. An absent mark succeeds and changes nothing.", "tags": [ - "notifications" + "not-interested" ], "x-silo-class": "profile_scoped", - "x-silo-conditional": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - } - }, - "/api/v2/notifications/discord-link": { - "delete": { - "operationId": "unlinkNotificationDiscord", + }, + "get": { + "operationId": "getNotInterested", "parameters": [ { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -175448,11 +178598,32 @@ ], "type": "string" } + }, + { + "description": "Catalog item identifier of a movie or series", + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "description": "Catalog item identifier of a movie or series", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotInterestedEntry" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -175550,24 +178721,21 @@ "bearerAuth": [] } ], - "summary": "Unlink the account's current Discord identity and turn delivery off. Send once; replay can unlink a later connection.", + "summary": "Answer whether the acting profile marked the item Not interested: the mark, or 404 when it did not (or the viewer may not see the item).", "tags": [ - "notifications" + "not-interested" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/notifications/discord-preferences": { - "get": { - "operationId": "getNotificationDiscordPreferences", + }, + "put": { + "operationId": "markNotInterested", "parameters": [ { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -175585,18 +178753,25 @@ ], "type": "string" } + }, + { + "description": "Catalog item identifier of a movie or series", + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "description": "Catalog item identifier of a movie or series", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationDiscordPreferences" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -175694,20 +178869,24 @@ "bearerAuth": [] } ], - "summary": "Read the login account's Discord notification preferences.", + "summary": "Mark a movie or series Not interested for the acting profile: its recommendation and discovery rows leave it out and rank similar titles lower, after a rebuild that starts within seconds. Library browsing, search, item detail, playback, favorites and the watchlist are unchanged. Episodes and seasons are refused with 422; mark the series. Marking a marked title succeeds and changes nothing.", "tags": [ - "notifications" + "not-interested" ], "x-silo-class": "profile_scoped", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true - }, - "put": { - "operationId": "updateNotificationDiscordPreferences", + } + }, + "/api/v2/notifications": { + "get": { + "operationId": "listNotifications", "parameters": [ { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -175725,24 +178904,52 @@ ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationChannelModeInputBody" - } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" } }, - "required": true - }, + { + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "status", + "schema": { + "default": "all", + "enum": [ + "all", + "unread" + ], + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDiscordPreferences" + "$ref": "#/components/schemas/NotificationListOutputBody" } } }, @@ -175798,36 +179005,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -175874,89 +179051,31 @@ "bearerAuth": [] } ], - "summary": "Set the login account's Discord delivery mode.", + "summary": "Manage the acting profile's notification inbox.", "tags": [ "notifications" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/notifications/discord/link/callback": { + "/api/v2/notifications/capabilities": { "get": { - "operationId": "completeNotificationDiscordLink", + "operationId": "getNotificationCapabilities", "parameters": [ { - "in": "query", - "name": "state", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "code", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "error", + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", "schema": { "type": "string" } - } - ], - "responses": { - "302": { - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - }, - "description": "Consent outcome redirect to notification settings", - "headers": { - "Location": { - "schema": { - "type": "string" - } - } - } }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "summary": "Consume Discord OAuth state and redirect to notification settings.", - "tags": [ - "notifications" - ], - "x-silo-class": "public", - "x-silo-raw-protocol": "redirect", - "x-silo-raw-reason": "Discord redirects browsers without Prairie login headers; the stored one-time state authenticates the account-link callback.", - "x-silo-service-backed": true - } - }, - "/api/v2/notifications/discord/link/init": { - "post": { - "operationId": "beginNotificationDiscordLink", - "parameters": [ { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -175974,6 +179093,13 @@ ], "type": "string" } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -175981,11 +179107,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationDiscordLink" + "$ref": "#/components/schemas/NotificationCapabilities" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -176037,6 +179187,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -176083,7 +179251,143 @@ "bearerAuth": [] } ], - "summary": "Begin one account-bound Discord consent flow.", + "summary": "Manage the acting profile's notification inbox.", + "tags": [ + "notifications" + ], + "x-silo-class": "profile_scoped", + "x-silo-conditional": true, + "x-silo-service-backed": true + } + }, + "/api/v2/notifications/discord-link": { + "delete": { + "operationId": "unlinkNotificationDiscord", + "parameters": [ + { + "description": "Optional. When present, it must name a profile of the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Unlink the account's current Discord identity and turn delivery off. Send once; replay can unlink a later connection.", "tags": [ "notifications" ], @@ -176093,15 +179397,14 @@ "x-silo-service-backed": true } }, - "/api/v2/notifications/email-preferences": { + "/api/v2/notifications/discord-preferences": { "get": { - "operationId": "getNotificationEmailPreferences", + "operationId": "getNotificationDiscordPreferences", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -176126,7 +179429,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationEmailPreferences" + "$ref": "#/components/schemas/NotificationDiscordPreferences" } } }, @@ -176228,7 +179531,7 @@ "bearerAuth": [] } ], - "summary": "Read the acting profile's email notification preferences.", + "summary": "Read the login account's Discord notification preferences.", "tags": [ "notifications" ], @@ -176236,13 +179539,12 @@ "x-silo-service-backed": true }, "put": { - "operationId": "updateNotificationEmailPreferences", + "operationId": "updateNotificationDiscordPreferences", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -176277,7 +179579,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationEmailPreferences" + "$ref": "#/components/schemas/NotificationDiscordPreferences" } } }, @@ -176409,7 +179711,7 @@ "bearerAuth": [] } ], - "summary": "Set the acting profile's email delivery mode.", + "summary": "Set the login account's Discord delivery mode.", "tags": [ "notifications" ], @@ -176419,15 +179721,79 @@ "x-silo-service-backed": true } }, - "/api/v2/notifications/email-preferences/address": { - "delete": { - "operationId": "clearNotificationEmailAddress", + "/api/v2/notifications/discord/link/callback": { + "get": { + "operationId": "completeNotificationDiscordLink", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "query", + "name": "state", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error", + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Consent outcome redirect to notification settings", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Consume Discord OAuth state and redirect to notification settings.", + "tags": [ + "notifications" + ], + "x-silo-class": "public", + "x-silo-raw-protocol": "redirect", + "x-silo-raw-reason": "Discord redirects browsers without Prairie login headers; the stored one-time state authenticates the account-link callback.", + "x-silo-service-backed": true + } + }, + "/api/v2/notifications/discord/link/init": { + "post": { + "operationId": "beginNotificationDiscordLink", + "parameters": [ + { + "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -176452,7 +179818,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationEmailPreferences" + "$ref": "#/components/schemas/NotificationDiscordLink" } } }, @@ -176554,7 +179920,7 @@ "bearerAuth": [] } ], - "summary": "Clear the profile's verified and pending notification address and turn email delivery off. Send once; replay may clear a newer address.", + "summary": "Begin one account-bound Discord consent flow.", "tags": [ "notifications" ], @@ -176562,9 +179928,480 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "requestNotificationEmailVerification", + } + }, + "/api/v2/notifications/email-preferences": { + "get": { + "operationId": "getNotificationEmailPreferences", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationEmailPreferences" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read the acting profile's email notification preferences.", + "tags": [ + "notifications" + ], + "x-silo-class": "profile_scoped", + "x-silo-service-backed": true + }, + "put": { + "operationId": "updateNotificationEmailPreferences", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelModeInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationEmailPreferences" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Set the acting profile's email delivery mode.", + "tags": [ + "notifications" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + } + }, + "/api/v2/notifications/email-preferences/address": { + "delete": { + "operationId": "clearNotificationEmailAddress", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationEmailPreferences" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Clear the profile's verified and pending notification address and turn email delivery off. Send once; replay may clear a newer address.", + "tags": [ + "notifications" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + }, + "put": { + "operationId": "requestNotificationEmailVerification", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -177658,24 +181495,407 @@ "bearerAuth": [] } ], - "summary": "Manage the acting profile's notification inbox.", + "summary": "Manage the acting profile's notification inbox.", + "tags": [ + "notifications" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/notifications/push/apple/display/{delivery_id}": { + "get": { + "operationId": "getNotificationApplePushDisplay", + "parameters": [ + { + "description": "Required for access tokens and API keys. Display tokens bind the profile from their claims and ignore this header; a display token is accepted only by this operation.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "delivery_id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationPushDisplay" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read compact display metadata for an Apple push notification.", + "tags": [ + "notifications" + ], + "x-silo-class": "profile_scoped", + "x-silo-service-backed": true + } + }, + "/api/v2/notifications/push/devices": { + "post": { + "operationId": "registerPushDevice", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Secret 32-byte base64url installation credential. Persist privately; never rotate on retry or account switch.", + "in": "header", + "name": "X-Push-Installation-Key", + "required": true, + "schema": { + "description": "Secret 32-byte base64url installation credential. Persist privately; never rotate on retry or account switch.", + "maxLength": 43, + "minLength": 43, + "type": "string" + } + }, + { + "description": "Positive decimal int64, persisted and incremented for each new installation intent across account/profile switches. Reuse exactly after uncertainty.", + "in": "header", + "name": "X-Push-Generation", + "required": true, + "schema": { + "description": "Positive decimal int64, persisted and incremented for each new installation intent across account/profile switches. Reuse exactly after uncertainty.", + "maxLength": 19, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AndroidPushRegistrationBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AndroidPushRegistrationReceipt" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Apply one ordered Android installation registration intent. Replay only the exact persisted generation, credential and payload; never advance a generation to retry uncertainty.", "tags": [ "notifications" ], "x-silo-class": "profile_scoped", "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "domain_identity", "x-silo-service-backed": true } }, - "/api/v2/notifications/push/apple/display/{delivery_id}": { + "/api/v2/notifications/push/devices/capabilities": { "get": { - "operationId": "getNotificationApplePushDisplay", + "operationId": "getPushRegistrationCapabilities", "parameters": [ { - "description": "Required for access tokens and API keys. Display tokens bind the profile from their claims and ignore this header; a display token is accepted only by this operation.", + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -177695,16 +181915,9 @@ } }, { - "description": "Opaque identifier", - "in": "path", - "name": "delivery_id", - "required": true, + "in": "header", + "name": "If-None-Match", "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, "type": "string" } } @@ -177714,11 +181927,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotificationPushDisplay" + "$ref": "#/components/schemas/AndroidPushRegistrationCapability" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -177770,6 +182007,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -177816,17 +182071,18 @@ "bearerAuth": [] } ], - "summary": "Read compact display metadata for an Apple push notification.", + "summary": "Describe ordered registration support separately from push delivery availability.", "tags": [ "notifications" ], "x-silo-class": "profile_scoped", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/notifications/push/devices": { - "post": { - "operationId": "registerPushDevice", + "/api/v2/notifications/push/devices/{device_id}": { + "delete": { + "operationId": "unregisterPushDevice", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -177873,28 +182129,19 @@ "maxLength": 19, "type": "string" } + }, + { + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AndroidPushRegistrationBody" - } - } - }, - "required": true - }, "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AndroidPushRegistrationReceipt" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -177946,16 +182193,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -177966,26 +182203,6 @@ }, "description": "Conflict" }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -178032,7 +182249,7 @@ "bearerAuth": [] } ], - "summary": "Apply one ordered Android installation registration intent. Replay only the exact persisted generation, credential and payload; never advance a generation to retry uncertainty.", + "summary": "Apply an ordered removal for the installation's current account/profile. Tombstones prevent stale registration replay.", "tags": [ "notifications" ], @@ -178042,18 +182259,10 @@ "x-silo-service-backed": true } }, - "/api/v2/notifications/push/devices/capabilities": { - "get": { - "operationId": "getPushRegistrationCapabilities", + "/api/v2/notifications/read-all": { + "post": { + "operationId": "markNotificationsRead", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -178076,49 +182285,21 @@ ], "type": "string" } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AndroidPushRegistrationCapability" - } - } - }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationReadAllInputBody" } } }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "required": true + }, + "responses": { + "204": { + "description": "No Content" }, "400": { "content": { @@ -178170,7 +182351,7 @@ }, "description": "Not Acceptable" }, - "412": { + "408": { "content": { "application/problem+json": { "schema": { @@ -178178,15 +182359,27 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" }, "422": { "content": { @@ -178234,18 +182427,20 @@ "bearerAuth": [] } ], - "summary": "Describe ordered registration support separately from push delivery availability.", + "summary": "Manage the acting profile's notification inbox.", "tags": [ "notifications" ], "x-silo-class": "profile_scoped", - "x-silo-conditional": true, + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/notifications/push/devices/{device_id}": { - "delete": { - "operationId": "unregisterPushDevice", + "/api/v2/notifications/series": { + "get": { + "description": "Every series the profile favorited, put on its watchlist, is partway through, or waits on a next episode of, and every series it muted, filtered and sorted as asked (by title by default). Each carries its reasons, which of them the profile's notification preferences let notify, and whether the profile muted it. Series the viewer may not see are omitted.", + "operationId": "listNotificationSeries", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -178271,40 +182466,212 @@ } }, { - "description": "Secret 32-byte base64url installation credential. Persist privately; never rotate on retry or account switch.", - "in": "header", - "name": "X-Push-Installation-Key", - "required": true, + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", "schema": { - "description": "Secret 32-byte base64url installation credential. Persist privately; never rotate on retry or account switch.", - "maxLength": 43, - "minLength": 43, + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], "type": "string" } }, { - "description": "Positive decimal int64, persisted and incremented for each new installation intent across account/profile switches. Reuse exactly after uncertainty.", - "in": "header", - "name": "X-Push-Generation", - "required": true, + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", "schema": { - "description": "Positive decimal int64, persisted and incremented for each new installation intent across account/profile switches. Reuse exactly after uncertainty.", - "maxLength": 19, + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Opaque cursor from page.next_cursor", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Opaque cursor from page.next_cursor", + "examples": [ + "eyJ0IjoiYmVhciJ9" + ], "type": "string" } }, { - "in": "path", - "name": "device_id", - "required": true, + "description": "active keeps the series that notify; inactive keeps the ones that do not, because the profile's preferences silence every reason or the profile muted the series", + "explode": false, + "in": "query", + "name": "status", + "schema": { + "default": "all", + "description": "active keeps the series that notify; inactive keeps the ones that do not, because the profile's preferences silence every reason or the profile muted the series", + "enum": [ + "all", + "active", + "inactive" + ], + "type": "string" + } + }, + { + "description": "Keeps the series with this reason, whether or not the preferences let it notify; absent keeps every reason", + "explode": false, + "in": "query", + "name": "reason", + "schema": { + "description": "Keeps the series with this reason, whether or not the preferences let it notify; absent keeps every reason", + "enum": [ + "favorite", + "watchlist", + "continue_watching", + "next_up" + ], + "type": "string" + } + }, + { + "description": "Keeps the series whose title or sort title contains this text, case-insensitively", + "explode": false, + "in": "query", + "name": "q", + "schema": { + "description": "Keeps the series whose title or sort title contains this text, case-insensitively", + "examples": [ + "bear" + ], + "maxLength": 200, + "type": "string" + } + }, + { + "description": "One field, descending with a leading '-'. added_at is when the series joined the catalog; latest_episode_added when it last gained an episode. A series without a year or an episode sorts last either way; ties order by title.", + "explode": false, + "in": "query", + "name": "sort", "schema": { + "default": "title", + "description": "One field, descending with a leading '-'. added_at is when the series joined the catalog; latest_episode_added when it last gained an episode. A series without a year or an episode sorts last either way; ties order by title.", + "enum": [ + "title", + "-title", + "year", + "-year", + "added_at", + "-added_at", + "latest_episode_added", + "-latest_episode_added" + ], "type": "string" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSeriesCollection" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -178356,16 +182723,6 @@ }, "description": "Not Acceptable" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "422": { "content": { "application/problem+json": { @@ -178412,19 +182769,18 @@ "bearerAuth": [] } ], - "summary": "Apply an ordered removal for the installation's current account/profile. Tombstones prevent stale registration replay.", + "summary": "List the series whose new episodes can notify the acting profile.", "tags": [ "notifications" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "domain_identity", "x-silo-service-backed": true } }, - "/api/v2/notifications/read-all": { - "post": { - "operationId": "markNotificationsRead", + "/api/v2/notifications/series/{series_id}/mute": { + "delete": { + "description": "The series notifies the acting profile again for its active reasons. The mute applies when notification delivery evaluates a new episode, normally within a minute of it arriving: an episode evaluated while the series was muted is not announced later, and one still waiting to be evaluated when the profile unmutes is announced. Unmuting a series that is not muted succeeds and changes nothing. No access check runs, so a profile can lift a mute on a series it can no longer see.", + "operationId": "unmuteNotificationSeries", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -178448,18 +182804,22 @@ ], "type": "string" } + }, + { + "description": "Catalog item identifier of the series", + "in": "path", + "name": "series_id", + "required": true, + "schema": { + "description": "Catalog item identifier of the series", + "examples": [ + "series-tvdb-81189" + ], + "minLength": 1, + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationReadAllInputBody" - } - } - }, - "required": true - }, "responses": { "204": { "description": "No Content" @@ -178514,7 +182874,7 @@ }, "description": "Not Acceptable" }, - "408": { + "422": { "content": { "application/problem+json": { "schema": { @@ -178522,9 +182882,9 @@ } } }, - "description": "Request Timeout" + "description": "Unprocessable Entity" }, - "413": { + "429": { "content": { "application/problem+json": { "schema": { @@ -178532,9 +182892,9 @@ } } }, - "description": "Request Entity Too Large" + "description": "Too Many Requests" }, - "415": { + "500": { "content": { "application/problem+json": { "schema": { @@ -178542,7 +182902,128 @@ } } }, - "description": "Unsupported Media Type" + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Unmute new-episode notifications for one series.", + "tags": [ + "notifications" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + }, + "put": { + "description": "The acting profile gets no new-episode notification for the series on any channel, whatever its reasons, until it unmutes. Favorites, the watchlist, Home, and watch history are unchanged, and other profiles are unaffected. Muting a series already muted succeeds and changes nothing. A series the viewer may not see, or an item that is not a series, is 404.", + "operationId": "muteNotificationSeries", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Catalog item identifier of the series", + "in": "path", + "name": "series_id", + "required": true, + "schema": { + "description": "Catalog item identifier of the series", + "examples": [ + "series-tvdb-81189" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" }, "422": { "content": { @@ -178590,7 +183071,7 @@ "bearerAuth": [] } ], - "summary": "Manage the acting profile's notification inbox.", + "summary": "Mute new-episode notifications for one series.", "tags": [ "notifications" ], @@ -182272,6 +186753,124 @@ "x-silo-service-backed": true } }, + "/api/v2/playback/bandwidth-probe": { + "get": { + "operationId": "getPlaybackBandwidthProbe", + "parameters": [ + { + "description": "Number of bytes to send. A client may close the connection early once it has timed enough of the transfer.", + "in": "query", + "name": "size", + "schema": { + "default": 1000000, + "format": "int64", + "maximum": 100000000, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Random bytes", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "Content-Length": { + "schema": { + "format": "int64", + "type": "integer" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Download random bytes to measure throughput to this server. Clients time the transfer and send the result as bandwidth_estimate_kbps; the bytes count as transfer egress, not playback. A server runs at most 32 probes at once; an account may run two of them and draw up to 400 MB per 10 minutes. Beyond any limit the request gets 429 with Retry-After.", + "tags": [ + "playback" + ], + "x-silo-class": "authenticated", + "x-silo-raw-protocol": "bandwidth-probe", + "x-silo-raw-reason": "A throughput measurement needs an unbuffered, uncompressible byte stream the client can time and abandon mid-transfer." + } + }, "/api/v2/playback/capabilities": { "get": { "operationId": "getPlaybackCapabilities", @@ -183813,6 +188412,18 @@ "type": "string" } }, + { + "description": "Set by the multivariant playlist of a plan with subtitle.hls_rendition: video selects the media playlist, subtitles the WebVTT subtitle playlist. Absent, such a plan's manifest is the multivariant playlist.", + "in": "query", + "name": "prairie_rendition", + "schema": { + "enum": [ + "video", + "subtitles" + ], + "type": "string" + } + }, { "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", @@ -184015,6 +188626,7 @@ } }, { + "description": "Segment name from the media playlist. sub_NNNNN.vtt names a WebVTT subtitle rendition segment, answered from the cues already produced; one the transcode does not reach in time is an empty WebVTT document.", "in": "path", "name": "name", "required": true, @@ -184105,6 +188717,13 @@ "type": "string" } }, + "text/vtt": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + }, "video/mp2t": { "schema": { "contentMediaType": "application/octet-stream", @@ -184166,6 +188785,13 @@ "type": "string" } }, + "text/vtt": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + }, "video/mp2t": { "schema": { "contentMediaType": "application/octet-stream", @@ -185558,24 +190184,368 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Discover policy availability and runtime health.", + "tags": [ + "policy" + ], + "x-silo-class": "profile_scoped", + "x-silo-conditional": true, + "x-silo-service-backed": true + } + }, + "/api/v2/profile/sections": { + "delete": { + "operationId": "resetProfileSectionOverrides", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The page the overrides apply to", + "explode": false, + "in": "query", + "name": "scope", + "schema": { + "default": "home", + "description": "The page the overrides apply to", + "enum": [ + "home", + "library" + ], + "examples": [ + "home" + ], + "type": "string" + } + }, + { + "description": "The library; required when scope is library and refused otherwise", + "explode": false, + "in": "query", + "name": "library_id", + "schema": { + "description": "The library; required when scope is library and refused otherwise", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete the acting profile's section overrides for one page, restoring the admin layout.", + "tags": [ + "profile_sections" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + }, + "get": { + "operationId": "listProfileSectionOverrides", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The page the overrides apply to", + "explode": false, + "in": "query", + "name": "scope", + "schema": { + "default": "home", + "description": "The page the overrides apply to", + "enum": [ + "home", + "library" + ], + "examples": [ + "home" + ], + "type": "string" + } + }, + { + "description": "The library; required when scope is library and refused otherwise", + "explode": false, + "in": "query", + "name": "library_id", + "schema": { + "description": "The library; required when scope is library and refused otherwise", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectionOverrideCollection" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, "422": { "content": { "application/problem+json": { @@ -185622,18 +190592,15 @@ "bearerAuth": [] } ], - "summary": "Discover policy availability and runtime health.", + "summary": "List the acting profile's saved section overrides for one page.", "tags": [ - "policy" + "profile_sections" ], "x-silo-class": "profile_scoped", - "x-silo-conditional": true, "x-silo-service-backed": true - } - }, - "/api/v2/profile/sections": { - "delete": { - "operationId": "resetProfileSectionOverrides", + }, + "put": { + "operationId": "replaceProfileSectionOverrides", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -185691,6 +190658,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectionOverrideSet" + } + } + }, + "required": true + }, "responses": { "204": { "description": "No Content" @@ -185745,6 +190722,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -185791,7 +190798,7 @@ "bearerAuth": [] } ], - "summary": "Delete the acting profile's section overrides for one page, restoring the admin layout.", + "summary": "Replace the acting profile's section overrides for one page.", "tags": [ "profile_sections" ], @@ -185799,9 +190806,11 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, + } + }, + "/api/v2/profile/sections/flags": { "get": { - "operationId": "listProfileSectionOverrides", + "operationId": "getProfileSectionFlags", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -185825,38 +190834,6 @@ ], "type": "string" } - }, - { - "description": "The page the overrides apply to", - "explode": false, - "in": "query", - "name": "scope", - "schema": { - "default": "home", - "description": "The page the overrides apply to", - "enum": [ - "home", - "library" - ], - "examples": [ - "home" - ], - "type": "string" - } - }, - { - "description": "The library; required when scope is library and refused otherwise", - "explode": false, - "in": "query", - "name": "library_id", - "schema": { - "description": "The library; required when scope is library and refused otherwise", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], "responses": { @@ -185864,7 +190841,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SectionOverrideCollection" + "$ref": "#/components/schemas/ProfileSectionFlags" } } }, @@ -185966,15 +190943,16 @@ "bearerAuth": [] } ], - "summary": "List the acting profile's saved section overrides for one page.", + "summary": "Get what this server lets profiles do to their pages.", "tags": [ "profile_sections" ], - "x-silo-class": "profile_scoped", - "x-silo-service-backed": true - }, - "put": { - "operationId": "replaceProfileSectionOverrides", + "x-silo-class": "profile_scoped" + } + }, + "/api/v2/profile/sections/settings": { + "get": { + "operationId": "getProfileSectionSettings", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -186032,19 +191010,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SectionOverrideSet" - } - } - }, - "required": true - }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileSectionSettingCollection" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -186096,36 +191071,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -186172,25 +191117,22 @@ "bearerAuth": [] } ], - "summary": "Replace the acting profile's section overrides for one page.", + "summary": "Get one page's sections as the acting profile sees them, with its overrides applied.", "tags": [ "profile_sections" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/profile/sections/flags": { + "/api/v2/profiles": { "get": { - "operationId": "getProfileSectionFlags", + "operationId": "listProfiles", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -186215,7 +191157,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileSectionFlags" + "$ref": "#/components/schemas/ProfileCollection" } } }, @@ -186317,22 +191259,20 @@ "bearerAuth": [] } ], - "summary": "Get what this server lets profiles do to their pages.", + "summary": "List the household profiles on the signed-in account.", "tags": [ - "profile_sections" + "profiles" ], - "x-silo-class": "profile_scoped" - } - }, - "/api/v2/profile/sections/settings": { - "get": { - "operationId": "getProfileSectionSettings", + "x-silo-class": "profile_scoped", + "x-silo-service-backed": true + }, + "post": { + "operationId": "createProfile", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -186350,50 +191290,36 @@ ], "type": "string" } - }, - { - "description": "The page the overrides apply to", - "explode": false, - "in": "query", - "name": "scope", - "schema": { - "default": "home", - "description": "The page the overrides apply to", - "enum": [ - "home", - "library" - ], - "examples": [ - "home" - ], - "type": "string" - } - }, - { - "description": "The library; required when scope is library and refused otherwise", - "explode": false, - "in": "query", - "name": "library_id", - "schema": { - "description": "The library; required when scope is library and refused otherwise", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileSectionSettingCollection" + "$ref": "#/components/schemas/Profile" } } }, - "description": "OK" + "description": "Created", + "headers": { + "Location": { + "schema": { + "description": "The created profile's resource path", + "type": "string" + } + } + } }, "400": { "content": { @@ -186445,6 +191371,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -186491,17 +191457,19 @@ "bearerAuth": [] } ], - "summary": "Get one page's sections as the acting profile sees them, with its overrides applied.", + "summary": "Create a household profile.", "tags": [ - "profile_sections" + "profiles" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/profiles": { + "/api/v2/profiles/household/sessions": { "get": { - "operationId": "listProfiles", + "operationId": "listHouseholdSessions", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -186531,7 +191499,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileCollection" + "$ref": "#/components/schemas/PlaybackSessionCollection" } } }, @@ -186633,15 +191601,17 @@ "bearerAuth": [] } ], - "summary": "List the household profiles on the signed-in account.", + "summary": "List the live playback sessions on the signed-in account, for a household manager.", "tags": [ "profiles" ], "x-silo-class": "profile_scoped", "x-silo-service-backed": true - }, - "post": { - "operationId": "createProfile", + } + }, + "/api/v2/profiles/{id}": { + "delete": { + "operationId": "deleteProfile", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -186664,36 +191634,25 @@ ], "type": "string" } + }, + { + "description": "The profile", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The profile", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProfileCreate" - } - } - }, - "required": true - }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Profile" - } - } - }, - "description": "Created", - "headers": { - "Location": { - "schema": { - "description": "The created profile's resource path", - "type": "string" - } - } - } + "204": { + "description": "No Content" }, "400": { "content": { @@ -186745,16 +191704,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, "409": { "content": { "application/problem+json": { @@ -186765,26 +191714,6 @@ }, "description": "Conflict" }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -186831,7 +191760,7 @@ "bearerAuth": [] } ], - "summary": "Create a household profile.", + "summary": "Delete a household profile; the primary profile cannot be deleted.", "tags": [ "profiles" ], @@ -186839,11 +191768,9 @@ "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/profiles/household/sessions": { - "get": { - "operationId": "listHouseholdSessions", + }, + "patch": { + "operationId": "updateProfile", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -186866,14 +191793,38 @@ ], "type": "string" } + }, + { + "description": "The profile to update", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "The profile to update", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileUpdate" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlaybackSessionCollection" + "$ref": "#/components/schemas/Profile" } } }, @@ -186929,6 +191880,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -186975,17 +191966,19 @@ "bearerAuth": [] } ], - "summary": "List the live playback sessions on the signed-in account, for a household manager.", + "summary": "Update a household profile; omitted members are unchanged.", "tags": [ "profiles" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/profiles/{id}": { + "/api/v2/profiles/{id}/avatar": { "delete": { - "operationId": "deleteProfile", + "operationId": "deleteProfileAvatar", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -187078,16 +192071,6 @@ }, "description": "Not Acceptable" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "422": { "content": { "application/problem+json": { @@ -187134,7 +192117,7 @@ "bearerAuth": [] } ], - "summary": "Delete a household profile; the primary profile cannot be deleted.", + "summary": "Remove a profile's uploaded avatar; a preset avatar is left as is.", "tags": [ "profiles" ], @@ -187143,8 +192126,8 @@ "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true }, - "patch": { - "operationId": "updateProfile", + "put": { + "operationId": "uploadProfileAvatar", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -187169,12 +192152,12 @@ } }, { - "description": "The profile to update", + "description": "The profile", "in": "path", "name": "id", "required": true, "schema": { - "description": "The profile to update", + "description": "The profile", "examples": [ "1" ], @@ -187185,9 +192168,14 @@ ], "requestBody": { "content": { - "application/json": { + "multipart/form-data": { + "encoding": { + "avatar": { + "contentType": "image/jpeg,image/png,image/webp" + } + }, "schema": { - "$ref": "#/components/schemas/ProfileUpdate" + "$ref": "#/components/schemas/ProfileAvatarForm" } } }, @@ -187264,16 +192252,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "413": { "content": { "application/problem+json": { @@ -187340,7 +192318,7 @@ "bearerAuth": [] } ], - "summary": "Update a household profile; omitted members are unchanged.", + "summary": "Replace a profile's avatar with an uploaded image (multipart form, part `avatar`: JPEG, PNG or WebP, at most 10 MiB; the whole request, framing included, at most 11 MiB).", "tags": [ "profiles" ], @@ -187350,9 +192328,10 @@ "x-silo-service-backed": true } }, - "/api/v2/profiles/{id}/avatar": { - "delete": { - "operationId": "deleteProfileAvatar", + "/api/v2/profiles/{id}/verify-pin": { + "post": { + "description": "Wrong PINs are counted per profile, Jellyfin password#PIN sign-ins included. Redis shares the count across server nodes; without Redis, each process counts its own attempts. Up to five attempts are allowed; the fifth wrong one locks the profile for five minutes, during which every check, even with the right PIN, is refused with 429 rate_limited and a Retry-After header giving the seconds left. A correct PIN while not locked clears the count, and an unlocked count expires five minutes after its first attempt. Each check affects the count and must not be automatically retried.", + "operationId": "verifyProfilePIN", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -187377,12 +192356,12 @@ } }, { - "description": "The profile", + "description": "The profile whose PIN is checked", "in": "path", "name": "id", "required": true, "schema": { - "description": "The profile", + "description": "The profile whose PIN is checked", "examples": [ "1" ], @@ -187391,9 +192370,26 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfilePINCheck" + } + } + }, + "required": true + }, "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileVerification" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -187445,6 +192441,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -187491,22 +192517,24 @@ "bearerAuth": [] } ], - "summary": "Remove a profile's uploaded avatar; a preset avatar is left as is.", + "summary": "Check a profile's PIN; a match issues the X-Profile-Token that unlocks the profile for this login session.", "tags": [ "profiles" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "put": { - "operationId": "uploadProfileAvatar", + } + }, + "/api/v2/progress": { + "get": { + "operationId": "listProgress", "parameters": [ { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -187526,41 +192554,73 @@ } }, { - "description": "The profile", - "in": "path", - "name": "id", - "required": true, + "description": "Only entries in this state; absent lists every entry", + "explode": false, + "in": "query", + "name": "status", "schema": { - "description": "The profile", + "description": "Only entries in this state; absent lists every entry", + "enum": [ + "in_progress", + "completed" + ], + "examples": [ + "in_progress" + ], + "type": "string" + } + }, + { + "description": "Only entries whose item is in this library", + "explode": false, + "in": "query", + "name": "library_id", + "schema": { + "description": "Only entries whose item is in this library", "examples": [ "1" ], "minLength": 1, "type": "string" } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "encoding": { - "avatar": { - "contentType": "image/jpeg,image/png,image/webp" - } - }, - "schema": { - "$ref": "#/components/schemas/ProfileAvatarForm" - } + }, + { + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Page size; default 50, maximum 200", + "examples": [ + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" } }, - "required": true - }, + { + "description": "Opaque cursor from page.next_cursor", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Opaque cursor from page.next_cursor", + "examples": [ + "eyJvZmZzZXQiOjUwfQ" + ], + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Profile" + "$ref": "#/components/schemas/ProgressCollection" } } }, @@ -187616,36 +192676,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -187692,25 +192722,31 @@ "bearerAuth": [] } ], - "summary": "Replace a profile's avatar with an uploaded image (multipart form, part `avatar`: JPEG, PNG or WebP, at most 10 MiB; the whole request, framing included, at most 11 MiB).", + "summary": "List the acting profile's watch progress, newest change first.", "tags": [ - "profiles" + "progress" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/profiles/{id}/verify-pin": { - "post": { - "description": "Wrong PINs are counted per profile, Jellyfin password#PIN sign-ins included. Redis shares the count across server nodes; without Redis, each process counts its own attempts. Up to five attempts are allowed; the fifth wrong one locks the profile for five minutes, during which every check, even with the right PIN, is refused with 429 rate_limited and a Retry-After header giving the seconds left. A correct PIN while not locked clears the count, and an unlocked count expires five minutes after its first attempt. Each check affects the count and must not be automatically retried.", - "operationId": "verifyProfilePIN", + "/api/v2/progress/capabilities": { + "get": { + "operationId": "getProgressCapabilities", "parameters": [ { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -187730,40 +192766,47 @@ } }, { - "description": "The profile whose PIN is checked", - "in": "path", - "name": "id", - "required": true, + "in": "header", + "name": "If-None-Match", "schema": { - "description": "The profile whose PIN is checked", - "examples": [ - "1" - ], - "minLength": 1, "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProfilePINCheck" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileVerification" + "$ref": "#/components/schemas/ProgressCapabilities" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -187815,17 +192858,7 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { @@ -187833,17 +192866,15 @@ } } }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { - "$ref": "#/components/schemas/Problem" + "type": "string" } } - }, - "description": "Unsupported Media Type" + } }, "422": { "content": { @@ -187891,18 +192922,18 @@ "bearerAuth": [] } ], - "summary": "Check a profile's PIN; a match issues the X-Profile-Token that unlocks the profile for this login session.", + "summary": "The watch progress features this server supports, such as clearing a resume position.", "tags": [ - "profiles" + "progress" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/progress": { - "get": { - "operationId": "listProgress", + "/api/v2/progress/{media_item_id}/position": { + "delete": { + "operationId": "clearResumePosition", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -187928,77 +192959,23 @@ } }, { - "description": "Only entries in this state; absent lists every entry", - "explode": false, - "in": "query", - "name": "status", - "schema": { - "description": "Only entries in this state; absent lists every entry", - "enum": [ - "in_progress", - "completed" - ], - "examples": [ - "in_progress" - ], - "type": "string" - } - }, - { - "description": "Only entries whose item is in this library", - "explode": false, - "in": "query", - "name": "library_id", + "description": "A movie or episode", + "in": "path", + "name": "media_item_id", + "required": true, "schema": { - "description": "Only entries whose item is in this library", + "description": "A movie or episode", "examples": [ - "1" + "movie-8f2c1a" ], "minLength": 1, "type": "string" } - }, - { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", - "examples": [ - 50 - ], - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Opaque cursor from page.next_cursor", - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "description": "Opaque cursor from page.next_cursor", - "examples": [ - "eyJvZmZzZXQiOjUwfQ" - ], - "type": "string" - } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProgressCollection" - } - } - }, - "description": "OK" + "204": { + "description": "No Content" }, "400": { "content": { @@ -188096,11 +193073,12 @@ "bearerAuth": [] } ], - "summary": "List the acting profile's watch progress, newest change first.", + "summary": "Clear the acting profile's resume position on a movie or episode, keeping its watched state and watch history. An item without a resume position is a no-op.", "tags": [ "progress" ], "x-silo-class": "profile_scoped", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, @@ -188829,6 +193807,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -188938,7 +194015,7 @@ "bearerAuth": [] } ], - "summary": "Cards recommended because the acting profile watched the item, minus what it has watched or rated low.", + "summary": "Cards recommended because the acting profile watched the item, minus what it has watched or rated.", "tags": [ "recommendations" ], @@ -188972,6 +194049,105 @@ ], "type": "string" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -189132,6 +194308,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -189292,6 +194567,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -189468,6 +194842,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -189577,7 +195050,7 @@ "bearerAuth": [] } ], - "summary": "The server's most played items over the window, minus what the acting profile has watched.", + "summary": "The server's most played items over the window, minus what the acting profile has watched or marked Not interested.", "tags": [ "recommendations" ], @@ -189644,6 +195117,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -189753,7 +195325,7 @@ "bearerAuth": [] } ], - "summary": "Items added over the window, minus what the acting profile has watched.", + "summary": "Items added over the window, minus what the acting profile has watched or marked Not interested.", "tags": [ "recommendations" ], @@ -189839,6 +195411,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -189999,6 +195670,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -190173,6 +195943,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -190282,7 +196151,7 @@ "bearerAuth": [] } ], - "summary": "Cards similar to the item by content; not personalised beyond the acting profile's access.", + "summary": "Cards similar to the item by content; personalised only by the acting profile's access and by leaving out the titles it marked Not interested.", "tags": [ "recommendations" ], @@ -190674,6 +196543,105 @@ ], "type": "string" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -190834,6 +196802,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -191050,6 +197117,105 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork size for every image in the response; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -193779,9 +199945,219 @@ } }, { - "in": "header", - "name": "If-None-Match", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeatureStatus" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get request capabilities for the current viewer.", + "tags": [ + "requests" + ], + "x-silo-class": "profile_scoped", + "x-silo-conditional": true, + "x-silo-service-backed": true + } + }, + "/api/v2/requests/{id}": { + "get": { + "operationId": "getRequest", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The request", + "in": "path", + "name": "id", + "required": true, "schema": { + "description": "The request", + "examples": [ + "1834729" + ], + "minLength": 1, "type": "string" } } @@ -193791,35 +200167,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FeatureStatus" + "$ref": "#/components/schemas/MediaRequest" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -193881,24 +200233,6 @@ }, "description": "Conflict" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -193945,18 +200279,17 @@ "bearerAuth": [] } ], - "summary": "Get request capabilities for the current viewer.", + "summary": "Get one of the account's media requests.", "tags": [ "requests" ], "x-silo-class": "profile_scoped", - "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/requests/{id}": { - "get": { - "operationId": "getRequest", + "/api/v2/requests/{id}/cancel": { + "post": { + "operationId": "cancelRequest", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -193982,20 +200315,30 @@ } }, { - "description": "The request", + "description": "Opaque identifier", "in": "path", "name": "id", "required": true, "schema": { - "description": "The request", + "description": "Opaque identifier", "examples": [ - "1834729" + "1" ], "minLength": 1, "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestCancelInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -194057,6 +200400,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -194067,6 +200420,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -194113,23 +200486,24 @@ "bearerAuth": [] } ], - "summary": "Get one of the account's media requests.", + "summary": "Cancel an accessible request.", "tags": [ "requests" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/requests/{id}/cancel": { + "/api/v2/scan": { "post": { - "operationId": "cancelRequest", + "operationId": "startLibraryScan", "parameters": [ { - "description": "The household profile acting for this request; it must belong to the authenticated account.", + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", "name": "X-Profile-Id", - "required": true, "schema": { "examples": [ "1" @@ -194147,42 +200521,28 @@ ], "type": "string" } - }, - { - "description": "Opaque identifier", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequestCancelInputBody" + "$ref": "#/components/schemas/ScanStartInputBody" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MediaRequest" + "$ref": "#/components/schemas/ScanStartOutputBody" } } }, - "description": "OK" + "description": "Accepted" }, "400": { "content": { @@ -194320,19 +200680,19 @@ "bearerAuth": [] } ], - "summary": "Cancel an accessible request.", + "summary": "Resolve and dispatch a library, subtree or file scan through the configured execution path.", "tags": [ - "requests" + "scan" ], - "x-silo-class": "profile_scoped", + "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/scan": { + "/api/v2/scan/cancel": { "post": { - "operationId": "startLibraryScan", + "operationId": "cancelLibraryScans", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -194361,22 +200721,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanStartInputBody" + "$ref": "#/components/schemas/ScanCancelInputBody" } } }, "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanStartOutputBody" + "$ref": "#/components/schemas/ScanCancelOutputBody" } } }, - "description": "Accepted" + "description": "OK" }, "400": { "content": { @@ -194438,16 +200798,6 @@ }, "description": "Request Timeout" }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, "413": { "content": { "application/problem+json": { @@ -194514,7 +200864,7 @@ "bearerAuth": [] } ], - "summary": "Resolve and dispatch a library, subtree or file scan through the configured execution path.", + "summary": "Cancel the library's currently queued and local scans.", "tags": [ "scan" ], @@ -194524,10 +200874,18 @@ "x-silo-service-backed": true } }, - "/api/v2/scan/cancel": { - "post": { - "operationId": "cancelLibraryScans", + "/api/v2/scan/capabilities": { + "get": { + "operationId": "getScanCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -194549,28 +200907,49 @@ ], "type": "string" } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanCancelInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ScanCancelOutputBody" + "$ref": "#/components/schemas/Capability" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -194622,17 +201001,7 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { @@ -194640,17 +201009,15 @@ } } }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { - "$ref": "#/components/schemas/Problem" + "type": "string" } } - }, - "description": "Unsupported Media Type" + } }, "422": { "content": { @@ -194698,30 +201065,21 @@ "bearerAuth": [] } ], - "summary": "Cancel the library's currently queued and local scans.", + "summary": "Discover scan-control availability.", "tags": [ "scan" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/scan/capabilities": { + "/api/v2/sections/recipes": { "get": { - "operationId": "getScanCapabilities", + "operationId": "listSectionRecipes", "parameters": [ { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", "name": "X-Profile-Id", "schema": { @@ -194741,13 +201099,6 @@ ], "type": "string" } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -194755,35 +201106,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Capability" + "$ref": "#/components/schemas/RecipeCatalog" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -194835,24 +201162,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -194899,18 +201208,17 @@ "bearerAuth": [] } ], - "summary": "Discover scan-control availability.", + "summary": "The section recipe gallery: every visible recipe with its presets, grouped by category.", "tags": [ - "scan" + "home" ], - "x-silo-class": "acting_admin", - "x-silo-conditional": true, + "x-silo-class": "profile_scoped", "x-silo-service-backed": true } }, - "/api/v2/sections/recipes": { + "/api/v2/sections/recipes/{type}/candidates": { "get": { - "operationId": "listSectionRecipes", + "operationId": "listSectionRecipeCandidates", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -194933,6 +201241,20 @@ ], "type": "string" } + }, + { + "description": "Recipe type from the catalog", + "in": "path", + "name": "type", + "required": true, + "schema": { + "description": "Recipe type from the catalog", + "examples": [ + "custom_filter" + ], + "minLength": 1, + "type": "string" + } } ], "responses": { @@ -194940,7 +201262,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipeCatalog" + "$ref": "#/components/schemas/RecipeCandidateCollection" } } }, @@ -195042,7 +201364,7 @@ "bearerAuth": [] } ], - "summary": "The section recipe gallery: every visible recipe with its presets, grouped by category.", + "summary": "The values a parameterized recipe offers for its parameter.", "tags": [ "home" ], @@ -195050,9 +201372,9 @@ "x-silo-service-backed": true } }, - "/api/v2/sections/recipes/{type}/candidates": { + "/api/v2/sections/seasonal-themes": { "get": { - "operationId": "listSectionRecipeCandidates", + "operationId": "listSeasonalThemes", "parameters": [ { "description": "Optional. When present, it must name a profile of the authenticated account.", @@ -195075,20 +201397,6 @@ ], "type": "string" } - }, - { - "description": "Recipe type from the catalog", - "in": "path", - "name": "type", - "required": true, - "schema": { - "description": "Recipe type from the catalog", - "examples": [ - "custom_filter" - ], - "minLength": 1, - "type": "string" - } } ], "responses": { @@ -195096,7 +201404,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecipeCandidateCollection" + "$ref": "#/components/schemas/SeasonalThemeCatalog" } } }, @@ -195198,12 +201506,11 @@ "bearerAuth": [] } ], - "summary": "The values a parameterized recipe offers for its parameter.", + "summary": "The holidays a Seasonal Picks row can follow, with the curated vibes each offers.", "tags": [ "home" ], - "x-silo-class": "profile_scoped", - "x-silo-service-backed": true + "x-silo-class": "profile_scoped" } }, "/api/v2/settings/contract": { @@ -198762,20 +205069,103 @@ } }, { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "requestBody": { @@ -199335,20 +205725,103 @@ } }, { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -199514,20 +205987,103 @@ } }, { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "requestBody": { @@ -199734,20 +206290,103 @@ } }, { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign on the item cards", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", "large", "original" ], + "examples": [ + "medium" + ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "requestBody": { @@ -206259,227 +212898,27 @@ "bearerAuth": [] } ], - "summary": "Apply a batch of progress writes for the acting profile and answer one result per item; supply updated_at on each item to preserve event ordering.", - "tags": [ - "progress" - ], - "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", - "x-silo-service-backed": true - } - }, - "/api/v2/sync/progress/capabilities": { - "get": { - "operationId": "getProgressBootstrapCapabilities", - "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, - { - "description": "The household profile acting for this request; it must belong to the authenticated account.", - "in": "header", - "name": "X-Profile-Id", - "required": true, - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - }, - { - "in": "header", - "name": "Authorization", - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProgressBootstrapCapabilities" - } - } - }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Discover full-replacement progress bootstrap support for this profile.", + "summary": "Apply a batch of progress writes for the acting profile and answer one result per item; supply updated_at on each item to preserve event ordering.", "tags": [ "progress" ], "x-silo-class": "profile_scoped", - "x-silo-conditional": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/sync/progress/snapshots": { - "post": { - "operationId": "createProgressBootstrapSnapshot", + "/api/v2/sync/progress/capabilities": { + "get": { + "operationId": "getProgressBootstrapCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -206509,35 +212948,44 @@ "schema": { "type": "string" } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProgressSnapshotCreateInputBody" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProgressSnapshot" + "$ref": "#/components/schemas/ProgressBootstrapCapabilities" } } }, - "description": "Created", + "description": "OK", "headers": { "Cache-Control": { "schema": { "type": "string" } }, - "Location": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { "type": "string" } @@ -206594,27 +213042,7 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { @@ -206622,17 +213050,15 @@ } } }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { - "$ref": "#/components/schemas/Problem" + "type": "string" } } - }, - "description": "Unsupported Media Type" + } }, "422": { "content": { @@ -206680,18 +213106,18 @@ "bearerAuth": [] } ], - "summary": "Capture an immutable full-replacement snapshot. Reuse request_id only for the same admission intent within the 24-hour metadata retention horizon.", + "summary": "Discover full-replacement progress bootstrap support for this profile.", "tags": [ "progress" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "domain_identity", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/sync/progress/snapshots/{snapshot_id}": { - "get": { - "operationId": "getProgressBootstrapSnapshot", + "/api/v2/sync/progress/snapshots": { + "post": { + "operationId": "createProgressBootstrapSnapshot", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -206722,29 +213148,20 @@ "schema": { "type": "string" } - }, - { - "in": "path", - "name": "snapshot_id", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "cursor", - "required": true, - "schema": { - "maxLength": 8192, - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProgressSnapshotCreateInputBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -206752,7 +213169,7 @@ } } }, - "description": "OK", + "description": "Created", "headers": { "Cache-Control": { "schema": { @@ -206816,6 +213233,16 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, "409": { "content": { "application/problem+json": { @@ -206826,6 +213253,26 @@ }, "description": "Conflict" }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -206872,30 +213319,65 @@ "bearerAuth": [] } ], - "summary": "Read the next immutable snapshot page using its signed continuation.", + "summary": "Capture an immutable full-replacement snapshot. Reuse request_id only for the same admission intent within the 24-hour metadata retention horizon.", "tags": [ "progress" ], "x-silo-class": "profile_scoped", + "x-silo-retry-safety": "domain_identity", "x-silo-service-backed": true } }, - "/api/v2/system/connections": { + "/api/v2/sync/progress/snapshots/{snapshot_id}": { "get": { - "operationId": "getServerConnections", + "operationId": "getProgressBootstrapSnapshot", "parameters": [ { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", - "name": "If-Match", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], "type": "string" } }, { "in": "header", - "name": "If-None-Match", + "name": "Authorization", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "snapshot_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "cursor", + "required": true, "schema": { + "maxLength": 8192, "type": "string" } } @@ -206905,7 +213387,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServerConnectionsDocument" + "$ref": "#/components/schemas/ProgressSnapshot" } } }, @@ -206916,19 +213398,7 @@ "type": "string" } }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "Location": { "schema": { "type": "string" } @@ -206955,7 +213425,7 @@ }, "description": "Unauthorized" }, - "406": { + "403": { "content": { "application/problem+json": { "schema": { @@ -206963,9 +213433,9 @@ } } }, - "description": "Not Acceptable" + "description": "Forbidden" }, - "412": { + "404": { "content": { "application/problem+json": { "schema": { @@ -206973,15 +213443,27 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" } } - } + }, + "description": "Not Acceptable" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" }, "422": { "content": { @@ -207029,24 +213511,40 @@ "bearerAuth": [] } ], - "summary": "List the addresses this deployment offers to clients: the configured public URL and each installed network access provider with its overlay origin when connected on the API host. available whenever the server identity is readable; a missing public URL is an absent endpoint, never a manufactured one. Node backend addresses, enrollment URLs and admin status are not included.", + "summary": "Read the next immutable snapshot page using its signed continuation.", "tags": [ - "system" + "progress" ], - "x-silo-class": "authenticated", - "x-silo-conditional": true, + "x-silo-class": "profile_scoped", "x-silo-service-backed": true } }, - "/api/v2/system/identity": { + "/api/v2/system/connections": { "get": { - "operationId": "getServerIdentity", + "operationId": "getServerConnections", + "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServerIdentity" + "$ref": "#/components/schemas/ServerConnectionsDocument" } } }, @@ -207056,6 +213554,23 @@ "schema": { "type": "string" } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } } } }, @@ -207069,6 +213584,16 @@ }, "description": "Bad Request" }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, "406": { "content": { "application/problem+json": { @@ -207079,6 +213604,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -207089,6 +213632,16 @@ }, "description": "Unprocessable Entity" }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, "500": { "content": { "application/problem+json": { @@ -207110,23 +213663,29 @@ "description": "Service Unavailable" } }, - "summary": "Read the deployment's stable server identity before login, so a client can tell whether two addresses lead to the same server. Self-asserted: pairing and credentials still go through device-login approval.", + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the addresses this deployment offers to clients: the configured public URL and each installed network access provider with its overlay origin when connected on the API host. available whenever the server identity is readable; a missing public URL is an absent endpoint, never a manufactured one. Node backend addresses, enrollment URLs and admin status are not included.", "tags": [ "system" ], - "x-silo-class": "public", + "x-silo-class": "authenticated", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/system/info": { + "/api/v2/system/identity": { "get": { - "operationId": "getSystemInfo", + "operationId": "getServerIdentity", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemInfo" + "$ref": "#/components/schemas/ServerIdentity" } } }, @@ -207178,28 +213737,46 @@ } }, "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" } }, - "summary": "Get server and contract identity for discovery before login.", + "summary": "Read the deployment's stable server identity before login, so a client can tell whether two addresses lead to the same server. Self-asserted: pairing and credentials still go through device-login approval.", "tags": [ "system" ], - "x-silo-class": "public" + "x-silo-class": "public", + "x-silo-service-backed": true } }, - "/api/v2/system/setup": { + "/api/v2/system/info": { "get": { - "operationId": "getSetupStatus", + "operationId": "getSystemInfo", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetupStatus" + "$ref": "#/components/schemas/SystemInfo" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -207240,35 +213817,24 @@ } }, "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" } }, - "summary": "Report whether the server still needs its first administrator.", + "summary": "Get server and contract identity for discovery before login.", "tags": [ "system" ], - "x-silo-class": "public", - "x-silo-service-backed": true + "x-silo-class": "public" } }, - "/api/v2/theme/admin-css": { + "/api/v2/system/setup": { "get": { - "operationId": "getThemeOverrides", + "operationId": "getSetupStatus", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ThemeOverrides" + "$ref": "#/components/schemas/SetupStatus" } } }, @@ -207325,9 +213891,9 @@ "description": "Service Unavailable" } }, - "summary": "Read public pre-login theme overrides.", + "summary": "Report whether the server still needs its first administrator.", "tags": [ - "branding" + "system" ], "x-silo-class": "public", "x-silo-service-backed": true @@ -213805,12 +220371,12 @@ } }, { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -213822,6 +220388,86 @@ ], "type": "string" } + }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -214451,12 +221097,12 @@ } }, { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "explode": false, "in": "query", "name": "image_size", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Artwork size for every image in the response; absent picks each surface's default", "enum": [ "small", "medium", @@ -214469,6 +221115,86 @@ "type": "string" } }, + { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "explode": false, + "in": "query", + "name": "poster_width", + "schema": { + "description": "Width in device pixels the client draws poster_url at. Overrides image_size for posters: the narrowest cached size at least this wide, else the original", + "examples": [ + 352 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "explode": false, + "in": "query", + "name": "backdrop_width", + "schema": { + "description": "Width in device pixels the client draws backdrop_url and backdrop_urls at. Overrides image_size for backdrops", + "examples": [ + 2688 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "explode": false, + "in": "query", + "name": "logo_width", + "schema": { + "description": "Width in device pixels the client draws logo_url at. Overrides image_size for logos", + "examples": [ + 1760 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "explode": false, + "in": "query", + "name": "still_width", + "schema": { + "description": "Width in device pixels the client draws an episode's still_url at. Overrides image_size for episode stills", + "examples": [ + 720 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "explode": false, + "in": "query", + "name": "photo_width", + "schema": { + "description": "Width in device pixels the client draws a cast or crew photo_url at. Overrides image_size for headshots", + "examples": [ + 400 + ], + "format": "int64", + "maximum": 7680, + "minimum": 1, + "type": "integer" + } + }, { "description": "Page size; default 50, maximum 200", "explode": false, @@ -215305,12 +222031,12 @@ "bearerAuth": [] } ], - "summary": "Remove the item from the acting profile's watchlist; an absent entry succeeds, but automatic retries can repeat provider and refresh effects.", + "summary": "Remove the item from the acting profile's watchlist. An absent entry succeeds and changes nothing, so a retry repeats no change or notification; it cannot resend notifications an interrupted earlier call never sent.", "tags": [ "watchlist" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true }, "get": { @@ -215609,12 +222335,12 @@ "bearerAuth": [] } ], - "summary": "Add the item to the acting profile's watchlist. Automatic retries are unsafe because provider and refresh effects are not change-gated.", + "summary": "Add the item to the acting profile's watchlist. Adding an item already there succeeds and changes nothing, so a retry repeats no change or notification; it cannot resend notifications an interrupted earlier call never sent.", "tags": [ "watchlist" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, @@ -224126,6 +230852,10 @@ "filled": { "format": "int64", "type": "integer" + }, + "refined": { + "format": "int64", + "type": "integer" } }, "required": [ @@ -224265,6 +230995,9 @@ "properties": { "allow_software": { "type": "boolean" + }, + "source": { + "type": "string" } }, "type": "object" @@ -225258,6 +231991,9 @@ "input_path": { "type": "string" }, + "keyframe_playlist": { + "type": "boolean" + }, "require_ready": { "type": "boolean" }, @@ -225303,6 +232039,9 @@ "subtitle_codec": { "type": "string" }, + "subtitle_rendition": { + "type": "boolean" + }, "subtitle_track_index": { "format": "int64", "type": "integer" @@ -225394,6 +232133,9 @@ "hw_accel": { "type": "string" }, + "keyframe_playlist": { + "type": "boolean" + }, "session_id": { "type": "string" }, @@ -225403,6 +232145,9 @@ "status": { "type": "string" }, + "subtitle_rendition": { + "type": "boolean" + }, "throttle_seconds": { "format": "int64", "type": "integer" diff --git a/contracts/settings/v1/conformance.json b/contracts/settings/v1/conformance.json index 0729c73154..3f84f5a25d 100644 --- a/contracts/settings/v1/conformance.json +++ b/contracts/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 17, + "manifest_revision": 20, "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": [ { @@ -1087,6 +1087,25 @@ {"key": "ui.title_art", "scope": "profile_device", "profile_id": "p1", "device_id": "phone", "value": false} ], "expected": [{"key": "ui.title_art", "value": true, "source": "profile"}] + }, + { + "name": "subtitle_sync_follows_profile", + "description": "From revision 19 player.subtitle_sync_ms is also a profile value: a device with no offset of its own uses the profile's.", + "keys": ["player.subtitle_sync_ms"], + "context": {"profile_id": "p1", "device_id": "another-device"}, + "stored": [{"key": "player.subtitle_sync_ms", "scope": "profile", "profile_id": "p1", "value": -750}], + "expected": [{"key": "player.subtitle_sync_ms", "value": -750, "source": "profile"}] + }, + { + "name": "subtitle_sync_device_wins", + "description": "A device's own player.subtitle_sync_ms still wins over the profile offset on that device.", + "keys": ["player.subtitle_sync_ms"], + "context": {"profile_id": "p1", "device_id": "d1"}, + "stored": [ + {"key": "player.subtitle_sync_ms", "scope": "profile", "profile_id": "p1", "value": -750}, + {"key": "player.subtitle_sync_ms", "scope": "profile_device", "profile_id": "p1", "device_id": "d1", "value": 250} + ], + "expected": [{"key": "player.subtitle_sync_ms", "value": 250, "source": "profile_device"}] } ] } diff --git a/contracts/settings/v1/manifest.json b/contracts/settings/v1/manifest.json index e7936136cf..6e4c9fea0d 100644 --- a/contracts/settings/v1/manifest.json +++ b/contracts/settings/v1/manifest.json @@ -1,6 +1,6 @@ { "api_version": 1, - "revision": 17, + "revision": 20, "option_sets": { "playback_audio_languages": { "type": "language_tag", @@ -476,7 +476,7 @@ { "value": "720p", "label": "720p" }, { "value": "1080p", "label": "1080p" }, { "value": "2160p", "label": "2160p / 4K" }, - { "value": "original", "label": "Original quality" } + { "value": "original", "label": "Original quality", "deprecated": true } ] }, "default_value": "auto", @@ -488,7 +488,7 @@ "label": "Preferred quality", "description": "Pick the quality Prairie should prefer.", "recommended_control": "select", - "notes": "The resolution axis. Members are exactly the vocabulary the server already speaks: NormalizeQualityV3 in internal/playback/protocol_v3.go accepts auto, 480p, 720p, 1080p, 2160p and original and normalizes anything else to auto with a degradation warning. Transcode ladder rungs (328p, 720p-high, 1080p-8 and friends) are deliberately absent — they were never a third dimension, only a bitrate spelled into the resolution string. web/src/player/hooks/useTranscodeQuality.ts already decomposes them, defining 1080p-high as {resolution: 1080p, bitrate: 10000} and sending the two to the server separately, so the compound form never reached the wire. playback.max_bitrate_kbps is now that second axis, and clients compose the two into whatever presets they want to show. Members are listed ascending so the ceiling constraint has a defined direction; \"auto\" sorts lowest because it never exceeds a cap, and \"original\" highest because it is the uncapped source. Enforcing the ceiling needs internal/access/quality.go to learn both sentinels: qualityRank ranks neither today, so auto and original both tie at 0 with unset and a cap would let original through. Migrates user_profiles.quality_preference as the profile fallback. The legacy column is NOT NULL DEFAULT '1080p', and that default was the effective playback cap, so existing profiles receive explicit 1080p and 6000 kbps rows; newly created profiles use the contract's auto/null defaults. The account/profile max_playback_quality columns stay in internal/policy and are NOT settings." + "notes": "The resolution axis. Members are exactly the vocabulary the server already speaks: NormalizeQualityV3 in internal/playback/protocol_v3.go accepts auto, 480p, 720p, 1080p, 2160p and original and normalizes anything else to auto with a degradation warning. Transcode ladder rungs (328p, 720p-high, 1080p-8 and friends) are deliberately absent — they were never a third dimension, only a bitrate spelled into the resolution string. web/src/player/hooks/useTranscodeQuality.ts already decomposes them, defining 1080p-high as {resolution: 1080p, bitrate: 10000} and sending the two to the server separately, so the compound form never reached the wire. playback.max_bitrate_kbps is now that second axis, and clients compose the two into whatever presets they want to show. Members are listed ascending so the ceiling constraint has a defined direction; \"auto\" sorts lowest because it never exceeds a cap, and \"original\" highest because it is the uncapped source. Enforcing the ceiling needs internal/access/quality.go to learn both sentinels: qualityRank ranks neither today, so auto and original both tie at 0 with unset and a cap would let original through. Migrates user_profiles.quality_preference as the profile fallback. The legacy column is NOT NULL DEFAULT '1080p', and that default was the effective playback cap, so existing profiles receive explicit 1080p and 6000 kbps rows; newly created profiles use the contract's auto/null defaults. The account/profile max_playback_quality columns stay in internal/policy and are NOT settings. Revision 20 deprecates \"original\": Auto with no bitrate cap already plays the source whenever the device can, so the separate choice only confused people. The value stays valid so older clients can still write it, the planner treats it as auto, a migration rewrote stored values to auto, and clients no longer offer it." }, { "key": "playback.max_bitrate_kbps", @@ -510,6 +510,56 @@ "recommended_control": "select", "notes": "The bitrate axis, orthogonal to playback.preferred_quality. Splitting them is what the clients were already doing: the in-player switcher sends resolution and bitrate as separate fields, and downloads (DownloadQuality in silo-android) dropped resolution entirely and kept only a bitrate ladder. Two values rather than one compound enum means a client can offer \"1080p High\" without the server having to agree on what \"High\" means — retuning a preset is a client release, not a contract break, and it stays additive under the widening rule. null is uncapped, which is why this is nullable rather than defaulting to a large number: absent and \"as much as you like\" are the same statement, and a numeric sentinel would have to be widened every time hardware improves. The bounds are deliberately loose — 100 kbps is below any watchable stream and 200 Mbps is above any remux — because this caps a preference, not a policy; entitlement limits live in internal/policy. Migration decomposes the legacy compound values: 1080p-high becomes (1080p, 10000), 720p-medium becomes (720p, 3000), 420p becomes (480p, 720), following the bitrates in web/src/player/hooks/useTranscodeQuality.ts, so no stored preference is lost to the rejects table." }, + { + "key": "playback.cellular_preferred_quality", + "introduced_in": 20, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "ordered": true, + "values": [ + { "value": "auto", "label": "Auto" }, + { "value": "480p", "label": "480p" }, + { "value": "720p", "label": "720p" }, + { "value": "1080p", "label": "1080p" }, + { "value": "2160p", "label": "2160p / 4K" } + ] + }, + "default_value": "720p", + "constrained_by": { + "policy_input": "max_playback_quality", + "constraint": "ceiling" + }, + "platforms": ["ios", "android", "web"], + "category": "playback", + "label": "Cellular quality", + "description": "The resolution to play at on mobile data or another metered connection.", + "recommended_control": "select", + "notes": "The resolution axis that replaces playback.preferred_quality while the device is on mobile data or a connection the OS marks metered or expensive (a phone hotspot, Android metered Wi-Fi, iOS Low Data Mode). Clients detect the connection and send this pair as quality_preference and bandwidth_cap_kbps; the web app does so when the browser reports Data Saver or a cellular connection; the server never sees which network the device is on, which is why metered on the playback request is ignored. When the connection changes mid-playback the client replans with the other pair. Clients show the pair as one preset list, since bitrate is what matters on a data plan. The members match playback.preferred_quality without the deprecated original." + }, + { + "key": "playback.cellular_max_bitrate_kbps", + "introduced_in": 20, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "integer", + "nullable": true, + "minimum": 100, + "maximum": 200000 + }, + "default_value": 2000, + "unit": "kbps", + "platforms": ["ios", "android", "web"], + "category": "playback", + "label": "Cellular maximum bitrate", + "description": "Cap how much data playback may use on mobile data or another metered connection.", + "recommended_control": "select", + "notes": "The bitrate axis paired with playback.cellular_preferred_quality, with the same bounds and null-is-uncapped meaning as playback.max_bitrate_kbps. The 2000 kbps default matches the 720p preset the clients already offer." + }, { "key": "playback.auto_skip_intro", "introduced_in": 1, @@ -796,16 +846,17 @@ "key": "player.subtitle_sync_ms", "introduced_in": 1, "persistence": "remote", - "allowed_scopes": ["profile_device"], - "resolution_order": ["profile_device", "default"], + "allowed_scopes": [{ "scope": "profile", "introduced_in": 19 }, "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], "value_schema": { "type": "integer", "minimum": -10000, "maximum": 10000 }, "default_value": 0, "unit": "milliseconds", - "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], "category": "player", "label": "Subtitle sync offset", - "description": "Shift subtitles earlier or later on this device.", - "recommended_control": "slider" + "description": "Shift subtitles earlier or later for this profile. A device can keep its own offset.", + "recommended_control": "slider", + "notes": "Revision 19 adds the profile scope, so the offset follows the profile to every device like the other subtitle display settings, and adds the web client. A device value still wins on that device. Clients write the profile scope only when the server reports manifest revision 19 or later; older servers reject it." }, { "key": "player.video_gravity", @@ -1233,6 +1284,20 @@ "recommended_control": "select", "notes": "Registered from the legacy unprefixed key next_up_mode. The server reads it directly when assembling home sections, so it cannot be client-local; that read moves to the canonical resolver at cutover. The legacy value was untyped and absent meant combined, which is why combined is the default rather than a third \"unset\" member." }, + { + "key": "ui.next_up_new_releases_first", + "introduced_in": 18, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "navigation", + "label": "Show new episodes first", + "description": "Move a show you've caught up on to the front of Continue Watching and Next Up when a new episode or season arrives.", + "recommended_control": "switch", + "notes": "The server applies it when ordering next-up rows (Home Continue Watching, the separate Next Up row, and Jellyfin /Shows/NextUp), so it cannot be client-local. An episode counts as new when the availability detector recorded it as a release: it arrived after its library's first full scan and its file is new (episode_availability.released_at). Profile scope so the order follows the viewer across devices; default false keeps the last-watched order a profile has always had." + }, { "key": "nav.primary_menu", "introduced_in": 5, diff --git a/deploy/observability/prometheus.yml b/deploy/observability/prometheus.yml index 7c3abcb67f..fed80d8713 100644 --- a/deploy/observability/prometheus.yml +++ b/deploy/observability/prometheus.yml @@ -1,4 +1,11 @@ # Replace targets with private main/worker listener addresses. Never add the debug port. +# The main server answers /metrics with 404 on its application port; it serves +# metrics only on the separate PRAIRIE_METRICS_LISTEN listener. Bind that listener to +# a private address Prometheus can reach (for example PRAIRIE_METRICS_LISTEN=0.0.0.0:9091 +# on an internal network). 127.0.0.1 inside the Prairie container is not reachable +# from a separate Prometheus container. +# Proxy and transcode nodes serve /metrics on their application listener; +# PRAIRIE_METRICS_LISTEN has no effect on them. global: scrape_interval: 15s evaluation_interval: 15s @@ -10,7 +17,7 @@ scrape_configs: scrape_timeout: 10s sample_limit: 20000 static_configs: - - targets: [silo-api:8080] + - targets: [prairie-api:9091] labels: cluster: example process_role: api diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 45e4d10235..9fc7da8b09 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -127,6 +127,7 @@ services: - prairie-dev-artwork:/var/lib/prairie/artwork - prairie-dev-compat:/var/lib/prairie/compat - prairie-dev-transcode:/tmp/silo-transcode + - prairie-dev-download-artifacts:/tmp/silo-download-artifacts - ${MEDIA_ROOT:-/tmp/prairie-dev-media}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro depends_on: postgres: @@ -173,6 +174,7 @@ services: volumes: prairie-dev-air-tmp: prairie-dev-compat: + prairie-dev-download-artifacts: prairie-dev-go-build: prairie-dev-go-mod: prairie-dev-plugins: diff --git a/docker-compose.yml b/docker-compose.yml index cbf11c0c6b..0cd367ef62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,14 @@ services: - ${PRAIRIE_DATA_ROOT:-/opt/prairie}/artwork:/var/lib/prairie/artwork - ${PRAIRIE_DATA_ROOT:-/opt/prairie}/compat:/var/lib/prairie/compat - ${PRAIRIE_DATA_ROOT:-/opt/prairie}/transcode:/tmp/silo-transcode + # Prepared downloads. With "Prepared file directory" (download.artifact_dir) + # blank, Prairie keeps them beside the transcode directory, not inside it, so + # they need their own mount to survive image updates. This path assumes the + # default playback.transcode_dir; with another one, mount the + # silo-download-artifacts directory beside it instead. No storage + # budget applies until one is set ("Default storage budget per location", + # download.artifact_max_bytes, in Settings > Downloads). + - ${PRAIRIE_DATA_ROOT:-/opt/prairie}/download-artifacts:/tmp/silo-download-artifacts - ${PRAIRIE_DATA_ROOT:-/opt/prairie}/catalog-seeds:/catalog-seeds:ro - ${PRAIRIE_DATA_ROOT:-/opt/prairie}/audiobook-covers:/var/lib/prairie/audiobook-covers # Only load-bearing when the Docker host is itself an LXC container, where diff --git a/docs/admin-api.md b/docs/admin-api.md index ee32f94612..8053fea8d2 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -264,7 +264,7 @@ configuration, last health result, and last stored hardware inventory. See | `drain_state` | string | What the node reported on its last health check: `none`, `draining` (after a drain request or SIGTERM, while it finishes its work) or `drained` (nothing left; safe to stop). `none` for a proxy, a node that predates draining, and a node that did not answer. | | `active_jobs`, `egress_kbps` | int | Last health-reported load. `egress_kbps` is a rolling average and is currently non-zero for proxy nodes only. | | `group` | string \| null | Co-location group. A group is only eligible while every enabled member is healthy. | -| `max_jobs`, `max_bandwidth_kbps` | int \| null | Capacity caps. `null` means unlimited. | +| `max_jobs`, `max_bandwidth_kbps` | int \| null | Capacity caps. `null` means unlimited. `max_jobs` limits streams and file downloads on a proxy and transcodes on a transcode node. `max_bandwidth_kbps` limits a proxy's egress, and a proxy with it set takes no file downloads, because a download's rate is unknown and cannot be reserved; it is stored for a transcode node but not enforced, because a transcode node's output leaves through a proxy or the API server. | | `last_health_check` | RFC3339 string \| null | When the node was last checked. | | `created_at` | RFC3339 string | When the node was registered. | | `capabilities` | object | The node's last stored capability report — the same body `GET /hw-capabilities` returns on the node. Omitted until one has been stored. | @@ -418,7 +418,7 @@ Each entry in `disks`: | Field | Type | Meaning | |---|---|---| -| `path` | string | Where the mount is. Absent from a node's `last_stats`: the node reports it on its own bearer-authed `/status`, and the API host reports its own on `GET /admin/system/resources`, but a node's `/health` takes no credential and withholds it. Use `role`. | +| `path` | string | Where the mount is. Absent from a node's `last_stats`: the node reports it on its own `/status`, which needs a [signed request](architecture/worker-http-protocol.md#authentication), and the API host reports its own on `GET /admin/system/resources`, but a node's `/health` takes no credential and withholds it. Use `role`. | | `role` | string | What the mount is for: `scratch`, or `library-N` positionally per media root. Assigned when the sample is built, so it names the same mount on `/health`, `/status`, `/admin/system/resources` and the `streamapp_node_disk_*` series, and it stays with the mount even when a probe cannot measure it. | | `used_gb`, `total_gb` | float | Capacity in GiB. `used_gb` counts filesystem-reserved blocks, as `df` does. `total_gb` is the capacity usable by the node process — used plus still-available — so it reads lower than the device's nameplate size on a volume that reserves blocks for root, and `used_gb`/`total_gb` is the ratio `df` prints as Use%. | | `stale` | bool | The numbers are real but carried over from an earlier pass because the current probe has not returned — the normal reading for a network mount whose server went away. Omitted when false. | @@ -763,7 +763,7 @@ this route can legitimately outlive the API listener's ordinary 120-second as a hung request. This re-probes only — it does not reload configuration or tear anything down. -Under the hood this is a bearer-authenticated `POST +Under the hood this is a [signed](architecture/worker-http-protocol.md#authentication) `POST /admin/reprobe-capabilities` on the node's own listener, which both transcode nodes and proxy nodes serve. That route is internal to the cluster and is not part of any client contract. @@ -1041,6 +1041,7 @@ client tells "this deployment is older than my build" from "the request failed". | `log_level_list` | `GET /admin/logs/app` accepts a multi-level filter. | | `watch_providers` | `GET /admin/stats` carries the per-provider `watch_providers` array. | | `downloads_stats` | `GET /admin/stats/downloads` serves the offline-download aggregate, and timeseries points carry the additive `download_egress_kbps` split. | +| `egress_totals` | `GET /admin/stats/timeseries` carries the window's additive `egress_bytes` and `download_egress_bytes` totals. | ```json { @@ -1051,7 +1052,8 @@ client tells "this deployment is older than my build" from "the request failed". "health": true, "log_level_list": true, "watch_providers": true, - "downloads_stats": true + "downloads_stats": true, + "egress_totals": true } ``` @@ -1079,7 +1081,7 @@ Each entry: | Field | Type | Meaning | |---|---|---| -| `provider` | string | Provider key: `trakt`, `simkl`, or `mdblist` for the first-party plugins, otherwise `plugin::`. | +| `provider` | string | Provider key: `simkl` or `mdblist` for the first-party plugins, otherwise `plugin::`. | | `display_name` | string | Human name from the registry, or the key when the provider is not registered. | | `registered` | bool | False when the provider only exists in stored rows. | | `scrobbling` | bool | The provider declares the scrobble-playback capability. | @@ -1125,8 +1127,8 @@ Each entry: "scrobbles_24h": 0 }, { - "provider": "trakt", - "display_name": "Trakt", + "provider": "simkl", + "display_name": "Simkl", "registered": true, "scrobbling": true, "exporting": true, @@ -1186,6 +1188,12 @@ an average: these charts are read to answer "how bad did it get", and a mean would erase exactly that. Stream counts and egress are maxed independently, so a bucket's columns may come from different minutes within it. +`egress_bytes` and `download_egress_bytes` are the data served over the whole +window, integrated from every sampled minute rather than from `points` — summing +the peak-per-bucket points would count a one-minute spike as if it lasted the +whole bucket. Unsampled minutes contribute nothing and node egress is a rolling +average, so treat the totals as a close estimate, not byte-exact accounting. + Each minute holds up to two kinds of row. The `shared` row is the cluster-wide snapshot: stream counts by play method, plus the egress reported by enabled, healthy stream nodes. Every replica tries to write it and the first one to land @@ -1228,6 +1236,8 @@ fresh install renders "collecting data" instead of an empty chart. "from": "2026-08-25T12:00:00Z", "to": "2026-08-26T12:00:00Z", "oldest_sample_at": "2026-08-24T09:31:00Z", + "egress_bytes": 412500000000, + "download_egress_bytes": 37800000000, "points": [ { "t": "2026-08-26T11:55:00Z", @@ -2171,8 +2181,10 @@ prospective combined values. Single-key validation preserves the existing paired setting repair behavior. Empty values retain the established clear/default rules. Both writes are nonretryable. Enabling diagnostics can probe storage before the -transaction, and runtime notifications after commit are not durable command -receipts. A lost response does not establish whether the update committed. The +transaction, a change to the Redis connection connects to Redis before it (see +[Saving the Redis connection](admin-settings-api.md#saving-the-redis-connection)), +and runtime notifications after commit are not durable command receipts. A +lost response does not establish whether the update committed. The web captures the displayed validator, copied values and acting profile before an offline pause, does not refresh/replay a failed mutation, and keeps dirty edits on the original baseline across background reads and 412 responses. Changing acting @@ -2211,12 +2223,12 @@ invalidates the exact compatibility status scope only for the active authority. ### Dashboard capability discovery in v2 -`GET /api/v2/admin/dashboard/capabilities` returns the eight dashboard support +`GET /api/v2/admin/dashboard/capabilities` returns the nine dashboard support flags to an acting administrator. These describe the v2 surface in the API build; they do not promise that a database or other runtime dependency is healthy. -`timeseries`, `playback_activity`, `top_activity`, `health`, `watch_providers` and -`downloads_stats` are true for the existing v2 dashboard operations. +`timeseries`, `playback_activity`, `top_activity`, `health`, `watch_providers`, +`downloads_stats` and `egress_totals` are true for the existing v2 dashboard operations. `server_layouts` remains false while the v2 layout lifecycle is unavailable. `log_level_list` is true for the accepted multi-level application-log reader. Clients must not infer v2 support from the bridge's independently retained capabilities. @@ -2258,7 +2270,8 @@ the handshake is served on this process, the protocol (`prairie.admin-logs.v2`) selections (`app`, `audit`). `POST /api/v2/admin/logs/ws-ticket` (`createAdminLogsSocketTicket`) delegates the acting administrator's current access-token login session for one connection. API keys and credentials without a bounded expiry cannot mint; a login whose effective role is -not administrator (a secondary profile on an administrator account) is refused with 403. The +not administrator (a secondary profile on an administrator account, or a profile-less session +on a household with a PIN-protected or access-limited profile) is refused with 403. The ticket is opaque, single-use, expires within 30 seconds and binds the account, login session, role, profile proof and resolved access scope. The response carries `ticket`, `expires_in`, `max_connection_seconds` (300) and `protocol`, is not cached, and minting is naturally @@ -2276,7 +2289,8 @@ proof and administrator role are validated again before 101. The connection ends at the earlier of five minutes or access-token expiry, and the handler rechecks session, account, profile and administrator role every 15 seconds with a two-second -bound; a failed check or a demotion closes the socket. Reconnect with a newly minted credential; +bound; a failed check or a demotion closes the socket, including once the household of a +profile-less session gains a PIN-protected or access-limited profile. Reconnect with a newly minted credential; this is bounded revocation detection, not instantaneous. Without Redis the ticket is process-local and requires affinity to the minting node. @@ -2760,7 +2774,9 @@ started from. Otherwise it skips the write without an error, leaves `last_run_at` and `last_error` as they were (so the next cycle polls the new upstream without waiting for the interval), records its starting marker as the event's `marker_after` with a note that the marker was not stored, and -the next poll starts from the reset marker. +the next poll starts from the reset marker. A poll that fails after such a reset +likewise leaves `last_error` and `last_run_at` as the reset left them; its error is +recorded only on the poll's event, with a note that it was not stored on the source. Configuration keys/values, connection and label are normalized as in the bridge. Webhook mode is restricted to the built-in identity, with auto/sonarr/radarr provider @@ -3034,7 +3050,12 @@ by `id`. Both listeners share one projection: global configuration values contai public fields the manifest declares and list configured secret names in `configured_secrets`; a configuration whose schema is missing from the manifest is projected empty. The reserved built-in host row is excluded and a projection that contains it is an -internal error. Timestamps are RFC 3339 UTC milliseconds. +internal error. Timestamps are RFC 3339 UTC milliseconds. In every plugin view that lists +capabilities (installations, catalog entries, and the install, upload and update responses), a +`request_router.v1` capability carries `request_router`, whose `api_key_optional` and +`base_url_optional` say which request server fields its plugin can do without; both are false +when the plugin declares neither. For an installation they come from the capability stored at +install or update, which is what routing reads. Both reads require an acting administrator and answer 503 when the plugin service or stores are not wired. The web plugins page and admin sidebar read these @@ -3080,7 +3101,8 @@ version in place. `POST /api/v2/admin/plugins/installations/{id}/restart` stops the installation's process and, for a resident plugin, starts it again with a fresh failure budget; a non-resident plugin -is only stopped and launches on its next use. A disabled installation is 409. Success is 200 +is only stopped, its recorded start failure is cleared, and it launches on its next use. A +disabled installation is 409. Success is 200 with the installation, whose `runtime` reports the outcome, including a launch that failed. Repeating the request converges on one running process, so it is naturally idempotent. @@ -3090,7 +3112,9 @@ exponential backoff from 1 s to 60 s, and is parked as `failed` after ten consec failures until restarted or reconfigured), `state` (`stopped`, `starting`, `running`, `backoff`, `failed`), `restart_count`, `last_error`, `last_started_at` and `next_restart_at`. Plugins declaring `network_access_provider.v1` are resident; every other plugin starts on -first use and reports only `running` or `stopped`. +first use and reports `running`, `stopped`, or `failed` when its last launch on the answering +server failed, with `last_error` saying why. A later successful launch, an administrator +restart, an update or a configuration change clears that failure. `DELETE /api/v2/admin/plugins/installations/{id}` stops the plugin, deletes the row (configuration, bindings and archives cascade) and removes its files; on a failed row delete @@ -3152,6 +3176,8 @@ automatically. A revision precondition (`If-Match`) is a follow-up, not part of `POST /api/v2/admin/plugins/installations/{id}/config/test` probes one prospective entry: the server starts a temporary plugin instance with the merged configuration, runs the plugin's connection check under a 20 s timeout, and stops the instance. Nothing is stored. +A metadata provider is checked with a movie search; a marker provider is asked for its +account statistics, which need the staged credentials. A completed failed check, including a plugin without connection-check support, is a 200 result with `success` false and the message; only transport or store failures are problems. The probe launches a process and may call a billed provider, so it is non-retryable: the diff --git a/docs/admin-catalog-api.md b/docs/admin-catalog-api.md index 218ea47fcc..27a115f423 100644 --- a/docs/admin-catalog-api.md +++ b/docs/admin-catalog-api.md @@ -67,7 +67,13 @@ field errors that explain the affected source roots. Direct import runs the existing catalog transaction and returns its committed counts. If a response is lost, callers must inspect the catalog before deciding -whether to submit again. The web explicitly offers background execution or +whether to submit again. + +Import keeps a bundle's embeddings only when they come from this installation's +embedding model: the model in the recommendations embedding lock, or the +configured embedding model when nothing is locked yet. `embeddings_skipped` +counts the others; the embedding backfill embeds those items with the local +model. Import never writes the embedding lock. The web explicitly offers background execution or "Import and wait"; it does not fall back between them after an error. All transfer mutations disable automatic mutation and authentication retries. @@ -178,12 +184,19 @@ is checked by the same permission middleware used by the frozen v1 routes. | `POST /api/v2/admin/items/{id}/metadata-translation/jobs/{job_id}/cancel` | Empty `204` after a cancellation request | Enqueue accepts `target_language`, optional `include_children`, and optional -`force`. Children default to included for item targets; season and episode -targets never include children. The requesting account is recorded by the -service. A matching active job can be reused; otherwise the service persists -and dispatches a job. `Location` identifies the item's recent-job listing. -Success does not mean translation has completed. The existing metadata AI -capability endpoint describes whether the translation provider is configured. +`force`. A job fills only fields with no localized text; `force` also replaces +text an earlier AI translation wrote. Provider and manual text is never +replaced, and the repository enforces the same rule on every AI write. +Children default to included for item targets; season and episode targets +never include children, and neither do movies. The requesting account is +recorded by the service. When a pending or running job for the same target +and language already covers the request (it includes children whenever the +request does, and is forced whenever the request is), the service returns that +job, so its `include_children` and `force` can differ from the request; +otherwise the service persists and dispatches a job. `Location` identifies the +item's recent-job listing. Success does not mean translation has completed. +The existing metadata AI capability endpoint describes whether the translation +provider is configured. Job IDs use opaque strings and timestamps use UTC millisecond instants. The web polls while jobs are pending or running. Cancellation checks that the job belongs @@ -195,6 +208,36 @@ Enqueue and cancellation have no durable request replay receipt. The web enqueue mutation disables both mutation retries and authentication replay. No existing web cancel control, native administrator caller, or Jellyfin equivalent exists. +### Library prewarm + +A server administrator can fill a whole library's missing translations ahead +of browsing. These routes require the acting-administrator role, not the +per-item curation permission. + +| Endpoint | Result | +| --- | --- | +| `POST /api/v2/admin/libraries/{library_id}/metadata-translation` | `202` with the canonical translation job | +| `GET /api/v2/admin/libraries/{library_id}/metadata-translation/jobs` | `jobs`: the newest 50 prewarm jobs of the library | +| `POST /api/v2/admin/libraries/{library_id}/metadata-translation/jobs/{job_id}/cancel` | Empty `204` after a cancellation request | + +Enqueue accepts only `target_language`. The job's `target_kind` is `library` +and its `content_id` is `library:{library_id}`. It walks the library's items that still +miss an overview, tagline, season overview, or episode overview in that +language, in title order, and translates each item with its seasons and +episodes. Items already in that language are skipped. A prewarm never forces: +provider, manual, and earlier AI text is kept. `fields_done` and `fields_total` +count items, and `progress_message` names the current one. + +One prewarm translates one item at a time on a slot of the shared AI job +bound, and gives the slot back between items so queued on-view and subtitle +jobs run in the meantime. An item that fails is skipped and named in the +server log; three failures in a row stop the job, since then the provider +itself is failing. Translated items stay. Re-running after a failure, a cancellation, or a +restart resumes with whatever is still missing. An active prewarm for the same +library and language is reused. Enqueue and list answer `404` for an unknown +library; cancel finds the job by `library:{library_id}` alone, so a job of a +library deleted while it ran can still be stopped. + ## Item metadata edits and refresh `POST /api/v2/admin/items/{id}/refresh-metadata` accepts `mode: "quick"` (the @@ -485,8 +528,10 @@ array. No current web, native or Jellyfin consumer needs migration for this rout `GET /api/v2/admin/items/{id}/images` requires acting-admin access and returns `items`, `page`, `current`, and optional `provider_errors`. Pages default to 50 -choices and accept `limit` 1–200. Choice metadata determines a stable order; -continuation binds the content ID, authority, limit and complete choice digest. +choices and accept `limit` 1–200. On movie and series lists, local sidecar +choices come first, in discovery order. Provider choices follow by rating, +highest first; choice metadata breaks ties, so the order is stable. +Continuation binds the content ID, authority, limit and complete choice digest. Expiring display URLs are excluded from that digest. A provider choice change returns `invalid_cursor` and requires reloading. Each page fetches and resolves the provider list again; pagination bounds response size, not provider work or @@ -494,11 +539,30 @@ memory, and stores no server-side snapshot. Provider failures expose generic messages. The web drains all pages under one captured authority and refuses repeated or invalid continuation rather than publishing a partial list. +For a movie or series the list also offers its local sidecar artwork (the +`poster.jpg`, `fanart.jpg` and similar files a refresh would use), found by +Prairie's own sidecar discovery whether or not the library's metadata chain uses +the NFO provider. A `file://` URL from any other provider is not offered. These +choices have `provider_id` `local`, a `file://` `original_url`, and a small +WebP preview as a `data:` URI in `url`. A file that cannot be read under the +library-root, symlink and size checks is left out, and `provider_errors` +reports the failure under `local`. Banner, thumb, clear art and disc art +sidecars, and image URLs an NFO names, are not offered. When the library's +provider chain cannot be resolved, the movie or series list still returns `200` +with its local choices and reports the failure under `chain` in +`provider_errors`; other lists return `500`. Frozen v1 lists provider choices +only. See +[Local NFO metadata](architecture/local-nfo-metadata.md#admin-image-picker). + `POST /api/v2/admin/items/{id}/images/apply` accepts `original_url`, `type`, and optional `provider_id`. An HTTP(S) `original_url` may name a public or local-network address; link-local, cloud metadata and other blocked addresses are refused (see -[Outbound address guard](architecture/outbound-address-guard.md#artwork-downloads)). +[Outbound address guard](architecture/outbound-address-guard.md#artwork-downloads)). A `file://` +`original_url` is accepted for a movie or series only when the item's sidecar +discovery offers that file for that image type; anything else returns a `422` +validation problem. A failure to read or cache an offered file returns `500`. +Frozen v1 does not accept local files. It preserves target validation before remote work, episode-to-still coercion, parent/season/episode cache identity, immutable upload, transactional catalog publication and orphan-GC scheduling after publication diff --git a/docs/admin-recommendations-api.md b/docs/admin-recommendations-api.md index 81ce7dc846..442698ccea 100644 --- a/docs/admin-recommendations-api.md +++ b/docs/admin-recommendations-api.md @@ -1,16 +1,19 @@ # Recommendation administration API -Recommendation administration uses acting-administrator authorization. All five +Recommendation administration uses acting-administrator authorization. All six routes remain registered when the recommendation worker is absent and return `503` until it is available. The frozen v1 handlers remain unchanged. | Endpoint | Result | | --- | --- | -| `GET /api/v2/admin/recommendations/status` | Counts and running flags for four job kinds | +| `GET /api/v2/admin/recommendations/status` | Counts, last runs, running flags, lock conflict and cache refresh time | | `POST /api/v2/admin/recommendations/trigger/embeddings` | `200` with `status: "started"` | | `POST /api/v2/admin/recommendations/trigger/taste-profiles` | `200` with `status: "started"` | | `POST /api/v2/admin/recommendations/trigger/cowatch` | `200` with `status: "started"` | | `POST /api/v2/admin/recommendations/trigger/recommendations` | `200` with `status: "started"` | +| `POST /api/v2/admin/recommendations/embeddings/reset` | `200` with the number of rows deleted | + +## Status Status contains `embeddings`, `taste_profiles`, `cowatch`, and `recommendations`. Each has a `running` boolean and integer `count`; embeddings includes `total` @@ -18,17 +21,88 @@ when nonzero. Counts come from persisted catalog/recommendation data. Running flags describe the responding process and are sampled separately from counts. They do not form a consistent cluster-wide snapshot. -Trigger success means the existing worker claimed its process-local running flag -and launched background work. It does not mean that work completed or that a -job was persisted. There is no job ID, Location header, durable acceptance, -cluster-wide exclusion, or automatic recovery promise. A duplicate trigger while -the same kind is running in that process returns `409`. Another process may -start the same kind independently; a restart loses the local running flag. - -All four triggers are non-retryable. The web disables both TanStack mutation -retries and authentication refresh/replay for these actions, including after -`401`. After an uncertain result, inspect status before deciding whether to -trigger again. The status query continues to poll every five seconds. +Each job also carries `last_run`, the newest finished full run (scheduled or +triggered) recorded by any server, once one exists: `status` (`completed` or `failed`), `started_at`, +`completed_at`, `error` on a failed run, and `result`, the counts the run +reported. A run that finished with partial failures is `completed` and counts +them in `result`; its keys differ by job and may grow. The error is the job's +own message with credential assignments and URL user info masked. Runs that a +busy job skipped are not recorded. `embeddings` and `recommendations` can also +carry `last_partial_run`, in the same shape: the newest embedding catch-up +pass, or the newest startup build of the global rows. Partial runs are +recorded apart, so one that runs after a failed full run does not hide it. + +`stale_sweep` reports the sweep that rebuilds profiles after their signals or +access change: `pending` counts the profiles waiting for a rebuild, and +`last_run`, in the job run shape, is the newest sweep on any server that +rebuilt or failed at least one profile. A profile whose rebuild keeps failing +stays pending and shows as `failed` in each sweep's `result`. + +`lock_conflict` is empty unless the stored embedding lock rejects the +embedding base URL or model this server runs with, or records a vector size +Prairie cannot store. It is then a sentence for the administrator that never +quotes a base URL. Embedding settings take effect after a restart, so a saved +but not yet applied change does not show here; the embedding connection check +reports it instead. `cache_refreshed_at` is when the newest cached +recommendation row was written, by the cache job or a profile refresh; it is +absent when nothing is cached. Profile refreshes keep it recent even while the +cache job fails, so it does not show job health; `recommendations.last_run` +does. + +## Triggers + +The embeddings kind also covers the catch-up pass, which every 15 minutes +embeds items that have no embedding or one from another model: while it runs, +an embeddings trigger answers `409`. Catch-up runs are reported as the +embeddings `last_partial_run`, marked `missing_only: true` in `result`. +Catch-up runs that found nothing to do are not recorded; failed ones are, so a +lock conflict shows as a failed partial run every 15 minutes until it is +resolved. + +Trigger success means the responding process claimed its running flag and the +job kind's cluster-wide lock, then launched background work. It does not mean +that work completed. There is no job ID, Location header, durable acceptance, or +automatic recovery promise. A trigger returns `409` while the same kind is +running in that process or on another server; the problem detail says which. +Failing to take the lock returns `500`. Scheduled runs take the same lock, so +each kind runs on one server at a time. A restart loses the local running flag +and releases any lock the process held. + +## Embeddings reset + +The reset deletes the embedding lock, every item embedding, every taste +cluster and every profile's cached rows, and clears every taste profile's +vector, in one transaction. It answers with the counts (`embeddings`, +`taste_clusters` and `cached_rows` deleted, `taste_profiles` cleared). Taste +profile rows keep their signal counts, so they are rebuilt once the +embedding job stores their titles' vectors again. It is the recovery path +for a server whose lock pins a model it can no longer use, and the way to +switch embedding models. Global rows (popular, recently added, top rated, +genre samplers) and co-watch pairs do not use embeddings and stay. + +The reset runs synchronously while holding the embeddings, taste profile and +recommendation jobs' claims and the stale profile sweep's lock, so none of +them runs beside it on any server. It returns `409` while any of them is +running and `500` when the transaction fails, which leaves every row in place. +A profile refresh that was already running when the reset committed can still +write one taste profile from the old embeddings; the profile's next refresh +after re-embedding replaces it. + +Every item that lost its embedding gets a catalog search index update, so the +next index syncs replace its stored vector with none. Semantic search stays +off until the new model covers enough of the catalog again. The embedding +catch-up pass starts re-embedding the catalog within 15 minutes, or run the embedding job +after the reset; the next embedding writes a new lock for the configured model. + +## Clients + +The four triggers and the reset are non-retryable. The web disables both +TanStack mutation retries and authentication refresh/replay for these actions, +including after `401`. After an uncertain result, inspect status before +deciding whether to trigger again. The status query continues to poll every +five seconds. The web asks for confirmation before a reset and disables it +while the embeddings, taste profile or recommendation job runs on the +responding process. These existing administration flows have no Apple or Android callers and no Jellyfin compatibility equivalent. Viewer recommendation endpoints are separate. diff --git a/docs/admin-settings-api.md b/docs/admin-settings-api.md index b174d0a613..6133303fed 100644 --- a/docs/admin-settings-api.md +++ b/docs/admin-settings-api.md @@ -30,7 +30,10 @@ compatibility does not expose these administrator settings contracts. check against the submitted `values` and `dirty_keys`, merged with stored settings. Supported kinds are `s3_public`, `s3_operational`, `s3_private`, `redis`, `recommendations_embedding`, `ai_chat`, `ai_transcription`, `meilisearch`, and -`mdblist`. Existing endpoint-change protection for stored AI credentials applies. +`mdblist`. Stored AI and embedding credentials are sent only to the base URL +they were saved for (same scheme, host and port): a draft that points +`ai_chat`, `ai_transcription` or `recommendations_embedding` elsewhere is +checked with the credential it submits in `values` and `dirty_keys`, or none. Provider failures return `success: false` with a generic message that excludes provider error bodies and credentials. Invalid kinds/configuration return `422`. @@ -223,6 +226,35 @@ single-server URL and for a Sentinel URL. A change needs a restart. database. That includes a cleared URL, so a URL emptied and typed again keeps the number; a save that leaves no URL stores no number. +### Saving the Redis connection + +A server that cannot connect to the Redis its stored settings name stops at its +next start, and a stored value can then no longer be corrected in the admin UI. +So a write that changes that connection first runs the check that +`POST /api/v2/admin/settings/check/redis` runs, and stores nothing when it +fails. The check connects, sends a `PING` and subscribes to the event bus +channels on the database number, which a starting server needs. It gives up +after 10 seconds, even for a URL that turns the socket timeouts off. + +- The connection changes when the write leaves a different `redis.url`, or a + different database number in use, than the stored settings. Both + writes and both API versions apply the check. A write that leaves the + connection as it is, clears `redis.url`, or runs in a process started with + `REDIS_URL` connects to nothing. +- Connecting selects the database number, so a number the Redis server does + not have (16 or more on a Redis with the default 16 databases) fails the + same way an unreachable host does. So does a Redis user whose ACL does not + grant the channels (the pattern `&prairie:*`). +- A failed connection answers a `422` validation problem on `/api/v2` and + `400 invalid_settings` on `/api/v1`. The detail says whether Redis refused + the connection, refused the channels, or could not be reached, or that + `redis.url` is not a URL the Redis clients can read (a bare `host:port` an + older build saved). It does not quote the client error, which can name hosts + and addresses; the server logs that error with credentials masked. +- The connection is made before the settings transaction. If another write + changes the stored Redis settings in the meantime, the write is refused the + same way and can be sent again. + ### HEVC encoding `playback.allow_hevc_encoding` is a boolean setting, default `false`. When diff --git a/docs/admin-tasks-api.md b/docs/admin-tasks-api.md index f895a3660e..c3dc351954 100644 --- a/docs/admin-tasks-api.md +++ b/docs/admin-tasks-api.md @@ -130,11 +130,19 @@ documents for this job type. The existing `POST /api/v2/library-jobs/{job_id}/cancel` contract remains intact. `POST /api/v2/admin/jobs/{id}/cancel` (`cancelAdminJob`) requests cancellation -for storage-transition jobs. Library refresh cancellation uses its separate -library-job endpoint above. Cancellation retains completed effects and verified -storage-copy checkpoints. An accepted cancellation returns `202`; an already -canceled job returns `200`; a succeeded or failed job returns -`409 job_not_cancelable`. A queued storage-transition cancellation also releases -its staged target so a later transition can choose a different destination. The -administrator task section does not add a generic durable scheduler or change +for storage-transition and image cache cleanup jobs. Library refresh +cancellation uses its separate library-job endpoint above. Cancellation retains +completed effects and verified storage-copy checkpoints. An accepted +cancellation returns `202`; an already canceled job returns `200`; a succeeded +or failed job returns `409 job_not_cancelable`. A queued storage-transition +cancellation also releases its staged target so a later transition can choose a +different destination. A queued image cache cleanup, including one waiting +between slices, is canceled at once and returns `200`. A running cleanup stops +at the prefix it is deleting, so the cancel can take partial effect: cached +images already deleted stay deleted and any not yet deleted remain in storage. +The canceled job reports the totals it reached in `library_result`: +`deleted_prefixes` counts fully deleted prefixes and `deleted_s3_objects` counts +every deleted object, including any from the prefix the cancel interrupted. A +cleanup canceled before it deleted anything reports zeros. The administrator +task section does not add a generic durable scheduler or change the retention and dispatch guarantees of existing job owners. diff --git a/docs/admin-users-api.md b/docs/admin-users-api.md index 1ae352e20b..81c1427a13 100644 --- a/docs/admin-users-api.md +++ b/docs/admin-users-api.md @@ -72,6 +72,21 @@ value; `0` explicitly allows unlimited bitrate. Both access-group fields default to `0`. Values are nonnegative integers in kbps. A policy edit changes new playback sessions, not streams already playing. +`max_profiles` is the household profile limit and is a nullable account override +as well. `null` inherits the access group's `max_profiles`, and a value of at least +1 replaces it. An administrator or an account outside every group inherits 5, and +an access group created without a value gets 5. `effective_policy.max_profiles` is +the limit that profile creation enforces: an account at its limit gets +`409 profile_limit_reached`, and profiles it already has above a lowered limit are +kept. The bridge v1 body has no inherit state for this field, so its +`max_profiles` reports the limit that applies. A v1 update stores an override, +except that sending back the inherited limit an account reported leaves it +inheriting, and an explicit `null` clears an override back to inherit, as with +the other policy fields. The group's own limit is v2-only: the v1 +access-group body carries no `max_profiles`, so a group limit is set and read +through the v2 access-group operations. `GET /api/v2/admin/users/capabilities` reports `profile_limits: true` +when access groups and account overrides take `max_profiles`. + `POST /api/v2/admin/users` returns 201 with `{ "id": "..." }` and `Location`. Default-profile creation, when requested, uses the existing transactional provisioner. Unsupported transactional profile storage fails before account @@ -104,6 +119,21 @@ clients show where a default comes from without keeping their own copy. The values come from the server build. Account capabilities advertise `policy_defaults`. Existing `admin:users` keys may use this read. +## Library lists + +An account's or access group's `library_ids` is `null` for every library and an +array for exactly the listed ones, so `[]` allows none. Create and update return +`422 validation_failed` at `body.library_ids` when the array names a library that +does not exist; a guarded update checks its precondition first. Whichever API +writes these lists, and the [invitation](invitations-api.md) lists too, the +database keeps only libraries that exist, and a write racing a library delete +waits for it as it would behind a foreign key. + +Deleting a library removes its ID from every account, access group and +invitation in the same transaction. A list left with no IDs stays `[]` and never +widens to `null`. The edited accounts and groups get new revisions, so an editor +holding the old ETag gets 412 instead of writing the deleted library back. + ## Passwords Account editor and list rows carry `password_login` and `password_change_required`. diff --git a/docs/architecture/admin-settings-ux.md b/docs/architecture/admin-settings-ux.md index 04a8153ee4..d83028c9ed 100644 --- a/docs/architecture/admin-settings-ux.md +++ b/docs/architecture/admin-settings-ux.md @@ -1,61 +1,130 @@ # Admin settings UX Admin settings are organized by admin intent ("I want subtitles to download -automatically"), not by subsystem. `/admin/settings` is the **Overview**: -server health across the top and one live card per settings group. Fourteen -standalone pages hang off it: General, Storage & Database, Appearance, -Security & Access, Library & Metadata, Playback, Downloads, Requests, -Subtitles & Metadata, Watch Providers, AI Services, Notifications, -Compatibility, and Network Access. The -global admin sidebar has one Settings destination; the Overview owns the -settings information architecture. Old `?tab=` URLs and retired page ids from -earlier layouts (including `integrations`, now split into Subtitles & Metadata, -Watch Providers, and AI Services) redirect to the page that absorbed them -rather than 404ing. -`⌘K` (`AdminSectionCommandDialog`) is mounted in `AdminLayout` so search works -from every admin page, not just the Dashboard. +automatically"), not by subsystem, and they live in the admin sidebar beside +the tools they configure. There is no separate settings area: the sidebar is +one tree of sections and topics (`ADMIN_NAV_SECTIONS`, +`web/src/lib/adminNavigation.ts`), and every admin page, tool or settings, +appears in it exactly once. + +- **Monitor**: Dashboard, Activity, Logs (Server Log, Client Diagnostics, + Settings) +- **Manage**: Libraries (Libraries, Collections, Home Sections, Subtitle + Files, Settings), Autoscan (Activity, Sources, Settings), Requests (Queue, + Settings), Users (Users, Access Groups, Devices, History Import), Playback + (History, Skip Markers, Nodes, Settings), Downloads (Prepared Files, + Settings) +- **Configure**: Server (General, Appearance, Storage, Search, Network + Access, Scheduled Tasks, Maintenance), Sign-in & Security (Sign-in, + Security, API Keys, Policy), Integrations (Plugins, Providers, Email, + Notifications, AI Services, Recommendations, Compatibility) + +A topic that holds both tools and settings lists its own settings page last, +as plain "Settings"; the topic already says whose. A topic's heading only +opens and closes it (`SideNavTopic`); the topic holding the current page opens +by itself, and the rest stay as the admin left them (`useAdminNavTopics`). + +Settings pages keep their own routes, `/admin/settings/:page`, and +`ADMIN_SETTINGS_NAV` (`web/src/lib/adminSettingsSearch.ts`) remains their +registry: id, label, description, and search index. The bare +`/admin/settings` opens the first page. Old `?tab=` URLs and retired page ids +from earlier layouts redirect to the page that absorbed them rather than +404ing (`LEGACY_ADMIN_SETTINGS_PAGE_ALIASES`). `⌘K` +(`AdminSectionCommandDialog`) is mounted in `AdminLayout`, and the search +field at the top of the desktop admin sidebar opens it; it lists the +sidebar's pages with each topic as its own group, so "Settings" always reads +under its topic, and it finds every indexed setting. + +A setting lives on the page whose name covers it. Public signups and +self-service password reset only work with Prairie passwords, so they sit with +password sign-in on Sign-in. Logging holds every log setting, the server log +and the activity log retention alike. General holds what the whole server runs +on: its name and address, the trusted proxies beside the public URL (as the +setup wizard presents them), the database pool, and Redis. Storage holds where +files go, including whether provider artwork is copied there. Its bucket +sections show for S3 and Automatic. Local disk keeps avatars, diagnostics, and +catalog artifacts in the local path, so it hides both, keeping private storage +only while a private bucket is saved, since that bucket then holds them. +Picking S3 stages the backend so the buckets can be filled in, and Save opens +any transition review the move needs. Search has its own page +because the engine serves every library and client, not one library's +metadata. Skip markers are a playback feature, so their detection settings +are on Playback › Settings and the marker history is Playback › Skip Markers; +subtitle sync stays on Libraries › Settings. Autoscan's polling defaults and +saved server connections are its own Settings page; they are not `server_settings` rows, so that page keeps its +own draft but saves through the same `SaveBar` and unsaved-changes prompt, +while the switch that turns Autoscan on and Run now stay on Sources beside the +sources they act on. The mail server has its own Email page, because invitations and +password resets need it as much as notifications do. Node routing (which +machine runs a remux or transcode and which serves the stream) is a section of +the Nodes page with its own save bar, because it only means anything once +nodes exist; its presets are the visible control and the five routes are the +Advanced tier. + +What needs the admin (storage nobody configured, a restart a saved setting is +waiting on, mail that cannot send) is derived by `useSettingsAttention` +(`web/src/hooks/admin/useSettingsHealth.ts`) and listed once, as a "Needs +attention" notice on the Dashboard, each item linking to the page that fixes +it. The sidebar adds a dot only for what is broken (`warn`), on the page and on +its topic while that is closed; setup the admin has not done yet stays on the +Dashboard, so a server that deliberately runs without mail does not carry a +dot forever. A healthy item is never shown. Hovering or focusing a settings +page in the sidebar prefetches its code and values, so a click lands on a +filled form rather than a skeleton. + +## User settings + +Personal settings (`/settings`) keep their own sidebar, because the app +sidebar is for browsing. On desktop, opening settings swaps the app sidebar +for the settings sidebar (`SettingsSidebar`, `web/src/components/settings/`) +at the same width, so there is never a second navigation column. It holds a +back link to the last page the app showed outside settings +(`useSettingsReturnPath`), the current profile, search, and every page in its +groups. The root redirects to the first page on desktop; on a phone it is the +grouped list (`SettingsIndexList`). Crossing in or out is the one navigation +that changes what the column holds, so its contents slide in from the side the +reader is heading toward (`useSidebarSwap`), and the profile menu loads the +settings code when it opens. ## Visual system One page is on screen at a time, and each thing on screen carries one signal. -The admin settings detail view deliberately mirrors the user settings page -(`SettingsLayout`): one `surface-panel-lg` shell with a `SideNavItem` rail on -the left and the page content on the right, so the two settings surfaces read -as the same product. The Overview stays the category directory: it explains -each group's scope, and every category has its own `/admin/settings/:page` -route. The rail (`SettingsPageRail`, rendered by the settings shell) lists -every settings page with the open one marked; an All settings link above the -shell leads back to the directory. The rail is desktop-only — on smaller -screens the Overview is the directory, exactly as on the user side. The -Overview shows a health tile only for a tile in `warn` or `off`. The -**Setup & health** section explains that it holds recommendations and -configuration problems; an empty checklist reads "No action needed" and -names the conditions that will appear there. Below it is one card per -settings group. Each card explains the group's scope and names the sections -inside it. Live state stays in the health area instead of reducing a -multi-provider group to one misleading summary. - -A category page opens with `SettingsPageHeader`: the title, and page actions -if it has any. No breadcrumb, no lede, no status strip. Below it, settings -are hairline-ruled rows inside `FieldGroup`s — thin wrappers over the shared -`SettingsGroup` panel the user settings pages use — one panel per group, -never per field. The Advanced tier stays inline as one disclosure row per -group. A row can carry its `server_settings` key as a mono caption under the -label (`SettingField`'s `settingKey`) so an admin can match the UI to the -API and environment overrides, and a violet dot marks unsaved edits on the -row and on the group heading (`dirty`, driven from `form.isDirty`). A + +Every admin page and every settings page, user or admin, opens with the +shared `PageHeader` (`web/src/components/page/`): one title size at one +position, a one-sentence subtitle, and page actions at the right. An admin +settings page's subtitle is its description from the settings index, which +`AdminSettingsLayout` provides to `SettingsPageHeader`. No breadcrumb and no +status strip. Every admin page and every user settings page sits in one +frame, `PageFrame`, which centers once the window is wider than it; pages +carry no `page-shell` box of their own, so the title sits at the same place +on every page. There are two widths inside the frame: settings pages cap their `PageColumn` at a +reading width from its left edge, while tables and dashboards fill it. A section's title and +description move into a left column only on a desktop-width window with room +beside the sidebar, so resizing switches each section once. Below the header, settings are +hairline-ruled rows inside `FieldGroup`s — thin wrappers over the shared +`SettingsGroup` section the user settings pages use — one section per group, +never per field. Sections are not cards: they sit on the page, divided by a +hairline, and once the column is wide enough a section's title and +description move into a left column beside its rows. Lists inside a section +(providers, delivery channels) are rows too, not tiles. The Advanced tier +stays inline as one disclosure row per group. Rows do not show their +`server_settings` keys; the label and search identify a setting. A violet +dot marks unsaved edits on the row and on the group heading (`dirty`, +driven from `form.isDirty`). A description under a field label is the exception, not the rule: one short sentence, and only when the label alone is ambiguous. Units live beside the control (`SettingField`'s `unit`), not in the label. When every field in a group needs a restart, the group says so once (`FieldGroup restartAll`) and -the fields inside drop their chips. Provider credentials are `ProviderTile`s -that expand in place to Test before saving; their border is neutral in every -state and the state is a dot plus a word in the header. Provider setup lives on -a provider page (Subtitles & Metadata, Watch Providers), not on the page that -owns the feature: Library & Metadata decides *whether* Silo looks for intro and +the fields inside drop their chips. Provider credentials are `ProviderTile` rows +that expand in place to Test before saving; the state is a dot plus a word +beside the provider's button. Provider setup lives on +the Providers page, not on the page that owns the +feature: Playback decides *whether* Prairie looks for intro and credits markers, while *which provider answers, in what order, and on what -terms* is a tile beside the subtitle and metadata providers, with a cross-link -each way. A tile only reads "Connected" when the provider could actually serve +terms* is a row beside the subtitle and list providers, with a cross-link +each way. Watch providers (Simkl, MDBList) open the Providers page, since each +profile connects its own account to them. A provider only reads "Connected" when the provider could actually serve a request — its configuration saved and the provider switched on — so an installed plugin whose API key was never entered reads "Needs setup". Request servers (Sonarr, Radarr) are the exception to the provider-page rule: they are @@ -80,7 +149,11 @@ dismiss it. Every admin setting is one of: - **Essential** — shown by default, no disclosure needed. Target at most ~8 - essential controls per page above the fold. A setting is Essential only if a + essential controls per page above the fold. Placement on transcode nodes + ("Generate … on", "Where to analyze audio") is Essential only while a + transcode node exists; otherwise it is Advanced, and a disclosure opens by + itself when a node-only choice has no node to run on, so its warning is + never folded away. A setting is Essential only if a household admin on a single-node install would plausibly need it without being told to look for it (on/off toggles for a whole feature, the handful of values that make the feature usable at all). @@ -109,25 +182,28 @@ Reuse these instead of adding a bespoke variant per page: and `useSettingsForm` (`web/src/hooks/`) — the one save model. Every page batches edits and commits them through one `SaveBar` with Discard; provider credentials are the only exception, and only because they need - Test-before-commit, which is `ProviderTile` rather than a bespoke card per + Test-before-commit, which is `ProviderTile` rather than a bespoke row per provider. - `UnsavedChangesGuard` (`web/src/components/`) plus `useReportUnsavedChanges` (`web/src/hooks/useUnsavedChanges.ts`) — the one unsaved-edits prompt. A form - only reports that it is dirty; the settings shell mounts the guard once and - blocks router navigation (rail, back link, admin sidebar, browser back) with - a confirmation. `useSettingsForm` keeps a `beforeunload` listener for tab + only reports that it is dirty; the settings layout mounts the guard once and + blocks router navigation (the admin sidebar, browser back) with a + confirmation. `useSettingsForm` keeps a `beforeunload` listener for tab close and reload, which the router never sees. Blocking is `useBlocker`, which is why `App.tsx` mounts a data router (`createBrowserRouter` + `RouterProvider`) — a page must never grow its own prompt, its own blocker, or its own draft store. -- `SettingsPageHeader` (`web/src/components/settings/`) — the one way a - section names itself. Live state belongs on the Overview, not repeated as a - strip on every page. -- `SettingsPageRail` (`web/src/components/settings/`) — the one sibling nav, - rendered once by the settings shell from `ADMIN_SETTINGS_NAV` using the - shared `SideNavSection`/`SideNavItem` primitives. Pages never render their - own nav or add entries directly to the rail. -- `SettingsGroup` (`web/src/components/settings/`) — the one settings panel, +- `PageHeader`, `PageFrame`, and `PageColumn` (`web/src/components/page/`) — + the one page title, the one centered frame, and the one content column for + admin and settings pages; + `SettingsPageHeader` is the admin settings pages' way into it. Server health belongs in the Dashboard's attention + notice, not repeated as a strip on every page. +- `SideNavSection`/`SideNavItem`/`SideNavTopic`/`SideNavSubItem` + (`web/src/components/SideNav.tsx`) — the admin sidebar's building blocks. + Pages never render their own nav; a new settings page is an entry in + `ADMIN_SETTINGS_NAV`, its component in `AdminSettingsLayout`, and its place + in `ADMIN_NAV_SECTIONS`. +- `SettingsGroup` (`web/src/components/settings/`) — the one settings section, shared with the user settings pages; admin pages reach it through the `FieldGroup` wrapper, which layers on the restart-all line, the unsaved-edits dot, and the restart context. diff --git a/docs/architecture/api-contract.md b/docs/architecture/api-contract.md index ec56c06129..556f12b505 100644 --- a/docs/architecture/api-contract.md +++ b/docs/architecture/api-contract.md @@ -500,9 +500,9 @@ The foundation is `internal/apiv2`. These facts about it are not derivable from `Idempotency-Key` header under any other strategy: the strategy, its required header, and the durable replay store land together with the first operation the inventory proves needs them, and the field is never advertised unimplemented. Inventory answer: all 225 - tier-1 ported mutation rows (219 distinct operations) are classified (97 + tier-1 ported mutation rows (219 distinct operations) are classified (101 `natural_idempotent`, 24 `unique_constraint`, 12 `domain_identity`, 9 `coalescing`, 3 - `durable_dispatch`, 74 `non_retryable`, 0 `idempotency_key`, counted per distinct + `durable_dispatch`, 70 `non_retryable`, 0 `idempotency_key`, counted per distinct operation) and no residual group justifies a shared generic-key implementation. The `non_retryable` rows are a one-shot display or a secret shown once (invite-code top-up, admin session message, webhook rotate-secret, webhook test), a destructive command whose retry can hit state the @@ -539,7 +539,7 @@ The foundation is `internal/apiv2`. These facts about it are not derivable from `non_retryable` until their durable identity and cleanup defects are fixed. Library creation, settings updates, stale-ID rematch, provider-chain replacement, and taste seeding also remain `non_retryable`: their current seams cannot resume dispatch after saving state, or - repeat unclaimed provider work. Their ledger notes retain the required durable repair. Forty-three rows (42 distinct operations) + repeat unclaimed provider work. Their ledger notes retain the required durable repair. Thirty-nine rows (38 distinct operations) carry a `DEFECT` note where v1 gates on process-local state, fires an external effect inline, lacks the dedup or ordering its identity implies, or re-runs a side effect a retry should not repeat (task run, collection @@ -547,8 +547,8 @@ The foundation is `internal/apiv2`. These facts about it are not derivable from invite-code redemption during signup, playback route events, watch-together selection and promotion, metadata-match-queue retry, playback session progress, download status, ebook reader progress, and onboarding progress, - whose last-write-wins update lets a delayed retry rewind a newer report, favorites, - watchlist, and rating add and remove, the taste seed, subtitle download and upload, + whose last-write-wins update lets a delayed retry rewind a newer report, rating add + and remove, the taste seed, subtitle download and upload, whose unique key resolves a retry but whose losing insert deletes the winner's S3 object, transcode start, which replaces a live session under the same id, and media request creation, whose partial unique index stops covering a request once it reaches a @@ -1387,10 +1387,17 @@ paged with `limit` plus an opaque cursor whose cards are the shared `CatalogItem `{item_id, rating, rated_at}` entries instead), a membership read that answers the entry (`{item_id, added_at}`, or `{item_id, rating, rated_at}`) or `404`, a bodiless `PUT` add (ratings take `{rating}`) answering `204`, and a `DELETE` answering `204` whether or not -the entry existed. All six mutations are `non_retryable`: the shared seams dispatch provider -list and rating events and recommendation refresh without change gating, and rating updates -replace `rated_at` even when unchanged. Their ledger `DEFECT` notes retain the durable-dispatch work -required before clients can retry automatically. The mutations are not demo-restricted: v1's demo +the entry existed. The four favorites and watchlist mutations are `natural_idempotent`: each +store add and remove reports whether it inserted or deleted the row, and the shared v1/v2 seam +sends the provider list event, the recommendation refresh, and the user-state event only for a +change, so a repeated add or remove answers `204` and notifies nobody. The notices run detached +from the request, so a client that disconnects after the write does not cancel them, but they +are not durable: a crash between the write and the notices loses them, and a retry, which +changes no row, does not resend them. The scheduled provider export resends an add the provider +missed; a missed removal has no scheduled repair yet. The two rating mutations remain +`non_retryable`: the rating seam dispatches provider rating events and recommendation refresh +without change gating, and rating updates replace `rated_at` even when unchanged. Their ledger +`DEFECT` notes retain the durable-dispatch work required before clients can retry automatically. The mutations are not demo-restricted: v1's demo guard only blocks its listed routes, so these writes pass in demo mode and v2 matches. The three lists page by keyset, not offset: the cursor is the (`added_at`, `item_id`) — for ratings (`rated_at`, `item_id`) — of the last entry emitted, and the next page resumes strictly after it @@ -1609,6 +1616,14 @@ retry after an intervening unmark can create another watch; a durable client wat is still needed. History removal and unmark choose a fresh cutoff, so a delayed retry can hide an intervening watch. Sync accepts an omitted `updated_at`, so a delayed replay can overwrite newer progress; every item needs a stable event time before automatic retries are safe. +Two v2-only operations extend the section without ledger rows: `clearResumePosition` +(`DELETE /api/v2/progress/{media_item_id}/position`, tag `progress`) clears a movie or episode's +resume position while keeping its watched state and history, answers `204` (also when there is +nothing to clear), `422` at `path.media_item_id` for a season, series or ebook and `404` for a +kind `markWatched` does not handle, and is +`non_retryable` because a delayed retry would also clear a position recorded in between; +`getProgressCapabilities` (`GET /api/v2/progress/capabilities`) advertises it as +`clear_resume_position`. Semantics are in [the progress API](../progress-api.md#clearing-a-resume-position). **Section catalog-recommendations, stage A (Phase 4).** Seven profile-scoped reads under the `recommendations` tag: `listBecauseWatched`, `getDiscover`, `getForYouMain`, `listForYouRows`, @@ -2323,15 +2338,18 @@ for limits), without volatile metadata. Integration reads include persisted publ configuration and check status, all covered by the row revision, and a credential presence flag. API credentials are write-only. A blank update preserves the saved credential and retains the existing requirement to re-enter it when changing the -base URL. Plugin validation happens before the storage transaction; an intervening -edit still fails the final comparison instead of overwriting it. A failed plugin +base URL, which applies only when a credential is saved; clearing the base URL +counts as changing it. Plugin validation happens before the storage +transaction; an intervening edit still fails the final comparison instead of overwriting it. A failed plugin validation uses structured v2 problem errors for the web's inline field messages. A v2 integration's `base_url` is normalized before option loading and before create or update: `http://` is assumed when no scheme is given and a trailing slash is dropped, so the saved address is the one the options probe used. An address with credentials, a query or a fragment, or another scheme, is a -`validation_failed` problem on `body.base_url`. When `POST +`validation_failed` problem on `body.base_url`. An options request that leaves +`base_url` out tests the saved address; one that sends it blank tests a router +that declares `base_url_optional` without an address. When `POST /admin/request-integrations/{id}/options` fails, the host answers with its own sentences and never echoes the plugin's upstream text. What the admin must fix is a `validation_failed` problem on `body.base_url` (wrong address, missing URL @@ -2373,10 +2391,26 @@ forget requests, return `404`; successful commands return `204`. Both commands a non-retryable because a later retry can remove settings written in the meantime. Neither operation revokes login sessions or changes push registrations. +`PATCH /api/v2/devices/{device_id}` (`updateDevice`) updates the same registry entry, +with the same `profile_id` authorization and `404` for a missing device. Its one +member, `custom_name`, names the device for that profile: a string sets it, `null` +removes it, and omission leaves it unchanged. The server removes control characters +and bidirectional overrides, collapses spaces, and rejects a name with no visible +character (`422`). Registration never writes the custom name, so it survives the +device being seen again, and the reported `device_name` is kept. `listDevices` +returns `custom_name` only when one is set; clients show it in place of +`device_name`. The operation answers `200` with the entry as `listDevices` renders it +and is naturally idempotent. A name +belongs to one profile's entry, so a device two profiles use is named separately for +each. `supports_device_rename` on `getSettingsContractCapabilities` tells a client +the operation exists. Administrator device reads and download device views still +show the reported name. + The web device-settings screen uses these operations. The Apple and Android native clients have no callers for this settings-device lifecycle; Apple's push registration belongs to the separate notification contract. Jellyfin has no corresponding -settings-device operation. +settings-device operation; jellycompat does not serve Jellyfin's device list or its +custom device names. ### Administrator collections v2 diff --git a/docs/architecture/blob-storage.md b/docs/architecture/blob-storage.md index dc4f0b67e5..e793352f4b 100644 --- a/docs/architecture/blob-storage.md +++ b/docs/architecture/blob-storage.md @@ -442,13 +442,17 @@ timestamps are truncated to a quarter of the token TTL instead, which leaves each URL valid for at least three quarters of it. Other presigned and token URLs are issued fresh on every resolve, and public URLs never change. +Every backend consults the publication manifest for a rung a ladder change +added: existing revisions lack the new widest rung until the ladder backfill +regenerates them, so a request for it gets a smaller rung the revision +published, and artwork without a manifest is limited to its type's medium rung. Local storage publishes each object with an atomic rename, and direct S3 reads -see an object as soon as its upload returns, so catalog responses resolve the -manifest key they hold and a missing object answers 404 and enqueues repair. -Only external delivery (a public or token-authenticated read endpoint in front -of S3) can lag behind a write. That configuration alone runs the -`verify_artwork_delivery` task and consults the verified-keys manifest when -choosing which variant to advertise. +see an object as soon as its upload returns, so on those backends every other +key resolves as held, without a lookup, and a published key that is +nevertheless missing answers 404 and enqueues repair. Only external delivery (a +public or token-authenticated read endpoint in front of S3) can lag behind a +write. That configuration alone runs the `verify_artwork_delivery` task, and it +consults the verified-keys manifest for every key. A verdict is scoped to the delivery configuration and is current only until the revision is published again. Publication records exact keys only after diff --git a/docs/architecture/collection-editor.md b/docs/architecture/collection-editor.md index e1174576ce..dbad78372f 100644 --- a/docs/architecture/collection-editor.md +++ b/docs/architecture/collection-editor.md @@ -152,16 +152,14 @@ list follows counts once, as the list. - **What can change depends on the source.** A server MDBList or TMDB list changes its link; a server TMDB chart changes its chart or moves to a TMDB list; a franchise list changes its TMDB collection ID. A profile changes its MDBList or TMDB list link; a chart it follows stays as it was - made. Discover lists (from Starter packs) and legacy Trakt lists show their source read-only. + made. Discover lists (from Starter packs) show their source read-only. - **What a server save sends.** The PATCH carries every field and rebuilds the source whole (`source_url` and `source_config`), as the server stores it: a changed MDBList link loses its `?query`, `#fragment` and trailing slash, and a TMDB list link becomes its canonical `https://www.themoviedb.org/list/{id}`. A Discover list and a franchise list with no ID yet send - no source unless max titles changed, and then send their stored config with the new limit; every - Trakt list sends no source, so the server keeps the stored one. A new chart unticks libraries that - can't hold its titles, as on create, because the server doesn't check library kinds on update. A Trakt list's - libraries and max titles are locked, and a stopped Trakt schedule can't be turned on, because - the server refuses those changes. A save never sends `featured`. + no source unless max titles changed, and then send their stored config with the new limit. A new + chart unticks libraries that can't hold its titles, as on create, because the server doesn't + check library kinds on update. A save never sends `featured`. - **What a personal save sends.** Name, sharing, libraries, the Collections tab switch and Show only always go; description, order, the link (`source_url`), max titles (`max_items`, `0` for the whole list) and the schedule go only when they changed. diff --git a/docs/architecture/collection-templates.md b/docs/architecture/collection-templates.md index f6b8a352c5..203a90f041 100644 --- a/docs/architecture/collection-templates.md +++ b/docs/architecture/collection-templates.md @@ -114,8 +114,6 @@ templates only, never bundles. The rules the web keeps: `validateTMDBFranchiseConfig` (`internal/catalog/library_collection_service.go`) fails its sync until a real ID is set. `Template.NeedsSetup` reports this case: bundle apply creates the collection without a first sync, and the `/api/v2` bundle list marks its summary `needs_setup`. -- `trakt`: the validator still accepts it, but the server rejects new Trakt collections with - `unsupported_source`. Don't add Trakt templates. ## Templates in the Synced list step diff --git a/docs/architecture/deterministic-content-id.md b/docs/architecture/deterministic-content-id.md index fac2c35ab1..4015ec0c12 100644 --- a/docs/architecture/deterministic-content-id.md +++ b/docs/architecture/deterministic-content-id.md @@ -203,6 +203,30 @@ catalog-driven column enumeration so the two stay in lockstep; `ON UPDATE CASCADE` only fires when a `content_id` actually changes (at this promotion and at a corrected-match re-anchor), so there is no steady-state cost. +### Merging onto the owner of a provider ID + +A match can merge an item into another item that already holds one of its +provider IDs (`findExistingByProviderIDs`): a provisional item at its match, a +new item's dedup, and a provider-ID unique conflict at persist all do, and a +scanned file links to the matched item that holds one of its folder's tagged +IDs. Sharing an ID is not enough. The two items must also agree on the first ID +both carry, in the order the item type's content ID anchors +(`identityProviderIDsContradict`, `contentid.AnchorPrecedence`): TMDB, IMDb, +then TVDB for a movie; TVDB, TMDB, then IMDb for a series. Provider cross-ID +mappings are community-edited and sometimes list one TVDB or IMDb ID on two +neighboring titles, and merging on such an ID folded a folder tagged +`[tmdb-125249]` into `movie-tmdb-43506` (issue #2263). + +When the items disagree and the item that already holds the shared ID is +matched, it keeps the ID, and the other item is persisted without it +(`recoverProviderIDConflict` logs `left a provider ID with the other title that +holds it`). A holder that is not yet matched gives up its provider IDs instead, +as for any provisional owner. When the tag lookup refuses an item, the file +links to the matched item at the tags' own content ID if there is one, and +gets a new provisional item otherwise. The anchoring ID decides first, so a movie whose TVDB mapping +changed since it first matched still merges on its TMDB ID, and a series whose +TMDB mapping changed still merges on its TVDB ID. + ### Re-anchor on a corrected match An item matched to the wrong title sits on that title's id, for example diff --git a/docs/architecture/discovery-row-ranking.md b/docs/architecture/discovery-row-ranking.md index a102a9da6f..53fca30417 100644 --- a/docs/architecture/discovery-row-ranking.md +++ b/docs/architecture/discovery-row-ranking.md @@ -56,7 +56,7 @@ section's libraries with the viewer's allowed libraries and applies the access filter's content allow-list and name prefix (`catalog.AppendContentScope`). Rows that sort by rating without requiring a vote count (format showcases, -anniversaries, seasonal keyword picks) use `catalog.RatedOrder`: the weighted +anniversaries) use `catalog.RatedOrder`: the weighted rating first, then titles with no known count by `rating_tmdb` and then `rating_imdb`, so a perfect score from a few votes never leads them either. The default Top Rated library rows are rule rows and keep the sort their diff --git a/docs/architecture/download-storage.md b/docs/architecture/download-storage.md index fd3bd47887..492ea932db 100644 --- a/docs/architecture/download-storage.md +++ b/docs/architecture/download-storage.md @@ -32,7 +32,13 @@ request cannot both win: once the touch commits, the file was used too recently expire, and a request that loses gets `prepared_file_expired`. A missing file found by recovery, or a node requeue, expires the row instead of -deleting it, and only in-flight rows are reset to `preparing`. An expired row is not a +deleting it, and only in-flight rows are reset to `preparing`. The reset keeps the +revision, so a device that fetched the whole file before it can still report +`completed`. That report applies to the `preparing` row, and when no in-flight +download is left and the job is still waiting in the queue, the job returns to +`expired` in the same transaction. A job a worker already claimed finishes into the +cache: an expired row keeps its id and output path, so stopping a live encode could +race a later prepare-again. An expired row is not a preparation: the admin preparation list, queue positions, and cancel skip it. Neither the failed sweep nor a revoke's cancel of abandoned preparations deletes a row that a live download refers to, in flight or finished, because finished downloads read its @@ -46,6 +52,16 @@ transcode node (`node:`). Each has a budget: the node's `download.artifact_max_bytes`. A node can also set `download_artifact_dir_override`; the node applies it at its next restart. +With `download.artifact_dir` blank, the API server's directory is +`silo-download-artifacts` beside `playback.transcode_dir` (`/tmp/silo-download-artifacts` +by default), never inside it: the orphaned-transcode sweep deletes unknown +subdirectories of the transcode directory. A node with no directory set uses +`download-artifacts` inside its own transcode directory. Because the server's default +sits outside the transcode mount, the default `docker-compose.yml` mounts it +separately under `PRAIRIE_DATA_ROOT`; without that mount the files live in the +container layer and every container recreation sends them through missing-file +recovery. + Maintenance runs every five minutes on one replica at a time, under the session advisory lock `pg_try_advisory_lock(0x5110d1, 1)`. The lock is held on a dedicated session outside the pool, so the pass's own queries work even with diff --git a/docs/architecture/dropped-shows.md b/docs/architecture/dropped-shows.md index 69b5200c21..da5124a411 100644 --- a/docs/architecture/dropped-shows.md +++ b/docs/architecture/dropped-shows.md @@ -2,7 +2,7 @@ A profile can drop a series to take it off its Next Up and Continue Watching rows without touching its watch history. Drops sync both ways with watch providers that -support them: Trakt, Simkl, and watch-sync plugins that advertise `sync_dropped`. +support them: Simkl and watch-sync plugins that advertise `sync_dropped`. ## Local drops @@ -10,7 +10,8 @@ support them: Trakt, Simkl, and watch-sync plugins that advertise `sync_dropped` dropped. A drop is **active** while no episode of the series has watch progress, or a history-hide stamp (mark unwatched, remove from history), newer than `dropped_at`. So watching any episode again undrops the series, whichever path recorded the watch, and no -writer needs to know about drops. Imported history keeps its original watch time, so +writer needs to know about drops. Clearing an episode's resume position is a progress write +too, so like mark unwatched it ends the drop. Imported history keeps its original watch time, so re-importing old plays never undrops a series. An inactive row means the same as no row; watch sync removes it. @@ -66,11 +67,11 @@ A tombstone whose key names neither changes nothing: that is usually the provide echoing an undrop whose agreement Prairie already forgot. Local imports are compare-and-set on the row the run read, so a concurrent dismissal or undo wins. -Provider reads can lag or omit Silo's writes. Trakt serves its GET responses from a cache -for up to an hour, and its dropped list leaves out drops that apps make, so a drop Silo -sends is usually never confirmed by a read. Such a drop stays agreed and is not sent -again; resending it would drop the show again each time the user undropped it on Trakt, -which Silo cannot see. An undrop of a drop a read had confirmed keeps its agreed row +Provider reads can lag or omit Prairie's writes. A provider may serve reads from a cache, +or leave drops that apps make out of its dropped list, so a drop Prairie sends may never be +confirmed by a read. Such a drop stays agreed and is not sent again; resending it would +drop the show again each time the user undropped it on the provider, which Prairie cannot +see. An undrop of a drop a read had confirmed keeps its agreed row until a read shows the show is gone, so a stale read resends the undrop instead of importing the old drop back; an undrop of a drop no read confirmed forgets the row at once. Both writes are idempotent. @@ -85,10 +86,6 @@ undrop a show that is watched. ## Providers -- Trakt: `GET /users/hidden/dropped` (every page, verified by a second read), and - `POST /users/hidden/dropped` and `/users/hidden/dropped/remove`. Trakt's older hidden - sections (`progress_watched`, `calendar`) are not drops and are not read. Trakt - undrops a show itself when the user watches it. - Simkl: `GET /sync/all-items/{shows,anime}/dropped`, skipped while the lists' `all` activity stamps are unchanged. Dropping moves a show to the `dropped` list with `POST /sync/add-to-list`; undropping moves it to `watching`. Simkl records no drop diff --git a/docs/architecture/external-watchlist.md b/docs/architecture/external-watchlist.md index 5c58efbbab..4894d3b4e6 100644 --- a/docs/architecture/external-watchlist.md +++ b/docs/architecture/external-watchlist.md @@ -157,7 +157,7 @@ catalog merges them. For each title with exactly one visible copy: run `Effects.WatchlistPromoted`, implemented by `handlers.PersonalDataHandler`: the provider export event (`dispatchLocalListEvent`), the recommendations refresh - (`triggerProfileRefresh`) and a realtime `user_state.changed` event with + (`notifySignalsChanged`) and a realtime `user_state.changed` event with `change: "watchlist"`. These are the effects of a manual add. The library add in step 3 uses a second pool connection while the promotion's @@ -296,7 +296,9 @@ kept. Promotion on library reads does not depend on that gate. one a re-add put back, is on the watchlist and keeps its request. An absent entry succeeds with 204. -Both mutations are `non_retryable`, as the library watchlist mutations are. +Both mutations are `non_retryable`: an add can create or follow a request and a removal can +cancel a watchlist-made request. The library watchlist mutations they reuse are +`natural_idempotent`, because they notify only for a changed row. Discovery results and the title detail carry `in_watchlist`, hydrated with two reads per page: title entries by TMDB ID, and the library watchlist by `library_content_id`. `GET /api/v2/requests/status` advertises @@ -339,7 +341,7 @@ least 15. ## Not built -Watch-provider (Trakt, Simkl, MDBList) and Plex imports still drop rows the +Watch-provider (Simkl, MDBList) and Plex imports still drop rows the library doesn't have, and export only library items. Carrying unowned titles through them needs its own design; imports would never auto-request. A profile that cannot request, or has opted out, gets no arrival notification: only the diff --git a/docs/architecture/history-import-execution.md b/docs/architecture/history-import-execution.md index 725f34bb5d..80ba5a0780 100644 --- a/docs/architecture/history-import-execution.md +++ b/docs/architecture/history-import-execution.md @@ -88,7 +88,10 @@ Silo progress. Favorite-only records add the favorite without creating progress. Source servers are user-supplied, so what an import reads from one is bounded (`internal/historyimport/upstream_limits.go`): each successful response is decoded only up -to 16 MiB, and each paged listing stops at 5,000 pages, 500,000 items, or 512 MiB in total. +to 16 MiB and 250,000 JSON values, and each paged listing stops at 5,000 pages, 500,000 +items, or 512 MiB in total. One budget also covers the whole run, across every listing, +Plex section, ID lookup, and metadata sweep: 2 GiB read and an estimated 256 MiB of kept +items. Lookups by ID keep only the items they asked for, one per ID. Going over a limit fails the run with its own message. That includes lists whose other failures only add a warning (resume positions, favorites, the watchlist, Plex sections, and metadata lookups), so an import never completes without a list the source sent. diff --git a/docs/architecture/intro-detection.md b/docs/architecture/intro-detection.md index e27fab6e72..fff9a506fb 100644 --- a/docs/architecture/intro-detection.md +++ b/docs/architecture/intro-detection.md @@ -59,6 +59,17 @@ Chromaprint points summarize a window that starts at the point's timestamp, so raw matches start and end early. Fixed leads measured against authored intro chapters move both boundaries back. +Every ffmpeg attempt that detection makes (fingerprints, tail passes, and +silence detection) has a deadline of two minutes plus the running time of +the media it reads, so a ten-minute opening gets twelve minutes. Storage +that can play a file reads it at least as fast as it plays, so a run still +going at its deadline has almost always stalled; a host busy enough to +starve background decoding can also reach it. ffmpeg is killed and the run +fails as a `timeout`, which is not permanent: a fingerprint counts as a +failed extraction and leaves its group partial, a tail pass is stored +`failed` with backoff, and the other files carry on. A tail pass whose +hardware attempt times out tries software on a deadline of its own. + ## Confidence Chromaprint confidence is the rate at which markers of the same kind covered @@ -251,8 +262,9 @@ These rules were validated on frame-checked episodes and movies from several series; every start they placed early was checked against frames and cut no story. -Automatic contribution to online providers stays intro-only; detected credits -are contributed only on request. +Automatic contribution to online providers shares detected credits as it does +intros, when they meet the provider's minimum confidence. At the default 0.95, +that is chapter credits and season-agreed audio credits that video moved. Credits versions and caches: diff --git a/docs/architecture/local-nfo-metadata.md b/docs/architecture/local-nfo-metadata.md index a1f99739ba..9943a302b6 100644 --- a/docs/architecture/local-nfo-metadata.md +++ b/docs/architecture/local-nfo-metadata.md @@ -309,7 +309,8 @@ Sidecar images are discovered by the provider's `GetImages` always receive the normal presigned `poster_url`/`backdrop_url`/`logo_url` (and, on `/api/v2` movie and series detail, `banner_url`, `thumb_url`, `clearart_url`, `disc_url` and `backdrop_urls`); library files are never served -directly, and API nodes never need filesystem access to libraries. +directly. The admin image picker (below) is the only API path that reads +sidecar files. Discovery lists each folder once per request and matches names ignoring case, so `Poster.JPG` and `Folder.jpg` count on case-sensitive filesystems; an entry @@ -401,7 +402,11 @@ Caching and refresh: processor confines each read to the owning library's `media_folders` roots: a lexical check on the logical path, then a symlink-resolving re-check (both path and roots are resolved, so a legitimately symlinked root stays valid - while an intermediate directory symlink escaping a root is rejected). When + while an intermediate directory symlink escaping a root is rejected). Each + library root is opened as an `os.Root` before the path is resolved, and the + file is opened through the handle that is the same directory as the resolved + root, so a root or folder swapped for a symlink after that point cannot lead + the open out of the library. When the re-check or the read fails, the processor probes, through `internal/rootcheck`, the nearest folder that exists at or above the file's folder, searching no higher than the library root: unless it lists entries @@ -433,14 +438,17 @@ Caching and refresh: that offers no artwork at all leaves the slot, local source included, as it is. Locked artwork gets no stand-in, and neither does an image URL an NFO names: its slot stays empty until the image cache has downloaded it. -- `metadata.cache_images` governs remote artwork only. With it off, local art - files are still selected, copied, and served: the drain claims local jobs - only (through the partial index `metadata_image_cache_jobs_local_due_idx`, so - remote jobs left queued are not read), a manual refresh waits for local jobs - only, and the provider-artwork backfill stays off. An image URL an NFO names - is a download, never served directly, so with caching off it is not selected - (`localSourceUsable`) and the slot takes provider artwork, as it does for - person photos. +- With `metadata.cache_images` off, clients load artwork straight from the + providers, so no local artwork is used (`localArtworkUsable`): refreshes, + targeted season and episode refreshes, scan re-applies, and person photos + drop local candidates, items keep no extra backdrops, and the image cache + claims, copies, and downloads nothing. NFO text still applies. A refresh + also stops keeping stored copies: `preserveCachedArtwork` lets a provider + path replace one unless the artwork is locked, and an unlocked item's stored + poster, backdrop, and logo count as empty for `applyBestImages`, so even a + fill-empty refresh takes an unrated provider image. Artwork stored before the + setting went off is released by `StoredArtworkReleaser` (see + [settings-api.md](../settings-api.md)). Precedence: local artwork beats remote providers wherever the NFO provider sits in the chain. @@ -515,8 +523,8 @@ reference is a path relative to the NFO or an http(s) URL: sidecar file: `isLocalImageSourcePath` counts it, it waits in `*_source_path` until the image cache downloads it through its public-address guard, it is never served to clients or fetched by - jellycompat's image proxy, and it is not used while - `metadata.cache_images` is off. Like a sidecar, it is re-copied by a + jellycompat's image proxy, and, like all local artwork, it is not used + while `metadata.cache_images` is off. Like a sidecar, it is re-copied by a refresh or the ladder backfill, not by the provider-artwork sweeps. Its copy is attributed to the `nfo` provider and keyed like a provider's (`nfo/{contentType}/{contentID}/{imageType}/...`); only `file://` copies @@ -544,8 +552,45 @@ still waiting for its copy, stays. It queues a person image-cache job whose `ser title, so the processor confines a local read to that title's library roots; a title's interactive artwork refresh leaves those person jobs out of its scope. A person refresh keeps a cached NFO photo over a provider's. Like other -local artwork, a sidecar photo is copied whatever `metadata.cache_images` says; -a photo URL an NFO names is used only while the setting is on. +local artwork, NFO photos are used only while `metadata.cache_images` is on; +photos already copied stay (the stored-artwork release leaves people alone). + +### Admin image picker + +`GET /api/v2/admin/items/{id}/images` also offers a movie's or series' local +sidecar artwork. The built-in sidecar discovery runs with the item's media files +and sidecar directories, so it finds the same files a refresh would, and it runs +whether or not the library's metadata chain uses the NFO provider: choosing an +image is an explicit admin action. Local choices come only from that discovery; +a `file://` URL returned by any provider in the chain is dropped, so a provider +cannot get another library file offered as this item's artwork. Only sidecar +files (including files an NFO names by relative path) for the slots an admin can +apply (poster, backdrop, logo) are offered: an image URL an NFO names has no file +to read here, and banner, thumb, clear art and disc art cannot be applied. Each +local choice has the provider ID `local`, keeps its fingerprinted `file://` +source as `original_url`, and shows a 300-pixel-wide WebP preview as an inline +`data:` URI (the web client authenticates with a bearer token, so an `` +cannot fetch an authenticated preview route). Local choices lead the list, ahead +of every provider's, in discovery order, so each slot's first local choice is +the sidecar file a refresh uses when it takes local artwork. The preview is read under the same +root confinement, symlink and size checks as the processor; a file that fails +them is left out of the list, and the list's `provider_errors` reports it under +`local`. Only the movie and series list does this: any other list treats a +provider's `file://` URL as an ordinary provider choice and never reads it. + +`POST .../images/apply` with a `file://` `original_url` caches the file only +when the item's sidecar discovery offers that exact source for that image type, +so a file replaced since the list (a new fingerprint) must be picked again. It +reads the file under the same checks, stores it under the same `local/...` key +the processor would use, and publishes the fingerprinted source with the images +lock like any other choice. Seasons, episodes and frozen v1 keep provider-only +choices. The server answering the admin API reads the file, so it needs the +library mounted, like a server that copies local artwork; on a server without +the mount discovery finds nothing and local choices are not offered. + +The reads open each library root as an `os.Root`, which needs read (list) +permission on the root directory, not only search permission. The scanner lists +the roots, so a server that scans a library already has it. ## Series depth and mixed libraries @@ -681,8 +726,8 @@ refresh (`internal/metadata/local_sidecar.go`, `local_reapply.go`). ## Known limitations -- The admin image picker does not surface local art (automatic chain path - only). +- The admin image picker offers local art for movies and series only, not + for seasons or episodes. - Seasons have no banner, thumb or extra backdrops (`seasonNN-fanart`, `seasonNN-banner`, `seasonNN-landscape` and their season-folder forms are not read). diff --git a/docs/architecture/media-naming.md b/docs/architecture/media-naming.md index 0785ee7cee..6994b2afa7 100644 --- a/docs/architecture/media-naming.md +++ b/docs/architecture/media-naming.md @@ -46,6 +46,39 @@ one show). Deeper folders, and names that start with the show title, keep the UK (`La Série 2/La Série 2 S01E03.mkv`) keeps the label as part of the title. `TestSeasonDirectoryWordLabelsAtTheLibraryTop` and `TestWordSeasonLabelsKeepShowContext` hold these cases. +### Extras folders in a series library + +The scanner's extras classifier (`internal/scanner/extras.go`) decides whether a folder named +like an extras folder (`other`, `Trailers`, `Featurettes`, `Extras`, ...) holds extras or +content. It is an extras folder only when its owner, the first ancestor not named like one, +holds media directly or belongs to a title: a file up to three levels below the owner +(`Season 01/Disc 1/episode.mkv`), outside the owner's own extras folders, resolves to the owner, +to a show folder above it, or to a folder inside it with the owner's own title and year (a +version folder such as `Alien (1979)/1080p/`, or a wrapper such as `Show/Show/`). A category +folder such as `Kids/` holds shows that resolve to their own folders under other titles, so +`Kids/Shorts/` and `Documentaries/Interviews/` stay content and keep their own series; so does a +franchise folder such as `Doctor Who/` holding `Doctor Who (2005)/`. A folder named like an +extras folder that holds episodes in season folders (`Kids/Shorts/Season 01/Shorts S01E01.mkv`) +is a show of its own wherever it sits. A full scan, a subtree scan of the extras folder, and a +single-file event apply the same test; the last two read the filesystem. A show folder with no +episodes yet owns nothing, so its `trailers/` stays a series of its own until the show has an +episode in the library; a later scan then turns it into an extra. + +An episode code makes a file in an extras folder an episode only under the show's own `Extras/` +folder (`Show/Extras/Show S00E01.mkv` is a special). Anywhere else (`other/`, +`Trailers/1080p/`, `Season 01/Extras/`, `Extras/Behind The Scenes/`) it is part of an extra's +name, so a deleted scene numbered `S01E05` never becomes a second version of the real episode. +An extra binds to its owner's item and never founds a series; while the owner has no item yet +(a show's first scan), the scan defers it to the next one. + +`deriveSeriesRoot` puts a `Specials/` or `Extras/` folder inside a season folder (`Season 01`, +`S01`, or a bare `Season` folder) with that season's show. A bare number or a +`Specials`/`Extras` label above it (`Drama/1883/Specials/`, `Extras/Specials/`) is the show's +title, not a season, and so is a folder the file's own title names (`Drama/Season 2/Specials/Season +2 S00E01.mkv`) or a season word with more title after it (`Series 7 Documentary/`). +`TestSeriesExtrasFoldersBelongToTheirShow`, `TestMixedLibraryVersionFolderOwnsExtras`, and +`TestSpecialsInsideASeasonFolderJoinTheShow` hold these cases. + ## Episode numbers - `parseEpisodeNumber` (`internal/naming/episode.go`) returns 0 for a digit run longer than diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index 2979d7d948..341c6ec619 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -97,7 +97,8 @@ records the route that sent it. A server a route sends to cannot be deleted until the route stops using it, so deleting a server never silently reroutes titles, and it cannot be switched to the type the route's media type cannot use (Sonarr for movies, Radarr for series) or stop taking that media type. A chosen -server that is disabled, not set up (no installation, no key), of the wrong type +server that is disabled, not set up (no installation, or no key when its plugin +needs one; see [Without a router](#without-a-router)), of the wrong type or not taking the media type anyway is an admin-fixable problem: when nothing has been sent yet, the submission retries with backoff; a later tier that fails that way @@ -391,6 +392,28 @@ installation) is a setup problem instead: the submission retries with backoff and records the reason in `last_error`, so fixing the connection lets the request through. +A connection needs an API key unless its plugin declares +`request_router.api_key_optional` in its manifest. The host reads the +declaration from the capability metadata stored when the plugin was installed +or updated, without launching the plugin, and only for a connection without a +key. Such a connection is routed, status-checked and probed like any other, +with an empty key. A connection whose declaration cannot be read is skipped +like one without a key; when no other connection can take the request, the +submission retries with the read error. The v2 plugin installation view +carries the same stored declaration, with `request_router.base_url_optional`, +so the admin form saves a server without a key or URL only when routing will +use it; the host itself never requires a URL. A saved key still has to be +typed again when the server's URL changes to another address; a server saved +without a key has none to protect, so its URL can change without one. The save +checks this again under the row lock, so a key saved, or an address changed, +by another admin in the meantime is never paired with a different address. A +connection test of a saved server that leaves the URL out tests its saved +address. A blank URL in a test stands for the saved address too, unless the +plugin declares `base_url_optional`; then it is tested blank, so an admin can +clear it. A save validates and stores the URL it is given, blank included. +Clearing it counts as changing the address: a saved key has to be typed again, +and the test goes without it. + The library also completes requests that never reached a router: a `pending` request whose title appears needs no approval any more, and a `failed` request whose title appears is complete, unless it failed after delivering one quality, diff --git a/docs/architecture/media-sampling.md b/docs/architecture/media-sampling.md index a1924f5491..1c18241024 100644 --- a/docs/architecture/media-sampling.md +++ b/docs/architecture/media-sampling.md @@ -97,7 +97,8 @@ statistics. Rules: one keyframe interval earlier. Frame timestamps follow the list, not the input, so a frame without the tag is dropped. A keyframe that serves several sample times is decoded once for each; when one sample decodes a - second keyframe, the first is kept. + second keyframe, the first is kept. Sheets lists over Matroska start their + entries at keyframes instead (see [Keyframe entries](#keyframe-entries)). - Sample times are finite, non-negative, strictly increasing, and at most 10,000 per request. `Samples` takes no audio output. - `Samples.ReadThrough` reads the sampled span as the one keyframes-only @@ -152,8 +153,8 @@ is held as PCM. Subtitle sync is its consumer. `POST /media-samples/run` on the transcode-node listener runs one `Request` with the node's ffmpeg and returns its `Result` as JSON -(`mediasample.RemoteClient` is the caller). The node requires its bearer -secret and an input path it is allowed to read, accepts software attempts +(`mediasample.RemoteClient` is the caller). The node requires a +[signed request](worker-http-protocol.md#authentication) and an input path it is allowed to read, accepts software attempts only, and answers a failed run with `422` and a `RemoteFailure` whose reason is the run's `Classify` cause. `RemoteError.Infrastructure` tells a caller whether running the request elsewhere may succeed: an unreachable node, a 5xx, @@ -173,23 +174,30 @@ An attempt with `Hardware` set decodes the request's video on the runner's `HardwareResolver` (`hwresolver.go`), which caches the resolved backend per configured pair until the playback probe cache is invalidated, and asks again after `HardwareRetryInterval` when `auto` resolved to no hardware; credits -detection uses one. Only QSV, VAAPI, and VideoToolbox decode -here (`SupportsHardwareDecode`); NVENC does not. A hardware attempt needs a -video output (`Images` or `Stats`). Audio in the same run always decodes in -software. +detection uses one. QSV, VAAPI, and VideoToolbox decode +every video output (`SupportsHardwareDecode`); NVENC decodes only `Sheets` +(`SupportsHardwareSheets`), on CUDA, so the stats chains cached intro +fingerprints come from and the chapter-thumbnail images stay as they were. A +hardware attempt needs a video output (`Images`, `Stats`, or `Sheets`); one +whose output its accelerator does not decode fails as `unsupported` without +starting ffmpeg. Audio in the same run always decodes in software. - QSV and VAAPI decode into VAAPI surfaces on a render device. QSV initializes its VAAPI parent device the way playback does (`tonemap.QSVInitDeviceArgs`) and decodes through it. +- NVENC decodes into CUDA surfaces on the CUDA device `HWDevice` names (an + index, or the default when empty), declared as the filters' device too: + `-init_hw_device cuda=cu[:N] -filter_hw_device cu -hwaccel cuda + -hwaccel_output_format cuda`. - VideoToolbox decodes `Images` into system-memory frames, so software filters apply to them directly. `Stats` asks for VideoToolbox surfaces (`-hwaccel_output_format videotoolbox_vld`) and downloads them, because only surfaces prove the decode ran on hardware: given a stream VideoToolbox cannot decode, such as VP8, plain `-hwaccel videotoolbox` quietly decodes in software, while the download of a software frame fails the attempt. -- Each hardware attempt reserves one render device through - `playback.AcquireHWDevice`, falling back to `playback.PickRenderDevice`, - before its timeout starts, and releases it when the attempt ends, before +- Each hardware attempt reserves one device through + `playback.AcquireHWDevice`, falling back to `playback.PickRenderDevice` for + QSV and VAAPI, before its timeout starts, and releases it when the attempt ends, before the next attempt. A multi-device `Runner.HWDevice` list therefore resolves to one device per attempt. A hardware attempt that cannot be built, such as VAAPI without a render device, fails as `unsupported` without starting @@ -198,6 +206,10 @@ software. the hardware one. A failed run takes its reason from its last attempt, so once the software attempt has run, a hardware failure costs only time and cannot mark an artifact unusable. +- A GPU that cannot decode the stream makes ffmpeg log "Failed setup for + format …" or "doesn't support hardware accelerated …" and fall back to + software frames, which the GPU chain refuses; the attempt's cause is + `unsupported`. `Stats` on VAAPI surfaces (VAAPI and QSV) scales the whole picture on the GPU and converts it to 8-bit NV12 there before the download, since a 10-bit @@ -297,21 +309,54 @@ metadata=mode=add:key=sample:value=-1,metadata@sheets=print `Sheets.UseInputAspect` derives the height from the input header instead, including a 90-degree display matrix, and returns the actual cell height in -`Result.SheetTileHeight`. This reuses the sampling probe; a `ReadThrough` -request also probes when this option is set. Callers publishing manifests +`Result.SheetTileHeight`. This reuses the sampling probe, which every +`Sheets` request runs, `ReadThrough` included. Callers publishing manifests must use the returned height and reject chunks whose geometry differs. -- HDR sources are scaled before they are tone mapped, so the software - tone-map chain of [Images](#images) only handles thumbnail-sized frames, - followed by `scale=out_range=full:out_color_matrix=bt601`. VAAPI and QSV - tone map on the GPU first (`tonemap_vaapi` needs the source's HDR - metadata), scale with `scale_vaapi`, and download. +- GPU surfaces (VAAPI, QSV, CUDA) are scaled on the GPU to four tiles' size, + or the frame's own size if smaller, downloaded as NV12 (P010 for a source + tone mapped below), and area-scaled to the tile in software: + + ```text + scale_vaapi=w=min(iw\,4W):h=min(ih\,4H):format=nv12,hwdownload,format=nv12,scale=W:H:flags=area:out_range=full:… + scale_cuda=w=min(iw\,4W):h=min(ih\,4H):interp_algo=bilinear:format=nv12,hwdownload,format=nv12,scale=W:H:flags=area:out_range=full:… + ``` + + The GPU scalers sample a few source pixels per output pixel, so scaling a + 4K frame straight to a 300-pixel tile aliases: SSIM 0.94 against the + software area scale on four 4K frames, where the two-step scale gives 0.999 + and downloads a fraction of the frame. +- HDR sources are tone mapped in software after scaling, so the tone map + only handles thumbnail-sized frames, followed by + `scale=out_range=full:out_color_matrix=bt601`. +- `ToneMap.Source` names the base signal `tonemap.ResolveSource` classified + the input as. With it, every attempt converts with the chain playback + converts that signal with in software (`tonemap.SoftwareFilter`) on a + filter the binary lists: `tonemapx`, else `tonemap`'s Hable curve, which + cannot apply a Dolby Vision RPU. GPU attempts decode and scale as above, + download 10-bit P010 (`format=p010`, `hwdownload,format=p010le`), and take + the same software chain, so every decoder gives the same colors. The GPU + tone mappers do not: on an Intel Arc, `tonemap_vaapi` crushed PQ midtones + and turned HLG teal and inverted, AMD's refuses HDR, and neither applies an + RPU. A frame keeps its Dolby Vision RPU through the GPU scale and the + download (SSIM 0.986 against the software render, 0.85 without the RPU). + An attempt the binary has no filter for, such as a Dolby Vision base + (Profile 5, compatibility id 0) without `tonemapx`, or one that does not + allow software tone mapping, fails as `unsupported` before ffmpeg starts, + so it makes no sheets rather than green and magenta ones. A base that is + already SDR only changes gamut. Without `Source` the [Images](#images) + chains apply, as best effort for sources whose catalog facts cannot be + classified; CUDA has none of those, so its attempt is `unsupported`. Images + take no `Source`. - `metadata=print` logs a frame only when it carries metadata, so `metadata=mode=add` gives every frame a `sample` entry of -1 unless its packet was tagged. The log then counts every frame on stdout, and the Nth - raw frame is the frame logged as `frame:N`. A counter that skips or - restarts, frames without log lines, log lines without frames, or bytes - after the last whole frame fail the attempt as `output`. + raw frame is the frame logged as `frame:N`. A filter graph reinit, which a + hardware decoder that renews its surfaces causes mid-list, restarts the + counter at zero after the old graph passed on every frame it logged; the + frames still line up, and the count goes on. A counter that skips or + restarts anywhere else, frames without log lines, log lines without frames, + or bytes after the last whole frame fail the attempt as `output`. - `-fps_mode passthrough` is required: a list's timestamps jump back at every entry, and rawvideo would otherwise pick a constant frame rate and drop or duplicate frames. Windows bound their duration with an input `-t`, since an @@ -329,42 +374,196 @@ must use the returned height and reject chunks whose geometry differs. half a second recovered all of them at no measurable cost. Stats keep the 40 ms span their cached analyses were computed with. A window gives each sample the last frame at or before its time. +- Sheets decode keyframes as the probed video codec needs. Interlaced MPEG-2 + often codes a keyframe as an I field paired with a P field, which the + decoder reports as a P frame and `nokey` discards: a DVD-sourced episode + decoded 2 of 80 samples. MPEG-2 therefore decodes with + `-skip_frame:v noref`. The MPEG-1/2 decoder honors only `noref` and + `nokey` (`nointra` acts as `noref`), so this decodes every I and P + picture, about two and a half times the work of `nokey`. A list still + keeps each sample's first frame, its keyframe; a window gives each sample + the last I or P picture at or before its time. MPEG-1 has no field + pictures and keeps `nokey`. +- MPEG-4 Part 2 (`mpeg4`, `msmpeg4*`) adds `-flags:v +low_delay`. Its + decoder otherwise holds each picture back for B-frames that a + keyframes-only read never sends, and releases it when the next keyframe + arrives; in AVI, whose packets carry no PTS, the frame then takes that + keyframe's time, and every cell showed the keyframe a GOP before its own. + Keyframes-only and reference-only reads decode no B-frames, so low delay + changes no picture. The repeat repair keeps its own `noref`. - A window also copies the same input's video packets to a `framecrc` timing file in a private temporary directory, separate from stderr and removed when the attempt ends. Their presentation times and durations bound the final keyframe's coverage without decoding extra frames or reading the file again. Samples beyond that observed extent remain missing, so premature EOF cannot turn every trailing cell into a decoded preview. +- A packet without a PTS counts by its DTS only when the decoder runs in + low delay (MPEG-4 Part 2, above). A DTS never runs ahead of its PTS, so + the extent can only shrink, by the reorder delay. Without a PTS the decoder + stamps each frame with the DTS of the packet that released it, which is + the frame's own only when the decoder releases each keyframe as it + arrives. A reordering decoder does not: H.264 with B-frames in AVI + releases each keyframe two GOPs late in a keyframes-only read, and low + delay does not change that. Such windows fail with no usable packet timing + rather than publish every cell at an earlier keyframe. - A cell without a frame shows the previous cell's frame, and cells before the first frame show the first, so the grid never shifts. A run that had to fill more than 10 % of its cells (and more than one) fails as `empty`, which sends a hardware attempt on to software. -- A list run that fails as `empty` after the HEVC decoder logged "Duplicate - POC in a sequence" is read again, in the same attempt, as the - keyframes-only window that other containers use. One decoder serves every - list entry and nothing resets it between them, so an HEVC CRA keyframe - (open GOP) after a jump takes a picture order count derived from the - previous sample's. When that count matches a picture still in the - decoder's buffer, the decoder drops the keyframe ("Duplicate POC in a - sequence", then "Skipping invalid undecodable NALU: 21"), and such a file - can lose nearly every sample on every run. A window decodes keyframes in - order and keeps the counts consistent, at the cost of reading the whole - span. A list that is empty without that message, such as a truncated or - damaged file's, still fails without the extra read: damaged pictures log - only "Skipping invalid undecodable NALU", and a window would give their - samples the last good keyframe instead of failing. +- A list run that fails as `empty` after the decoder logged a dropped + keyframe is read again, in the same attempt, as a window. Two messages + mark a dropped keyframe. One decoder serves every list entry and nothing + resets it between them, so an HEVC CRA keyframe (open GOP) after a jump + takes a picture order count derived from the previous sample's. When that + count matches a picture still in the decoder's buffer, the decoder drops + the keyframe ("Duplicate POC in a sequence", then "Skipping invalid + undecodable NALU: 21"). An H.264 keyframe whose reference marking names + pictures a keyframes-only read skipped is discarded too ("illegal short + term buffer state"). Either way such a file can lose nearly every sample + on every run. An HEVC list goes to the keyframes-only window that other + containers use, which decodes keyframes in order and keeps the counts + consistent, at the cost of reading the whole span. An H.264 list that + logged its message goes straight to the reference-picture window below, + since a keyframes-only window discards the same keyframes. A list that is + empty without either message, such as a truncated or damaged file's, + still fails without the extra read: damaged pictures log only "Skipping + invalid undecodable NALU", and a window would give their samples the last + good keyframe instead of failing. - Skipped pictures do not advance the decoder's count, so keyframes a whole - count cycle apart (256 pictures with the common 8-bit count) collide in the - window too. A window that logs "Duplicate POC in a sequence" after a list - fallback fails as `empty` rather than give later samples the last keyframe - it kept; such files fail as they did without the fallback. If the window - fails for any reason, the attempt reports the window's failure, whose - reason and log describe the latest read, and its error names the list - run's. -- A sheet is JPEG-encoded in-process (`image/jpeg`, `Quality`) as soon as - frames land two sheets further on, so at most three sheets are held. - `Result.Sheets` holds them in order and `Result.SheetFrames` counts the - decoded and filled cells. + count cycle apart (256 pictures with the common 8-bit count) collide in a + keyframes-only window too, and H.264 windows discard keyframes as lists + do. A window that logged a dropped keyframe finds, for each sample that + holds a frame, the last keyframe packet at or before it in the timing + file; a sample whose own keyframe was not decoded shows an earlier one and + counts as filled. Such a window may fill up to 15 % of its cells instead + of 10 %, since the file is whole and a preview a keyframe early is still + usable; a list keeps 10 %, so too many drops still send it to the window. + A window that fills more, or that logged a drop and has a keyframe packet + without a time, which leaves the stale samples unknown, is read once more + with `-skip_frame:v noref` and `select=key` ahead of the sheets chain: + the decoder sees every reference picture, so its state stays whole, and + the chain still gets only the keyframes. That read costs several + keyframes-only reads and runs only for files that fail without it. If the + window fails for any reason, the attempt reports the window's failure, + whose reason and log describe the latest read, and after a list fallback + its error names the list run's. +- Sheets are JPEG-encoded in-process (`image/jpeg`, `Quality`) once the run + and its repairs are done, since a repair may replace any cell. A run holds + its tiles until then, so a request whose sheets exceed 64 million tile + pixels is invalid; callers chunk longer files. `Result.Sheets` holds them + in order and `Result.SheetFrames` counts the decoded, filled, and + repaired (`Refined`) cells. + +### Keyframe entries + +A sample-time entry makes the concat demuxer seek to the keyframe before the +sample and read every packet of every stream, video and all audio tracks, +from there up to the sample; the decoder skips them. With keyframes several +seconds apart that is most of the file: 32 GB of a 43 GB 4K HEVC remux for +645 cells. A Sheets list over a Matroska input therefore starts each entry +at the sample's keyframe and reads only it and the entry's span +(`keyframes.go`): + +- `Runner.Run` reads the keyframes once, before any attempt and its timeout, + from the file's Cues with `keyframes.ReadMatroska`: the index the + demuxer's seek uses, plus its spot check of a few clusters' block headers, + in-process and without decoding. The read runs in a goroutine, and the run + stops waiting after 10 seconds or when its context ends, which also stops + the read at its next call; a read blocked on stalled storage is left + behind. At most 4 reads are in flight in a process, blocked ones + included, which also bounds the memory parsing Cues takes (a few times + their size), and a run that finds none free skips the read. A file that is not + Matroska, has no Cues, whose Cues leave out keyframes, or whose read is + skipped or stopped gets sample-time entries. +- An entry starts at the last keyframe at or before the sample's inpoint + (sample time plus container start, in the microseconds the list is + written in), rounded up to the microsecond so the seek never lands on the + keyframe before it. On ffmpeg-muxed files with B-frames and with container + starts of 3.21 and 11.4 s, this matched where ffprobe's + `-read_intervals` seek, which makes the same seek call, landed for every + sample. Samples before the first keyframe keep sample-time entries. +- A sample whose keyframe is the previous sample's gets no entry: it would + only decode the frame the previous cell shows, and the HEVC decoder drops + a keyframe sent twice in a row as a duplicate picture order count. Once + the list is read, such a sample shows the previous cell's frame and its + source time, so the repair below finds it repeating that cell and decodes + its own moment. A distinct keyframe keeps its entry even when it lies + inside the previous entry's span. +- Every frame of a keyframe entry lies at or after its inpoint, so its time + places it back in the input exactly. A sample-time entry decodes the + keyframe from before its inpoint, where ffmpeg rewrites the time to keep + the list's timestamps monotonic; with samples sharing a 40-second GOP, + that hid a repeat from the repair and left a cell showing the scene before. +- An open-GOP keyframe after a jump takes a picture order count derived + from the last picture whose header the decoder parsed, skipped pictures + included. Keyframe entries leave longer gaps between parsed pictures, and + across a gap of more than half the count's cycle (128 frames with x265's + default 8-bit count) the derived counts run backwards: the decoder passes + on keyframes out of list order with rewritten times, or drops them as + duplicates. As soon as a frame comes from an earlier entry than the frame + before it, or the decoder logs a dropped keyframe ("Duplicate POC in a + sequence", or H.264's "illegal short term buffer state"), the attempt + stops ffmpeg and reads the samples from their own times, exactly as a + file without Cues. An abandoned read is never published: if the + attempt's time ran out meanwhile, it fails as `timeout`. A drop in the + abandoned read does not count toward the window fallbacks above. + Closed-GOP keyframes start their counts afresh and keep their keyframe + entries. +- MP4 and every other container keep sample-time entries: an MP4 entry that + starts exactly at its keyframe's time often seeks past it (45 of 80 + samples of one H.264 episode decoded nothing). Stats lists and repairs + keep sample-time entries too. + +Over Matroska test encodes (x264 and x265 at default settings and with +scene cuts off at 5, 10, 20, and 40-second GOPs, open- and closed-GOP HEVC, +samples every 5 and 10 seconds) keyframe entries never placed more wrong +cells than sample-time entries, and fewer where samples share long GOPs. +Open-GOP HEVC with an 8-bit count falls back once its listed keyframes lie +more than about 5 seconds apart, which covers x265's default 10-second GOP at +either interval; it then reads what it did before, plus the few entries read +before the fallback. + +### Repairs + +A list gives each sample the keyframe at or before its time, placed back in +the input from its frame time: `pts + start - k*span + inpoint - container +start` for list entry `k`, where `start` is the list input's start time from +its header, by which ffmpeg shifted every frame, and `inpoint` is where the +entry starts in the container. Two kinds of cell come out worse than the +input allows, and the attempt reads their samples again in a second list, +after the first run and before the sheets are encoded (`sheet_repair.go`): + +- Missing cells decoded no frame. The demuxer could not seek to a keyframe + before the sample, as in a container that flags no keyframes (VC-1 in + Matroska, whose I-frames only the decoder recognizes, lost a third of its + samples to half-second entries), or the HEVC decoder dropped the keyframe + as a duplicate picture order count. The repair lists them again, + keyframes only (with the codec's options above), each entry lasting until the next sample (the shortest gap + between the run's samples): the decoder starts afresh and finds the + keyframe before the sample, or failing that the first one after it. +- Repeated cells show the same moment as the cell before: their frames lie + within a tenth of the time between the two samples, because no keyframe + lies between them (a GOP longer than the interval, including every sample + a keyframe list left out), or because an open-GOP keyframe brought a + leading picture just before it that one sample kept. + The repair lists them again with `-skip_frame:v noref`, decoding every + reference frame from the keyframe on, and keeps the last frame at or before + each sample's time. + +Each repaired sample keeps its nearest frame: the last at or before its +time, else the first after it. A repair that ffmpeg finishes must line up +with its log like any run; one cut short keeps the frames it placed, which +were written and logged in order, and leaves the rest. Both repairs share one +time box, 30 seconds plus a quarter second a sample of the run, within the +attempt's timeout: on a 4K HEVC clip with a keyframe every 25 seconds and +samples every 10, software repaired 12 of 31 cells in 26 seconds, CUDA in 4. +Missing cells are repaired first, then the repeated cells, recomputed so a +recovered cell that shows its neighbor's keyframe is decoded at its own time +too. A list that decoded nothing is repaired as well, since a container +without keyframe flags can miss every short entry. A repair never fails the +run, except that the caller canceling ends it as `canceled`; the sparse check then counts what the run and its repairs +decoded, so a list whose repairs recover its missing samples no longer needs +the window fallback above. ## Argument stability diff --git a/docs/architecture/network-access.md b/docs/architecture/network-access.md index d87708fef5..9d8b7eabd8 100644 --- a/docs/architecture/network-access.md +++ b/docs/architecture/network-access.md @@ -182,11 +182,12 @@ to them. (`Service.FollowLifecycleChanges`), so a missed publish costs at most one minute. A caller that cancels an accepted restart stops waiting while the supervisor continues the restart and records the result. -- The API reaches proxies over their backend URL with the node bearer: +- The API reaches proxies over their backend URL with + [signed worker requests](worker-http-protocol.md#authentication): `GET /network-access/status`, `POST /network-access/{provider}/connect` and `.../disconnect` on the proxy listener, ten seconds each, in parallel, like force-reload. Those routes carry `auth_url` and error text because - they take the bearer; the public `/health` report carries only state, + they require a signed request; the public `/health` report carries only state, origin and hostname. The proxy listener also runs `netaccess.Middleware`, so its own provider's stamped requests are validated there too. - The proxy's `/health` `network_access` block is what the API stores on the diff --git a/docs/architecture/notifications.md b/docs/architecture/notifications.md index 7ca102fb4a..681b6e2c54 100644 --- a/docs/architecture/notifications.md +++ b/docs/architecture/notifications.md @@ -24,6 +24,16 @@ before it became available is therefore recorded without a release event. The ad file in the library for flat items. The window leaves room for new episodes whose metadata lands a few days after the file. +The detector also stores that decision on the availability row: an episode's +`episode_availability.released_at` is set when it was recorded as a release +(seeded library, inside the window), even while release events are turned off, +and stays empty for back catalog and late matches. A profile that turns on +`ui.next_up_new_releases_first` (off by default) gets Next Up ranked by the +later of when it finished a series' previous episode and the `released_at` of +the next episode (earliest availability row in the libraries the viewer can +reach), so a caught-up series moves to the front when a new episode or season +arrives. Changing what counts as a release changes that order too. + Event kinds: - `episode` — carries series/episode identity and fans out to interested @@ -89,6 +99,27 @@ release event's episode key: for the daily interest rebuild. A release fanned out in that window is judged on the stale interest. Evaluating Home removals at fanout time would close the gap. +- A series the profile muted is a hard gate too. `ListActiveBySeries`, the + query that loads a release's candidate recipients, anti-joins + `profile_series_notification_mutes`, so a muting profile is never a + candidate: no delivery row is created and no channel sees the event. The + check costs one index probe per candidate inside that query, and it runs + again when fanout re-reads the candidates under the inbox locks. A mute + committed after that second read is like a preference change made just + after the delivery. Muting writes no interest row: the mute lives in its own + table keyed by account, profile, and series, because interest rows are + recomputed per library and pruned once their reasons go, and a mute lasts + until the profile unmutes. The account is part of the key because profile + IDs, such as the legacy `default`, repeat across accounts. A muted + profile's notification cursor does not move. The mute is read when fanout + evaluates a release, not when the episode arrives: unmuting does not + announce episodes fanout already evaluated during the mute, since their + release events are processed, but an event still inside the settle delay at + unmute is fanned out to the profile. No suppression state is kept per + release; the window is the settle delay plus one claim pass, and it applies + symmetrically to a mute made just after a release. A mute follows its series through a + catalog merge or split like other per-profile series state, and profile + deletion removes it. - Profile-level notification preferences are a hard gate: a reason disabled in preferences can never match, so no delivery row is created and no channel — including webhooks — ever sees the event. Per-webhook reason @@ -110,11 +141,31 @@ release event's episode key: when access changes later. Multiple matching reasons produce one delivery with merged reason flags. -Deliveries are deduplicated per `(profile, release event)` and, for -`episode.available`, per `(profile, episode)` across libraries, so +Deliveries are deduplicated per `(account, profile, release event)` and, for +`episode.available`, per `(account, profile, episode)` across libraries, so dual-quality library setups notify at most once. Reprocessing an event is idempotent. +A profile ID is unique only within its account: every account from before +profiles has a profile named `default`. Interest rows, preferences, mutes, +the delivery dedupe keys, and every inbox read, count, and mark-read are +therefore keyed by account and profile, and profile deletion removes those +for the deleted profile's account only. The email channel reads a profile's +deliveries on the account that owns its email settings. Still keyed by +profile ID alone: the inbox clock (`notification_inbox_clocks`), which only +orders inserts, so two accounts' same-ID profiles share it and serialize +their inserts without changing either inbox's order; and the email settings, +push devices, web push subscriptions, and webhooks. + +The notified-series list (`GET /api/v2/notifications/series`) shows a profile +the same index fanout evaluates, plus the series it muted. `ActiveReasons` is +the event-independent half of these rules, shared by fanout and the list, so +the two cannot disagree about which reasons a preference silences; a muted +series keeps its active reasons on the list but does not notify. The list filters series through the +catalog's card access check rather than fanout's per-library one, and it leaves +out the per-episode checks (Next Up's expected episode and the +already-notified cursor), which depend on the episode that arrives. + ## Release-event retention Release events and inbox deliveries have independent lifetimes. Pruning an old diff --git a/docs/architecture/observability.md b/docs/architecture/observability.md index b35c3a7418..076687e22c 100644 --- a/docs/architecture/observability.md +++ b/docs/architecture/observability.md @@ -360,7 +360,8 @@ application listener; the API process serves it only on `PRAIRIE_METRICS_LISTEN` `mount="library-1"`), never by path, so an unauthenticated scrape cannot enumerate media locations. A node's unauthenticated `/api/v1/health` withholds paths on the same terms. Paths appear only in the admin resources response and a - node's bearer-authenticated `/status`. + node's `/status`, which needs a + [signed request](worker-http-protocol.md#authentication). - **Probing is bounded.** Each process's sampler checks at most eight mounts: the transcode scratch directory first, then library roots in order. The process logs how many go unsampled. The cap is per process, not per host, so @@ -510,6 +511,63 @@ that ended. Their difference includes queued, exporting and dropped spans, so it is not an instantaneous drop count. Inspect it after a drain and monitor collector/backend self-metrics. There is no OTel MeterProvider. +## Query attribution + +Attribution is opt-in during the initial soak. `PRAIRIE_QUERY_ATTRIBUTION_ENABLED` defaults to `false`, read once at startup. +The `prairie starting` line records `query_attribution` (`on`/`off`) and a `query_run` UUID. +Off adds no `db_*` request fields, events, summaries, metrics or span attributes. + +SQL enters only the shape function inside `queryattr`; the normalized text is hashed and discarded; no log, span, or metric carries SQL text, table or column names, argument values, or error text. + +A site is the first Prairie function and line outside `internal/database` and its subpackages; `main` qualifies. +Names omit module and `internal/` prefixes; no eligible frame in 32 frames gives `unknown`. +Shapes are 16 lowercase hex digits from FNV-64a of `go-sqllexer` normalized text. Each site holds eight shapes, +then a ninth `other` row for further shapes or normalization errors. Fixed `batch` and `copy` shapes count toward eight. + +The `api request`, `apiv2 request` and `jellycompat request` lines add, in order, `db_queries`, `db_query_ns`, +`db_acquire_wait_ns`, `db_slowest_site`, `db_slowest_shape`, `db_slowest_ns`, `db_top_site`, `db_top_shape`, `db_top_count`. +Times are nanoseconds; summed operation time is not wall time. One traced query (`Query`, `QueryRow`, `Exec`), batch +or copy counts as one operation, regardless of statement count. `Query` ends at `Rows.Close`, including row processing; +a no-row `QueryRow` is a success. Acquires add only wait time. Rankings use (site, shape) pairs; ties choose the smaller pair. +A request with no operation and no acquire wait adds none of these fields; an acquire-only request gives four `none` +strings and zero operation numbers. The first `LogAttrs` closes and caches the snapshot; +later completions count only in site totals. `queryattr.Detach` hides the request, preserving cancellation, deadlines +and other values. It applies at `workmetrics.Start`, sections trending refresh, both asynchronous subtitle-sync launches, +and metadata auto-translation. Shared cache and singleflight fills keep the waiting request's attribution. +A request line can include background work that overlaps it before the line is written. + +The Info event `slow or large database query` holds `site`, `shape`, `ns`, `statements`, `args`, `max_array` (`-1` if +unavailable), allowlisted `outcome`, and `request` (whether it entered an open snapshot). `cause` is `slow`, `large` +or `slow,large`. The node-wide limiter drops excess events; the next allowed event reports their count in `suppressed`. +Log-drain inserts use `queryattr.Quiet`: they count in totals and metrics, emit no events and add no suppression count. + +Every enabled mode emits the Info `database query summary` each interval and once on shutdown, before the operational +log drains and the pool closes. It holds window deltas (`queries`, `query_ns`, `errors`, `events_suppressed`), `interval_ns`, +and up to ten (site, shape) rows each in `top_time` and `top_count`; rows hold `site`, `shape`, `calls`, `ns`, `errors`. +`run` maps to a node only through the private startup line's `query_run`; `build` is the current build display. +Enabled full nodes register `prairie_postgres_queries_total` and `prairie_postgres_query_seconds_total`, labeled only by `site` +(at most two series per site); proxy and transcode nodes do not. Recording query, batch and copy dependency spans add +`prairie.db.site` and `prairie.db.shape`. + +Memory is bounded by compiled pgx call lines (about 2,847 static sites), nine rows and two metric children per site, +a 4,096-entry cache of site pointers and SQL hashes, and one PC-cache entry per walked PC in the binary. +A request map holds one entry per touched row until the snapshot. Nothing retains SQL, arguments or errors. +All defaults and caps are provisional: + +| Setting | Default | +|---|---| +| Slow operation | > 100 ms | +| Arguments or array elements | > 1,000 | +| Batch statements | > 100 | +| Event limiter | 1 per second, burst 10 | +| Summary interval and each top list | 15 minutes; 10 rows | +| Shapes per site | 8 plus `other` | +| Shape cache and stack buffer | 4,096 entries; 32 frames | + +Window cuts are approximate: concurrent completions can land in either window. Late work is absent from a closed +request line; work begun before `Detach` keeps its request. Batch statements share one envelope time and outcome. +Empty batches, copy setup failures and pgx `Exec` early returns after a failed deallocate are uncounted. + ## Client experience and plugin coordination Server response bytes and first playable segments do not establish first frame diff --git a/docs/architecture/outbound-address-guard.md b/docs/architecture/outbound-address-guard.md index 6504553a60..7ce496a11a 100644 --- a/docs/architecture/outbound-address-guard.md +++ b/docs/architecture/outbound-address-guard.md @@ -1,8 +1,8 @@ # Outbound address guard Some features make Prairie send HTTP requests to an address a user typed or a -third party supplied: history import, webhook sync, notification webhooks, and -collection posters set from a URL. +third party supplied: history import, webhook sync, notification webhooks, +collection posters set from a URL, and artwork URLs from metadata providers. The server sits on a network the user may not: a household LAN, or a cluster with its database, cache, transcode nodes, and a cloud metadata endpoint. An unchecked address lets anyone who can sign in probe that network from the @@ -95,8 +95,10 @@ it. See [Notifications](notifications.md#trust-model-and-ssrf-guard). ## Artwork downloads Artwork Prairie downloads on its own (metadata refresh, image-cache jobs, person -images) comes from Public addresses only (`imagecache`). An image an admin -applies through `POST /api/v2/admin/items/{id}/images/apply` (and its v1 +images) comes from Public addresses only (`imagecache`). Provider artwork URLs +are untrusted, so these downloads use the netguard transport too, checked on +every dial and redirect; the cache keeps no address list of its own. An image +an admin applies through `POST /api/v2/admin/items/{id}/images/apply` (and its v1 counterpart) is trusted with the local network, like the import sources an admin configures. That covers a URL the admin typed and one picked from the provider list, since the server cannot tell them apart. The handler marks the @@ -105,6 +107,30 @@ transport, which still refuses Blocked addresses on every dial and redirect. Only acting admins reach that route, so this does not widen what any other account can make the server request. +## Serving catalog artwork + +Catalog artwork is not always in Prairie's own storage. A provider URL stays on +the item until its image-cache job succeeds, and stays for good when caching +fails, so an address the cache refused can still be served. Three places fetch +catalog artwork themselves instead of handing the URL to the client: the +jellycompat image proxy (Infuse, and requests with a proxy tag), offline +download artwork, and collection collages. Each uses a netguard transport, and +`artworkurl.FetchContext` decides the trust per URL: + +- A direct URL to the artwork store is trusted with the local network, where + S3-compatible storage often runs. It must match the scheme, host, and path + prefix (bucket and key prefix, or the read endpoint's path) of the URLs the + store's `DirectURL` issues, with no dot segments in its path. +- Any other URL is Public only. A refused proxy request answers 502 and a + refused download asset reports the asset not found, so clients don't retry + it; neither returns anything from the refused address. + +Local artwork storage serves signed `/api/` routes on this server, which the +proxy redirects to and downloads read from the store, so neither goes over +HTTP. A provider URL under the artwork store's own prefix is trusted like a +store URL. Trust carries across redirects, as for every netguard request, so +the store is relied on not to redirect into the local network. + ## Collection artwork Collection artwork downloads use the netguard transport with no fallback @@ -121,9 +147,8 @@ client (`newCollectionImageClient` in `internal/api/handlers`): operations) are trusted with the local network, like an image an admin applies to an item. Only acting admins reach those routes. - **Collages.** A collage fetches its titles' posters as the server resolved - them, usually from its own artwork storage, which may be on the local - network. No request supplies those URLs, so the fetch is trusted with the - local network. + them. Those are artwork store URLs or uncached provider URLs, trusted as + described in [Serving catalog artwork](#serving-catalog-artwork). Blocked addresses stay refused in all three cases, on every dial and redirect. @@ -135,3 +160,9 @@ answer, as it could from its own machine. In a container with bridge networking the server cannot see the host's public address, so a request to it is not recognized as the server's own. Run the container behind a firewall that also applies to traffic from the container if that matters. + +A resolver that answers public names with addresses netguard classes as +Private makes those hosts unreachable for untrusted requests, provider artwork +downloads included. Two setups do this: proxy routers in fake-IP DNS mode, +which answer from `198.18.0.0/15`, and DNS64 on an IPv6-only network, which +answers IPv4-only hosts with `64:ff9b::/96` addresses. diff --git a/docs/architecture/playback-protocol-v3.md b/docs/architecture/playback-protocol-v3.md index f00c3b8042..1f1883f7c8 100644 --- a/docs/architecture/playback-protocol-v3.md +++ b/docs/architecture/playback-protocol-v3.md @@ -98,7 +98,8 @@ the document is always the full one: { "enabled": true, "protocol_versions": [3], - "features": ["playback_plan_v3", "neutral_playback_v3_contract_v1", "embedded_subtitles_v1", "layout_aware_passthrough", "playback_route_diagnostics", + "features": ["playback_plan_v3", "server_remote_stream_bitrate_policy_v1", "server_local_stream_bitrate_policy_v1", + "neutral_playback_v3_contract_v1", "embedded_subtitles_v1", "layout_aware_passthrough", "playback_route_diagnostics", "device_quirks_v1", "seek_reanchor_v1", "output_change_v1", "output_display_evidence_v1", "direct_stream_resume_v1", "header_authenticated_media_v1", "authorized_media_origins_v1", "software_video_decode_v1", "plan_invalidated_v1", "plan_source_duration_v1"], @@ -107,7 +108,7 @@ the document is always the full one: } ``` -The fifteen feature strings above are the full set this server version advertises on `/api/v1`. `/api/v2` advertises them plus `subrip_sidecar_v1`: +The seventeen feature strings above are the full set this server version advertises on `/api/v1`. `/api/v2` advertises them plus `subrip_sidecar_v1`, `marker_thumbnails_v1`, and `hls_subtitle_renditions_v1`: | Feature | What it promises | | --- | --- | @@ -127,6 +128,7 @@ The fifteen feature strings above are the full set this server version advertise | `plan_invalidated_v1` | The client can be told mid-session that the plan it is playing was withdrawn, over the realtime `plan_invalidated` command, and replans off it. A session that did not negotiate it is stopped instead (§6.1) | | `plan_source_duration_v1` | `source.duration_seconds` is populated when known, so its absence means *unknown* rather than *unsupported* (§5) | | `subrip_sidecar_v1` | `/api/v2` only. An opted-in client that parses SubRip itself receives external and downloaded SRT tracks as the original `.srt` file instead of the WebVTT conversion (§8) | +| `hls_subtitle_renditions_v1` | `/api/v2` only. An opted-in client receives a `server_transcode_hls` plan's embedded text subtitle as a WebVTT `SUBTITLES` rendition of the plan's HLS stream (`subtitle.hls_rendition`) instead of a sidecar artifact (§8) | `plan_source_duration_v1` 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 @@ -637,7 +639,10 @@ path does not inherit evidence from a different playback engine. `original_http` never receives normalized-remux evidence. An HDR10 claim can carry `hdr10_max_width`, `hdr10_max_height`, `hdr10_max_frame_rate`, and `hdr10_max_bitrate_kbps`; these ceilings keep a successful format probe from -admitting an untested stream class. +admitting an untested stream class. When the viewer turns on the web player's +browser decode engine, `original_http` instead carries +`client_managed_dynamic_range_v1` (when its WebGPU presenter works) and the engine tone-maps the +original itself; see [web-decode-engine.md](web-decode-engine.md). --- @@ -661,6 +666,18 @@ the server to remux the file. Without the claim, the historical default-track gate remains. A claiming client that cannot honor the identity reports a typed playback failure so the bounded replan ladder can choose a packaged route. +A `server_remux_progressive` stream is forward-only: it has no byte ranges, and +a request for it always starts at the plan's position. The server closes it +after it makes no write progress for the stream stall window (see +[streaming write deadlines](streaming-write-deadline.md)), which a paused +client causes once its buffer is full. A client cannot resume such a stream +where it stopped and has to open a new one at that position. The web player +does this with a `seek_reanchor` replan (§6) when playback resumes after a +minute idle, and falls back to its reconnect (§6.2) when the replan is refused. +It does not do this in a watch-together room, whose transport the room +controls; a room member who pauses past the stall window is not recovered this +way. + `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 @@ -824,24 +841,31 @@ out of band, after which a client can retry or reload that URL with the new header. The signed playback token is what carries a reconstruction recipe across an -API restart. Opting it out therefore also opts out of transparent session -reconstruction: a missing in-memory session returns the normal expired/missing -response and the client starts a fresh attempt. Once selected, this mode is -sticky for the lifetime of the attempt; a client that can no longer honor it -must stop and start a new attempt rather than downgrade a replan to a -credential-bearing URL. - -**Replica affinity.** Because there is no reconstruction recipe, a -header-authenticated attempt's session exists only in the memory of the API -process that started it. A media request routed to any other replica finds no -session and returns the expired/missing response, so a deployment serving -tokenless attempts currently needs either a single API replica or session -affinity on the media routes; legacy token-bearing attempts are unaffected, -since they reconstruct anywhere. Proxy-origin URLs -(`authorized_media_origins_v1`) are also unaffected: the proxy serves from the -shared grant store rather than from an API process's memory. Moving session -state into shared storage is the fix, and until it lands this constraint is -part of the deployment contract. +API restart, so an attempt without one keeps its recipe server-side instead. +When a transport served from the API origin commits, the server records its +recipe in Redis. A media or sidecar request that finds no in-memory session is +rebuilt from that record, but only for an authenticated caller who owns the +attempt (account and selected profile), and only while the durable attempt +still names the recorded plan and has neither expired nor been stopped. The +rebuilt session keeps requiring the client's own credential on every request. +Without Redis, or once the record has lapsed with the 24 h token lifetime, a +missing in-memory session returns the normal expired/missing response and the +client starts a fresh attempt. Once selected, this mode is sticky for the +lifetime of the attempt; a client that can no longer honor it must stop and +start a new attempt rather than downgrade a replan to a credential-bearing URL. + +**Replica affinity.** The recorded recipe lets a restarted API process rebuild a +header-authenticated session, and another replica can rebuild it the same way. +A deployment with several API replicas should still keep session affinity on +the media routes for these attempts: a replica that holds an idle copy of the +session ends its transcode when that copy expires, and nothing in a tokenless +request can start it again on the node. Without Redis the session exists only in +the memory of the API process that started it, so it needs a single API replica +or session affinity. A transcode the API executes itself (no transcode node) is +only safe with one API replica or session affinity in any case: each replica +that rebuilds it starts its own FFmpeg. Proxy-origin URLs +(`authorized_media_origins_v1`) are unaffected: the proxy serves from the shared +grant store rather than from an API process's memory. ### 4.2 Media and subtitle URL query parameters @@ -852,8 +876,8 @@ parameter, and never carries a parameter across families. | Route family | Routes | Query parameters | | --- | --- | --- | -| Media | `/stream/{session_id}`, `/playback/transcode/{session_id}/master.m3u8` and its segments | `seek` — the progressive-remux start offset in seconds, present only when it is non-zero; `hls_vars=1` on the HLS manifest of an attempt that advertised `hls_variable_substitution_v1` (below) | -| Media on a designated origin | `{proxy}/stream/v3/{session_id}`, `{proxy}/stream/v3/{session_id}/master.m3u8` and its `segment/{name}` children (§4.1) | `seek` and `hls_vars`, with the same meanings; these routes never accept a credential parameter of any kind | +| Media | `/stream/{session_id}`, `/playback/transcode/{session_id}/master.m3u8` and its segments | `seek` — the progressive-remux start offset in seconds, present only when it is non-zero; `hls_vars=1` on the HLS manifest of an attempt that advertised `hls_variable_substitution_v1` (below); `prairie_rendition=video` or `prairie_rendition=subtitles` on the `master.m3u8` URIs that a subtitle rendition's multivariant playlist emits (§8) | +| Media on a designated origin | `{proxy}/stream/v3/{session_id}`, `{proxy}/stream/v3/{session_id}/master.m3u8` and its `segment/{name}` children (§4.1) | `seek` and `hls_vars`, with the same meanings, plus `prairie_rendition` on the multivariant playlist's own `master.m3u8` URIs as above; these routes never accept a credential parameter of any kind | | Subtitle artifact | `/stream/{session_id}/subtitles/{combined_index}{.ext}`, `/stream/{session_id}/subtitles/{combined_index}/fonts` | `file_id`, always; one identity pin: `embedded_stream_index`, `external_subtitle_key`, or `downloaded_subtitle_id` (§8); `original=1` on an original-SRT `.srt` URL (§8). VTT receivers may explicitly request `timestamp_offset` in seconds | A media route never carries `file_id` or `downloaded_subtitle_id` — the session @@ -907,6 +931,19 @@ Three shapes exist: position, `can_seek_anywhere` is true when the runtime is known, and `seek_restoration` is `player_position` — the client seeks locally. +Source positions count from the start of the file: the container's first +timestamp is position 0. Most files start at timestamp zero, but MPEG-TS +recordings and files remuxed from them can start seconds (or more) in. Plan positions, playlist time, +subtitle tracks, and subtitle windows (`?position=&duration=`) are all on this +container-relative timeline. Encoded HLS segments can still carry the source's +own timestamps, so place them by playlist time, not by their PTS. +A direct-play engine may keep the container's own timestamps instead: +Chrome does for Matroska, reporting `currentTime` equal to the container start +time at its first frame. Such a client must subtract that origin as part of its +timeline offset, or subtitles, seeks, and reported positions drift by it. The +web player reads the origin from `currentTime` at `loadedmetadata`, before it +seeks to `player_start`. + **Copy remux over HLS** is served from FFmpeg's live, still-growing playlist, which starts at the preceding keyframe selected by FFmpeg's input seek. For HEVC HDR copy packaging, the frozen plan also controls the sample entry: a @@ -988,7 +1025,7 @@ 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 +not silently reset 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. @@ -1007,11 +1044,14 @@ fallback to another media version must remap the selected subtitle. It prefers a version with an equivalent track; when none has one, playback continues with subtitles off and the plan carries `subtitle_track_unavailable` rather than ending in a terminal. A selection the effective file cannot honor -on a direct start degrades the same way. A replan that would have to drop the +on a direct start degrades the same way. When the file being planned was +refused with `subtitle_conversion_unsupported` and the only other versions that +play drop the subtitle, the server plays that file with subtitles off instead +of moving to one of them, provided that plan can run; when no other version +plays, the terminal stands. A replan that would have to drop the subtitle to return to the requested version stays on the version already -playing, unless quality `original` pins the requested version; the requested -version remains a fallback without the subtitle if no version that keeps it can -play. Malformed +playing; the requested version remains a fallback without the subtitle, ahead of any other version +without it, if no version that keeps it can play. Malformed selections (a track identity that does not parse or names another file, or a negative index) are still rejected. @@ -1026,8 +1066,8 @@ seek is not an authority boundary for replacing the client's declared abilities mid-session. **Attempt-sticky features.** `client_features` is otherwise refreshed by any -replan that sends it, but four entries are fixed by the start negotiation and a -replan can neither add nor drop them: +replan that sends it, but the entries below are fixed by the start negotiation +and a replan can neither add nor drop them: | Feature | Why it is fixed | | --- | --- | @@ -1035,6 +1075,7 @@ replan can neither add nor drop them: | `authorized_media_origins_v1` | It selects which origins may serve the attempt's media. A plan that already handed out a proxy origin outlives the replan that would revoke it, so the client would be left holding a URL it no longer trusts (§4.1) | | `software_video_decode_v1` | It widens the direct-play evidence tiers. Dropping it converts a direct route into a transcode and persists that downgrade into the durable request | | `subrip_sidecar_v1` | It picks the representation of every SRT sidecar URL. A mid-attempt switch would publish one track under two URLs (§8) | +| `hls_subtitle_renditions_v1` | It decides whether a transcode's stream URL is a multivariant playlist carrying the subtitle or a media playlist beside a sidecar. A reused HLS transport keeps the shape it was built with (§8) | The server silently restores the negotiated state of each, whatever the replan sends — including an explicit list that omits one, which is otherwise a valid @@ -1212,6 +1253,20 @@ route, so a direct-play viewer would come back on a transcode. The web player the connection was lost and offers to try again. Leaving the player or starting other playback cancels the cycle. +Keeping the current route eligible does not mean keeping a dead node. Every +replan except `seek_reanchor` first asks the session's transcode node and egress +proxy whether they answer, with a two-second bound. A node that does not answer +is left out of that replan's plan and route: its capabilities don't count, its +running transport is not reused even by a `track_change` that changes nothing, +and the replacement starts elsewhere. A `failure_recovery` or +`seek_failure_recovery` from such a node doesn't exclude the failed plan's key +either: the node failed, not the recipe, so the same recipe can run on another +node. If the dead node was the only proxy a progressive remux could use, the +remux has no route left and is planned as HLS on a transcode node instead. The +pool's health flag cannot be trusted for this: it changes only at the next health +sweep, up to 30 seconds after the node went away, and until then the node still +looks healthy. + A network failure before a transport's own first frame stays on the ordinary failure path, even when an earlier transport of the same viewing played: the new route may be one this client cannot reach. If that recovery then cannot @@ -1230,9 +1285,10 @@ must not branch on an unrecognized value. `hls_audio_adaptation`, `hls_packaging_required`, `subtitle_burn_in_required`, `client_dv7_to_dv81`, `client_dv7_to_hdr10`, `client_managed_dynamic_range`, `client_dv8_base_layer`, `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`. +and the quality reasons `quality_auto_source`, `quality_fixed_rung`, +`quality_device_limit`, `quality_bandwidth_limit` (a measured estimate could +not carry the source), `quality_bandwidth_cap`, and `quality_original` (an +automatic reduction could not run, so the source size was kept). ### 7.2 Degradation warnings @@ -1253,7 +1309,7 @@ The plan will play, but something the user might notice was given up. | `bandwidth_cap_applied` | `bandwidth_cap_kbps` limited the selection | | `evidence_insufficient_for_direct` | Evidence tier blocked a direct route | | `audio_track_unavailable` | The selected audio track is not on the effective file; its default track plays | -| `subtitle_track_unavailable` | The selected subtitle has no equivalent on the effective file; playback starts with subtitles off | +| `subtitle_track_unavailable` | The selected subtitle has no equivalent on the effective file, or is on it but cannot be shown there; playback continues with subtitles off. `message` says which | ### 7.3 Terminal reasons @@ -1385,7 +1441,7 @@ transcodes it to a client-renderable format first — always to WebVTT, served a `text/vtt` at a `.vtt` URL), or `burn_in` (rendered into the video, which forces a transcode). -`subtitle.artifact` describes the selected sidecar. A `render` decision carries either `artifact` or `embedded`, never both; `convert` carries an artifact. Under `off` and `burn_in` both are absent, and every plan states this afresh: an artifact is never carried over +`subtitle.artifact` describes the selected sidecar. A `render` decision carries exactly one of `artifact`, `embedded`, or `hls_rendition`; `convert` carries an artifact or an `hls_rendition`. Under `off` and `burn_in` all three are absent, and every plan states this afresh: an artifact is never carried over from an earlier plan of the same session, so a client must take the current plan's `subtitle` block literally rather than remembering the previous one. `off` also carries no `subtitle.track_id`. The inventory `url`s are unaffected @@ -1408,7 +1464,7 @@ Native embedded selection requires `embedded_subtitles_v1` in `client_features` The scanner records `container_track_id` as the MP4 track ID (`tkhd`) that FFprobe reports and, for Matroska, as the TrackEntry's `TrackNumber`, which FFprobe does not report. The scanner reads the Matroska `Tracks` element itself and maps each entry to its FFmpeg stream the way FFmpeg's demuxer creates streams. It records a number only when the per-type stream counts, the subtitle positions, and the subtitle codecs all agree with the probe. A track number is often not stream index + 1, because remuxes keep the original numbers. Tracks with the legacy codec IDs `S_ASS` and `S_SSA` get no ID: FFmpeg reports them as `ass`, but Media3 drops them, so a client could not resolve the number and its capability cannot exclude them. Files probed before Matroska numbers were recorded get them from the `backfill_matroska_track_numbers` task, which reads only the `Tracks` element and writes only when the file and the row still match the stored probe. It reads only files with a SubRip or ASS track lacking an ID, the codecs a client currently plays by track number, and records each file it reads in `matroska_track_backfill_checks` against the file's size, mtime and stored tracks. A recorded file is read again only after one of those changes or the matching rules do; a file storage could not open, or that differs on disk from its row until a scan rewrites it, is retried on the next start. A file skipped for its codecs, such as a PGS-only one, gets an ID from its next reprobe, or from the backfill once its codec is added to that list. Container and codec support must match; authored ASS preservation additionally requires styling and font support. The old `embedded_text` flag alone never authorizes native selection. Text sidecar rendering depends on `sidecar_text`, regardless of the source being embedded or external. -The native decision is `subtitle: {"mode":"render", "track_id":"file:42:subtitle:0", "embedded":{"stream_index":3,"container_track_id":"4"}, "inventory":[...]}`. The client selects that exact stream from the original media and does not mount the inventory's fallback URL. Native selection applies only to `original_http`; remux and transcode plans use sidecars or burn-in. Inventory `delivery` continues to describe the available server representation, so even a `burn_in_only` entry can be selected natively when the client attests the exact bitmap codec. +The native decision is `subtitle: {"mode":"render", "track_id":"file:42:subtitle:0", "embedded":{"stream_index":3,"container_track_id":"4"}, "inventory":[...]}`. The client selects that exact stream from the original media and does not mount the inventory's fallback URL. Native selection applies only to `original_http`; remux and transcode plans use sidecars, burn-in, or, for a transcode, an HLS subtitle rendition (below). Inventory `delivery` continues to describe the available server representation, so even a `burn_in_only` entry can be selected natively when the client attests the exact bitmap codec. A confirmed native selection failure triggers `failure_recovery` with `failure.classification: "subtitle_embedded_failed"`. The server disables native selection for the rest of that playback attempt, including later capability refreshes, and replans with an executable fallback. The native identity participates in both plan identifiers, so the same video route with extracted subtitles is a distinct attempt. Seek reanchors preserve the native identity and reject source drift. @@ -1420,6 +1476,78 @@ The artifact `format` and `mime_type` describe served bytes, independently of th A client that sends `subrip_sidecar_v1` in `client_features` to `/api/v2` receives external and downloaded SRT tracks as the original file instead: the inventory URL is `.srt` with `original=1`, a `render` artifact uses that URL and reports `format: "srt"` and `mime_type: "application/x-subrip"`, and the bytes are the SRT exactly as stored. A `convert` artifact stays WebVTT at `.vtt`. Embedded SRT tracks keep their existing representation. A `.srt` request without `original=1`, and any request on the frozen `/api/v1` route, keeps the historical WebVTT response. `/api/v1` neither advertises nor negotiates the feature. The feature is attempt-sticky (§6). Realtime subtitle events publish the same representation as the session's current plan, and every replan, including a seek reanchor, keeps the representation the current plan published, even when the attempt started on a server that did not know the feature. +**HLS subtitle renditions.** A whole-file sidecar of an embedded track has to +demux the entire source, because Matroska interleaves subtitle packets and +indexes only video; on network storage that outlasts a player's readiness +deadline. A client that sends `hls_subtitle_renditions_v1` to `/api/v2` instead +receives such a track inside the transcode itself. It applies when all of these +hold: + +- the plan is `server_transcode_hls` (encoded video, never a copy remux); +- the selected track is embedded text the policy already shows as text + (`render` or `convert`): SubRip, WebVTT, mov_text, or ASS/SSA unless the + client requires authored fidelity; +- the source duration is known and the playlist fits the synthetic VOD bound; +- an executor can emit it: this server, or a transcode node advertising the + `hls_subtitle_rendition_v1` transport feature. Node selection then admits only + such nodes, and a node that does not attest `subtitle_rendition` in its start + response is stopped and skipped. + +The decision is `subtitle: {"mode":"render", "track_id":"file:42:subtitle:2", +"hls_rendition":{"group_id":"subs","name":"Subtitles","format":"vtt", +"mime_type":"text/vtt"}, "inventory":[...]}`, with no `artifact` and no +`embedded`. The inventory keeps every sidecar URL, so another selection still +has one. The plan's stream URL is then a multivariant playlist: one +`EXT-X-MEDIA:TYPE=SUBTITLES` rendition in group `group_id` (`DEFAULT=YES`, +`AUTOSELECT=YES`) and one variant naming it in `SUBTITLES`. Both URIs are the +same `master.m3u8` with `prairie_rendition=subtitles` or `prairie_rendition=video` +and the original query, so every relay path serves them unchanged. The video +playlist is the ordinary synthetic VOD playlist; the subtitle playlist lists +the same segments (`segment/sub_NNNNN.vtt`) with the same durations. The client +selects the rendition in the player's subtitle (legible) group and fetches no +sidecar. A client that scopes its media credentials to the plan's URLs must +admit `master.m3u8` with exactly one `prairie_rendition=video` or +`prairie_rendition=subtitles` query, or the player cannot open either playlist. + +The transcode's own FFmpeg process writes the track as WebVTT beside the HLS +output, on the same `-copyts` source clock, so a cue is on disk once the video +around it is. Subtitle segment N carries every cue overlapping the source +time video segment N covers, `[O + (N−K)·d, O + (N−K+1)·d)`, with its absolute +source time and settings; a cue spanning segments repeats in each. K is the +segment the generation started at and O the source time of its first video +frame, read once per generation from that segment's first video PTS less the +timestamp-map shift below. O is not `K·d` when the source clock does not start +at zero: the first generation's grid starts at the first video frame, while an +input seek counts from the container's start time. On MPEG-TS, whose +timestamps wrap at 2^33 ticks, O takes the cue clock's epoch, and every window +of the generation keeps it. A +generation's output is read only while no restart is under way and no later +generation has rewritten it; a generation whose origin cannot be read uses +`[N·d, (N+1)·d)`. Every segment starts with +`X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:`, where `` is the +video segments' shift from the source clock: 900000 (10 s) for MPEG-TS and 0 +for fMP4. A segment whose cues are not produced yet is waited for while +FFmpeg runs: as long as its video segment would be (up to 30 s) when the +running or restarting transcode is about to reach it, otherwise a few seconds. +It is then answered as a valid WebVTT document with no cues, which a player +keeps, so the longer wait is what keeps cues after a slow seek restart. +Subtitle segments never return `503` and never trigger a seek restart. Each seek restart writes a new cue file and +keeps the earlier ones, so a backward seek into output an earlier generation +produced keeps its cues. Bitmap tracks keep their sidecar or burn-in routes. + +The rendition is part of the transcode recipe. Turning subtitles on or +choosing another track therefore starts a new HLS generation at the current +position, like a burn-in change; turning them off keeps the running transport, +whose playlist still offers the old rendition for the client to leave +unselected. A rendition plan and the sidecar plan for the same track have +different plan identifiers, so `failure_recovery` after a failed rendition +falls back to the sidecar instead of `adaptation_exhausted`. A client whose +player never offered the rendition sends that recovery with +`failure.classification: "subtitle_hls_rendition_failed"`; the server then +stops offering renditions for the rest of the attempt, so a later quality or +track change does not repeat the failure. The feature is +attempt-sticky (§6); `/api/v1` neither advertises nor negotiates it. + 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 @@ -1483,9 +1611,10 @@ does not publish its preimage. 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`. On `/api/v2` it also carries `display_name` -`Original`; the frozen `/api/v1` response leaves it unnamed. Transcode rungs are added below the source resolution +On `/api/v2` the menu lists only fixed rungs. The source is not an entry, +because `auto` already returns to it. The frozen `/api/v1` response still leads +with an unnamed source entry labelled `original` (`preserves_source: true`), +which now selects `auto`. Transcode rungs are added below the source resolution class, plus at the same class when they reduce bitrate, and only when HLS is available to the client, transcoding is enabled, the viewer's account may transcode video (`transcode_allowed`; admission still enforces it), and 4K @@ -1508,16 +1637,17 @@ remux without video encoding. A 4K version whose 4K transcoding is disabled takes its lower qualities from a lower-resolution version instead, when the item has one and the request allows -version fallback. The menu is then `original` (the requested 4K version), the +version fallback. The menu then lists the first non-4K version in fallback order at its plain resolution class (`1080p` or `720p`, with that version's height and bitrate; omitted when the version is taller than its class or the requested version is not taller than it), and the -rungs a transcode of that version can serve. When that version cannot play and +rungs a transcode of that version can serve (`/api/v1` also leads with +`original`, the requested 4K version). When that version cannot play and the fallback adopts a later non-4K version, the plan it returns describes the version it adopted. Choosing any of them refuses the 4K source, so the fallback plays the lower version: unchanged for its plain label or a rung it fits, transcoded for a lower -rung. `original` returns to the 4K version. The menu stays the same while the +rung. `auto` returns to the 4K version. The menu stays the same while the lower version plays. It needs HLS and transcoding enabled, because the 4K refusal under any other policy is not one the fallback acts on. @@ -1551,32 +1681,69 @@ clamp their bitrate but does not silently demote their resolution. Plain labels remain accepted for stored/default preferences and size their output like `auto` below. -`auto` picks its resolution from the shared bitrate ladder -([quality-ladder.md](quality-ladder.md)): 80% of the bandwidth estimate or cap -earns a class for the source's frame rate. A source whose height already fits -the class is sent as-is when its bitrate allows, even when it is wider than the -16:9 box (a 2560x1080 film at the 1080p class). Otherwise automatic and -plain-label targets fit the source into that class's 16:9 box, so a 3840x1600 -film at the 1080p class streams at 1920x800, and encode at the class bitrate: 20000 kbps for 2160p, 6000 -for 1080p, 2000 for 720p, 1800 for 540p and 1500 for 480p, never above that 80% -budget. A source that already fits the class but whose bitrate exceeds 80% of the -bandwidth estimate is re-encoded at its own size within that budget rather than -sent as-is; a source of unknown bitrate counts as needing its class's full -bitrate. Under a cap, a source over the cap itself is re-encoded, and so is a -video source of unknown bitrate, since it cannot be shown to fit. A transcode -also never targets more than the source's own bitrate, counted in the output -codec (H.264 for a scaled encode, or HEVC for any encode when the planner -chooses HEVC). +`auto` is best-effort direct play. It sends the source as-is unless the device +rules it out, through a `max_resolution` below the source or decode +capabilities the source does not fit, or the source's bitrate exceeds 80% of a +measured `bandwidth_estimate_kbps`. A source that fits that budget is sent as-is +at any resolution; the estimate only lowers a source the link cannot carry. +Without an estimate `auto` assumes the link can carry the source. `metered` is +accepted from older clients and ignored: a client on a cellular link sends its +cellular quality as `quality_preference` and `bandwidth_cap_kbps` instead. A +fixed rung or plain resolution never reads the estimate, so a viewer who wants +the source whatever the estimate says picks a resolution. `original` used to be +that choice; it is now an alias of `auto`. + +A source over the estimate's budget, or over `bandwidth_cap_kbps`, is +re-encoded at the class that 80% of the estimate or cap earns on the shared +bitrate ladder ([quality-ladder.md](quality-ladder.md)) for the source's frame +rate. A source of unknown bitrate counts as needing its class's full bitrate +against an estimate, and is always re-encoded under a cap, since it cannot be +shown to fit. A source whose height already fits the earned class keeps its own +size, even when it is wider than the 16:9 box (a 2560x1080 film at the 1080p +class). Otherwise automatic and plain-label targets fit the source into that +class's 16:9 box, so a 3840x1600 film at the 1080p class streams at 1920x800, +and encode at the class bitrate: 20000 kbps for 2160p, 6000 for 1080p, 2000 for +720p, 1800 for 540p and 1500 for 480p, never above the estimate's 80% budget or +the cap. A transcode also never targets more than the source's own bitrate, +counted in the output codec (H.264 for a scaled encode, or HEVC for any encode +when the planner chooses HEVC). + +`bandwidth_estimate_kbps` is optional and is the client's belief about its +link, not a promise. An absent estimate removes the bandwidth term entirely, so +`auto` plans from the device and the source alone; a client should omit the +field rather than send a hint it knows is clamped, such as a browser's +`navigator.connection.downlink`, which Chromium caps at 10 Mbps. Clients that +can measure use the `/api/v2` bandwidth probe ([playback-api.md](../playback-api.md#bandwidth-probe)). + +A transcode is a single HLS rendition, so the player cannot step down on its +own. A client on `auto` whose playback keeps rebuffering sends a +`quality_change` replan that keeps `quality_preference: "auto"` and lowers +`bandwidth_estimate_kbps` below the bitrate that stalled; the planner then +re-runs `auto` against the lower estimate. The web player does this after two +stalls of at least a second within two minutes, or one stall of six seconds. +Stall time in the first ten seconds of a plan does not count, and buffering +caused by a seek, pause or source reload is not a stall. It sends 70% of the +delivered bitrate, so the next stream targets about 56% of the one that +stalled. It keeps that lower estimate for the next 30 minutes. When the plan +that follows is no lower, it leaves that plan alone for five minutes rather than +replan in a loop. On a metered link it uses the estimate only if +the stall was observed on the same kind of metered link, by the browser's +connection type and Data Saver setting, since probe results and observations +from other links do not describe it; back on an unmetered link it ignores a +limit recorded on a metered one. Buffering while media requests fail (load +errors, not timeouts) or while the browser is offline is an outage for the +reconnect path and never counts toward a downshift. 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. +Audio-only sources publish no rungs on `/api/v2` (a single `original` entry on +`/api/v1`), since quality rungs are a video concept. -`quality_preference` accepts `auto`, `original` (aliases `source`, `max`), the +`quality_preference` accepts `auto`, the legacy `original` (aliases `source`, +`max`), which means `auto`, the plain `2160p` / `1080p` / `720p` / `480p` values with their obvious aliases (`4k`, `uhd`, `fhd`, `hd`, `sd`), and the compound labels in the table above. An unrecognized value normalizes to `auto` and the response carries the @@ -1675,6 +1842,8 @@ or older node advertisement is rejected instead of silently changing modes. Dolby Vision compatibility IDs `1` through `6` resolve to their declared standards-compatible base layer: `1` and `6` are PQ, `2` is BT.709 SDR, `3` is legacy BT.709-gamut HLG, `4` is BT.2100 HLG, and `5` is BT.2020 SDR. +A plain HLG source resolves to BT.2100 HLG, or to the BT.709-gamut HLG kind +when its primaries and matrix are both tagged BT.709. Profile 5 and ID `0` carry a base only the stream's RPU can map to PQ, so they resolve to the `dolby_vision` source kind. Only executors that apply the RPU advertise it: software `tonemapx`, QSV `tonemap_opencl`, and NVENC diff --git a/docs/architecture/postgres-search.md b/docs/architecture/postgres-search.md index 73d35a4606..ca18b41117 100644 --- a/docs/architecture/postgres-search.md +++ b/docs/architecture/postgres-search.md @@ -40,15 +40,30 @@ stored representation. ## Ranking, counts, and continuation -The relevance order is exact title, contiguous title, year hint, phrase rank, -title prefix rank, overview rank, lower title, and content ID. A media title -also counts as exact when it is the query followed by the item's own year, as -providers title remakes and reboots ("Castle (2009)"); short whole-title -admission accepts that form too. Episode titles have no year of their own and -do not use this rule. Access filtering -precedes results, and any accessible title or alias match suppresses the entire -overview family. Overview matches must meet the existing rank floor. This gate -applies to the complete family, including on later cursor pages. +The relevance order is exact title, title position, year hint, phrase rank, +title prefix rank, overview rank, lower title, and content ID. Title position +is 2 when a title, original title, sort title, or alias starts with the query, +1 when it contains the query, and 0 otherwise. When a title variant contains +the query, title prefix rank is `1 / (1 + words)` of the shortest such variant, +so shorter titles rank first. Otherwise it is `ts_rank_cd` divided by document +length (normalization 2), as is phrase rank. Cover density adds a cover for +each repeat of a query word, so it ranked "From Star Wars to Star Wars" above +the films and an alias "The Godfather & the Lady" above "The Godfather 2". The +episode exact tier computes the same rank for its constant prefix score. A +media title also counts as exact when it is the query followed by the item's +own year, as providers title remakes and reboots ("Castle (2009)"); short +whole-title admission accepts that form too. Episode titles have no year of +their own and do not use this rule. Access filtering precedes results, and any +accessible title or alias match suppresses the entire overview family. +Overview matches must meet the existing rank floor. This gate applies to the +complete family, including on later cursor pages. + +Ranks are `float8`. A cursor carries each key as text and seeks with it cast +to `numeric`; PostgreSQL compares that with a `real` rank as `float8`, so a +`real` key such as 2.4 never equals itself, and continuation skipped or +repeated rank ties. `postgresCursorConfig` names the meaning of the keys; +change it with any ranking change so older cursors are rejected +(`invalid_cursor` in `/api/v2`, `catalog_cursor_changed` in `/api/v1`). Title and alias lookups produce an indexed union of candidate IDs before media policy checks and scoring. Media library checks remain correlated with those @@ -105,6 +120,33 @@ use the root `window_cursor` with `seek`, which may scan the ordered prefix and requires another boundary query. The wire contract and live-data continuation rules are documented in `docs/catalog-api.md`. +## Typo fallback + +A first page with fewer than five FTS hits adds trigram typo candidates for +queries with a word of four or more characters. PostgreSQL matches the query's +words other than stopwords (the whole query when none has four characters) +against title and alias trigrams and returns up to 500 candidates by trigram +rank. With "the", "the godfathr" admitted a quarter of a 190,000-title catalog +and ran close to the three-second search deadline. Whole-query similarity +favors short titles, so a long title containing every word can rank in the +hundreds. The whole-title similarity floor below still compares the full query. +The server scores each candidate's title and up to eight aliases word by word: +the four most similar to the query and the four covering the most query words, +since similarity also ranks a long alias with every word below short aliases +with one. It allows 0 edits for words of up to two characters, 1 for three to +five, and 2 for longer words (Elasticsearch's fuzziness AUTO). A word may also +match two adjacent title words joined, at one extra edit for ranking only. +Stopwords ("the", "a", "of", and similar) take no edits and need not appear. A +candidate is kept only when it matches every other query word, as +Elasticsearch's AND operator and Meilisearch's `all` strategy require. When no +candidate matches every word and FTS found nothing, the candidates missing the +fewest words remain, as Typesense's token dropping does; a candidate matching +only stopwords never does. When FTS found hits, candidates also need +whole-title similarity of at least 0.45, and none is kept unless it matches +every word. At most 50 typo rows follow the FTS hits; the total is inexact when +candidates or kept rows were capped. The offset and cursor paths share this +scoring and admission. + ## Card enrichment Catalog and section badges select a file winner in SQL and decode only that diff --git a/docs/architecture/realtime-monitoring.md b/docs/architecture/realtime-monitoring.md index aed3e82571..c9b0573d84 100644 --- a/docs/architecture/realtime-monitoring.md +++ b/docs/architecture/realtime-monitoring.md @@ -29,7 +29,10 @@ operations are in `internal/apiv2/library_monitoring.go` and are described in Two switches control monitoring: - `scanner.realtime_monitoring`, the server-wide switch (default `true`). It - hot-reloads through the config watcher and needs no restart. + hot-reloads through the config watcher and needs no restart. Admins turn + monitoring on or off per library, so Library & Metadata shows this switch + only while it is saved off, which is where the library editor sends an admin + to turn it back on. - `media_folders.realtime_monitoring`, the per-library switch (default `true` for existing and new libraries). It is exposed on `/api/v2/libraries` only; the frozen `/api/v1` routes neither return nor change it. diff --git a/docs/architecture/recommendations-evaluation.md b/docs/architecture/recommendations-evaluation.md new file mode 100644 index 0000000000..0ca0d0ea60 --- /dev/null +++ b/docs/architecture/recommendations-evaluation.md @@ -0,0 +1,167 @@ +# Evaluating recommendations + +This document defines the measurements used to judge recommendation output +against the Recommendations acceptance criteria in issue #1139: AC1 (a +personalized, varied set per profile, refreshed in the background, with +sensible choices for a new profile) and AC2 (clients render the output, and a +default row instead of an empty shelf when there is none). It elaborates AC1 +and AC2 and does not change them. It sets no pass or fail values: the +validators agree those on the validation tasks before they test. + +Every metric is computed from `/api/v2` responses as the acting profile sees +them, so it measures what clients receive rather than internal state, and it +does not depend on the engine's weights. `scripts/recs-probe/capture.sh` +captures the responses and prints the numbers; see [Probe](#probe). How rows +are built and served is in [recommendations.md](recommendations.md). + +## Terms + +- **Served window.** The first 20 cards of a row as v2 returns it with + `limit=20`, the default. Section pages (`getRecommendationSection`) return up + to 60 cards and are not served windows. +- **Personal row.** A row whose `type` is `cluster` (the main For You row from + `getForYouMain` and the "Because you enjoy …" rows) or + `similar_users_liked`. Other row types are server-wide. `getForYouMain` + answers `200` with `items: []` when the profile has no row, so a check of + the main row's `type` or `title` must also check that it has cards. +- **Upcoming card.** A Discover card with an `upcoming_event` announces an + airing; it is not a recommendation and every metric leaves it out. +- **History.** The profile's items in `listHistory` (`GET /api/v2/history`) + whose `watch.completed` is true, plus its `listFavorites` items, each counted + once. Taste-seed picks are favorites. History cards for episodes are their + series, as recommendation cards are. +- **History genre share.** For genre g, the share of history items that carry + g. The profile's top genres are those with the largest shares. +- **Accessible catalog.** What `listCatalogItems` (`GET /api/v2/catalog`) + returns for the profile, which applies its access. Its `total` counts it; + `type` and `genre` narrow it, one media type and one genre per request. + +## Personalization + +**Overlap between profiles.** For two profiles on one account, the Jaccard +index of their served main rows: shared cards divided by distinct cards, by +`content_id`. The same index over the union of each profile's personal rows +(the main row, `listForYouRows` and the personal rows of `getDiscover`) shows +whether the rest of the page is shared. #1139 notes that profiles need not +have disjoint sets. + +**On-taste share and lift.** For each of the profile's top two history genres +g, compute per media type (movie, series): + +- served share: the share of served main-row cards of that type that carry g; +- catalog share: `total` of `GET /api/v2/catalog?type=&genre=&limit=1` + divided by `total` of `GET /api/v2/catalog?type=&limit=1`; +- lift: served share divided by catalog share. + +A lift near 1 means the row carries g no more often than the profile's +catalog does. A genre that most of the catalog carries cannot reach a high +lift, so read the served share against the history share as well. + +## Variety + +**Genre shares.** The share of served main-row cards carrying each genre, +against the history genre shares. + +**Amplification.** The served share of the served row's top genre minus that +genre's history share. Positive values mean the row concentrates on the top +genre more than the history does. + +**Taste coverage.** For each top history genre, the number of served main-row +cards carrying it, and whether a cluster row on the page names it in its +title. A profile with two distinct tastes shows whether both reach the page. + +## Repetition + +**Within one load.** Cards that appear in two or more Discover rows, and cards +that appear in two or more home recommendation sections (`section_type` +`recommended_for_you`, `because_you_watched`, `similar_users_liked` or +`taste_match`, from `listHomeSections`). + +**Across loads.** For the same profile, the number of shared cards between +two loads of the served main row, over the first 10 and the first 20 cards. +Measure it for two loads on one day and, for a profile with no new signals, +for loads on consecutive days. Rows change between loads when a refresh wrote +new rows (see [Refresh window](#refresh-window)), and rows chosen by date, such +as Discover's genre rows, change with the date. + +## Leakage + +Count the served cards, in every row of `getForYouMain`, `listForYouRows`, +`getDiscover` and the home recommendation sections, that are: + +- **finished:** in the history with `watch.completed` true, or flagged + `user_state.played`; +- **favorited:** in `listFavorites`, including taste-seed picks, or flagged + `user_state.is_favorite`; +- **rated:** rated at all in `listRatings` (`GET /api/v2/ratings`), since rows + leave out every title the profile rated, not only those it rated 1 or 2; +- **not accessible:** answered `404` by `getCatalogItem` + (`GET /api/v2/catalog/items/{id}`) for the profile, or rated above the + profile's `max_content_rating` (`listProfiles`); +- **out of 1.0 scope:** of a type other than `movie` or `series`, in a personal + row of a profile with no book activity. + +Cards name a movie or series, so an episode or season in the history, +favorites or ratings counts as its series, as the rows' own exclusion set does: +a series the profile finished an episode of, favorited an episode of or rated +an episode of is leakage when a row serves it. History and favorites items +carry `series_id`; a rating names only the rated item, so its series comes from +`getCatalogItem`. + +Watchlist titles are not leakage: they stay recommendable. + +## New-profile usefulness + +**Before any signal.** For a profile with no history: the number of +`getDiscover` rows and the cards in each, on the first request, without a +nightly run or an admin action in between. AC2 asks that clients then show a +default row rather than an empty shelf. + +**After the taste seed.** After `createTasteSeed` +(`POST /api/v2/recommendations/taste-seed`) with a few picks: + +- whether the next `getDiscover` and `listForYouRows` reads hold a personal + row, and how long after the submission it appeared; +- how many picks appear in any row (they are favorites, so this is leakage); +- the share of personal-row cards sharing a genre with at least one pick. + +The submission answers once the profile's refresh has finished, or after +about 5 seconds; a slower refresh finishes in the background. + +## Refresh window + +The refresh window is the time from a signal (a rating, favorite, watchlist +entry, watch progress or history import) to the first read whose rows reflect +it. `getTasteProfile`'s `updated_at` moves past the time of the signal when the +profile's refresh has stored its taste profile; the refresh writes the +profile's rows next. The schedule that bounds it: + +| Path | When it runs | +| --- | --- | +| Signal write (native and Jellyfin handlers, history imports, watch-provider syncs, media-server webhooks) | At once, on the server that took the write, after any refresh already queued there; one at a time per server, each with a 2-minute budget | +| Taste seed | At once, on the server that took it; the request waits up to about 5 seconds | +| Stale sweep | Every 5 minutes, up to 50 profiles with a taste profile marked stale, oldest mark first; picks up a refresh whose server dropped it | +| Read of a profile whose rows are missing | Requests a refresh at most once per 15 minutes per profile on each server | +| Nightly jobs | Embeddings 03:00, taste profiles 04:00, co-watch 04:30, rows of every profile with a taste profile 05:00, in server-local time (the `recommendations.*_cron` settings) | + +Cached rows expire 26 hours after the run that wrote them. Ebook and +Audiobookshelf progress (beta) do not request a refresh; those profiles +refresh at the next nightly taste run or stale sweep. + +## Probe + +`scripts/recs-probe/capture.sh` sends only GET requests. It takes the server +URL and a token from the environment and, per profile, saves the +`getTasteProfile`, `getForYouMain`, `listForYouRows`, `getDiscover`, +`listHomeSections`, `listHistory`, `listFavorites` and `listRatings` responses +as JSON, and looks up each rated ID that is not a movie or series with +`getCatalogItem` to find its series. It prints one line per check: signal counts, main row and Discover +row sizes, leakage counts, repeats within the load, card types in personal +rows, and genre shares with amplification. It then prints the overlap between +each pair of profiles and, given an earlier capture in `PREV_DIR`, the +day-over-day overlap of each main row. It captures the first 200 entries of +the history, favorites and ratings and marks a list that has more. It prints +numbers, never a verdict. Run it with `--help` for its settings. + +Catalog shares for lift, access checks of single cards and timings of the +refresh window are taken by hand with the requests named above. diff --git a/docs/architecture/recommendations.md b/docs/architecture/recommendations.md new file mode 100644 index 0000000000..1d6f8efb83 --- /dev/null +++ b/docs/architecture/recommendations.md @@ -0,0 +1,519 @@ +# Recommendations + +Recommendations turn each catalog item into an embedding, build a taste +profile per household profile from its signals, and cache rows of candidates +that reads filter for the viewer. Rows are built in the background, and reads +never call the embedding provider: the only interactive embedding calls are +catalog search queries and the admin connection check. + +The code lives in `internal/recommendations`: the embedding backfill +(`embed_backfill.go`, provider client in `embeddings/`), taste profiles +(`taste.go`, `taste_signals.go`, `signals.go`, `cluster.go`), row building +(`personal.go`, `collaborative.go`, `cowatch.go`, `diversity.go`), the worker +and its jobs (`worker.go`, `jobs.go`), the admin reset +(`embedding_reset.go`), and the cached-row reads (`reader.go`). Native reads +go through `internal/api/handlers/recommendations*.go` and +`internal/apiv2/recommendations.go`; home and library sections through +`internal/sections`; Jellyfin's `/Movies/Recommendations` through +`internal/jellycompat`. Admin operations are in +[admin-recommendations-api.md](../admin-recommendations-api.md); the +measurements used to judge the output are in +[recommendations-evaluation.md](recommendations-evaluation.md). + +## Jobs + +Every API node runs a `Worker`. Each job takes the in-process running flag and +a Postgres advisory lock for its kind before it starts, so one node runs it at +a time across the cluster; a busy job is skipped by cron and answered with +`409` by a manual trigger. Each finished run is recorded in `task_executions` +under `recommendations.embeddings`, `recommendations.taste_profiles`, +`recommendations.cowatch` and `recommendations.cache`, with its counts as +`result_data`. Partial runs, the embedding catch-up and the startup global-row +build, are recorded under `recommendations.embeddings.catch_up` and +`recommendations.cache.global_rows`, so admin status shows them beside the +last full run instead of in its place. A run that hit partial failures is `completed` and counts them. +An advisory lock pins the pooled connection that took it, so every further +lock a lock holder takes goes on that same session: a job's or the stale +sweep's per-profile locks (each released after its profile), and the +embeddings reset's job claims and sweep lock. A running job holds one +connection for its locks. + +| Job | When | What | +| --- | --- | --- | +| Embeddings | `recommendations.embeddings_cron` (03:00) | Pass 1 then Pass 2, below | +| Embedding catch-up | every 15 minutes, and at startup when items need it | Pass 1 only, under the embeddings job's claim; idle runs are not recorded, failed ones are | +| Taste profiles | `recommendations.taste_profiles_cron` (04:00) | Rebuild every profile with signals or a taste row | +| Co-watch | `recommendations.cowatch_cron` (04:30) | Rebuild `item_cowatch`, pruning pairs the run did not rewrite | +| Cache | `recommendations.recommendations_cron` (05:00) | Global rows, then the rows of every profile with a taste-profile row, oldest For You row first (profiles without one first) | +| Stale sweep | every 5 minutes, own lock | Refresh up to 50 stale profiles, oldest mark first; a sweep that refreshed or failed one is recorded under `recommendations.stale_sweep` | + +At startup the worker also builds the global rows when none are cached, so +cold-start rows exist before the first nightly cache run. Recently Added +fills from any matched movie or series; Popular needs viewing by at least two +accounts and stays empty on smaller servers. + +## Embeddings + +Pass 1 embeds items with no embedding or one from another model. Pass 2 finds +items whose stored canonical text no longer matches `BuildEmbeddingText`; it +rebuilds the text in SQL, so the people ordering there must match Go's +byte-wise sort (`COLLATE "C"` and the full sort key), and every SQL candidate +is re-checked in Go before it counts against the 200-item quota. + +The first stored vector writes the embedding lock (base URL, model, source and +storage dimensions) in `server_settings`. Every later vector must match it. + +- A vector is checked before the lock is read or written: an empty vector or + one wider than `embeddingvectors.CanonicalDimensions` (3072) ends the run, + so an unusable model never pins the lock. +- Vectors are zero-padded to 3072 for storage; cosine is unaffected. +- Changing the model or base URL requires the admin embeddings reset, which + deletes the lock, embeddings, clusters and per-profile cached rows, and + clears every taste vector, in one transaction while holding the embedding, taste and cache + job claims and the stale sweep lock. Its transaction also takes the + embedding writers lock (`EmbeddingWritersLock`) exclusively, which a catalog + import carrying embeddings holds shared for its transaction, so the reset + refuses while one runs and an import waits for a running reset before it + reads the embedding lock. It also refuses while the saved model or + base URL differs from the one the server runs with, since the old model + could otherwise be embedded and locked again before the restart. +- Catalog seed imports keep only embeddings from the locked model, or the + configured one when nothing is locked, and never write the lock. An + embedding run that finds such vectors and no lock embeds a probe text with + the configured provider and writes the lock from it, so semantic search + turns on even when every item already has a vector. +- The canonical text stored with a vector is always the full text, even when + the provider only accepted a shortened copy, so Pass 2 does not re-embed it. + +A run stops at once on provider limits, rejected credentials, a wrong model or +URL (401/403/404) and unreachable hosts. It also stops after three single-item +failures in a row, and when the first single-item retry after a failed batch +fails before anything was stored. An input the provider refuses at every +length (a 4xx answer other than 401, 403, 404, 408 and 429) counts as skipped +and is not a failure: refused items cannot stop a run, and a run whose only +misses are refused inputs completes. The first refusal before anything is +stored, and every third refusal in a row after a stored vector or a test, is +followed by one call with a fixed test text. A provider that refuses that too, +as Gemini does a bad API key with 400 (including a key revoked mid-run), stops +the run, and the refusals since the last stored vector or test count as +failures that the next run retries, not as refused inputs. Each +server remembers in memory the items it saw refused, with a hash of their +text, and its catch-up passes skip them until the text changes; the nightly +or a manual embedding run clears the record and retries them, and so do a +restart and a run that finds a different embedding lock than the record was +made under (an embeddings reset deletes the lock on every server). A lock that conflicts with the configuration fails every run, +including the catch-up pass every 15 minutes, until an administrator resets +embeddings or restores the settings. + +Search indexes only vectors from the locked model, and semantic search is +ready for a type once a lock exists and at least 85% of that type's eligible +items carry one of its vectors. + +## Signals and taste profiles + +Signals are ratings, favorites, watchlist entries, Not interested marks, video +watch progress and history, and ebook reading progress. The write paths for +ratings, favorites, watchlist, Not interested marks and video progress call +`Worker.NotifySignalsChanged`, which marks +the profile stale and queues a refresh on that server: native and Jellyfin +handlers, history imports (once per run), watch-provider syncs (once per run +that changed local state) and media-server webhooks. Catalog merges and splits +only mark the affected profiles stale, for the stale sweep. Changes to a +profile's access scope also notify (see Access). Ebook and Audiobookshelf +progress (beta) do not notify; those profiles refresh at the next nightly taste +run or stale sweep. + +A taste-seed submission calls `Worker.RefreshProfileNow` instead: it marks the +profile stale, refreshes it on that server at once and waits up to 5 seconds, +so the client's next read shows the picks' effect. The refresh holds the +profile's pending key, so the queue and the sweep do not start a duplicate on +that server, and it runs detached from the request with the usual 2-minute +budget: a slower refresh finishes in the background and is not queued again. +At most 4 run at once per server; past that the refresh is queued. + +A refresh takes a cluster-wide advisory lock for the profile, then records the +database time it started, stores the taste profile with that time as +`updated_at`, and clears only stale marks set before it. A refresh request +made on the same server while the refresh runs makes it run once more as soon +as it ends, so a burst of changes is applied within seconds; after 3 runs +in a row a profile still changing is left to the stale sweep, so one +profile cannot hold the queue. A mark set during +the refresh from another server survives, and a failed refresh marks the +profile stale again, so the stale sweep retries both. The sweep takes every +profile with a mark; only a full refresh clears one, so a mark the nightly +taste job passes over (it stores the vector but rebuilds no cached rows) stays +pending. The scheduled taste and cache jobs take the same lock per profile and +skip a profile another server is refreshing. Storing embeddings marks stale +the profiles with signals on those titles (every profile when the user store +is outside Postgres, where that query cannot see favorites, watchlist or +progress), so a profile refreshed before its titles had vectors, or cleared by +a reset, is rebuilt by the sweep. Marking a profile with no taste row yet +creates a vectorless row updated at the epoch, so a change during its first +build is +not lost. A refresh that finds the profile's lock held by +another server marks the profile stale and stops, so two refreshes never +finish out of order. + +- Episodes roll up to their series. A series decays once by its most recent + signal, as a movie decays by its last watch. A series with no signal for + 30 days counts in proportion to its episodes watched with a positive + weight: in full from 5, and at least a quarter. A series being watched now + is not scaled, and neither is a negative series score. +- Progress below 15% counts as abandonment only after 14 days. +- The taste vector and clusters average the titles with a positive weight + only. Dislikes and abandoned titles still count in `signal_counts`, but do + not pull the vector away from themselves. A title marked Not interested + leaves the vector however it was watched, rated or favorited, and counts + as `not_interested`; marks are not positive signals, so a profile with + only marks has no taste profile and asks for no refresh. The rows rank + against marked and 1-2 star titles instead (see Turned-away titles under + Ranking). A 3-star rating adds no weight of + its own and halves the title's watch and intent weight, so a 3-star + completion weighs less than an unrated one. +- Clusters split the same titles into interests. A profile under 10 titles + has one cluster. A larger one gets 2 (10-19 titles), 3 (20-59), 4 + (60-199) or 5 (200 or more), or more, up to 5, when a partition into + more has a higher silhouette (cosine distance) and one of at least 0.1; + every cluster holds at least 3 titles. A partition is the best of up to 8 + deterministic k-means++ seedings (`min(8, 800/titles)`: fewer above 100 + titles, one from 401), + by inertia, among those leaving no cluster under 3 titles. One seeding + can put two seeds in one interest and leave a sliver that merging folds + away together with every other interest. When no seeding qualifies, the + first seeding's small clusters are merged; it is not run again. Real distinct + interests scored 0.13-0.22 and one interest cut in two under 0.08 + (gemini-embedding-001 on a real catalog). +- Watch, rewatch, favorite and watchlist counts in `signal_counts` count each + canonical title once (a watch in the bucket of its strongest watch), so one + long series is one title. +- A refresh also stores `positive_titles` in `signal_counts`: the titles with + an embedding and a positive weight, each once, leaving out titles that are + only on the watchlist. The cold-start level counts them: 0 titles is + level 0, global rows only; 1-2 is level 1, global rows then one personal + row; 3-9 (three is the taste-seed picker's minimum) is level 2, personal and + global rows interleaved, personal first; 10 or more is level 3, personal + rows first. A row without the entry, stored before it existed, uses the sum + of its positive signal counts until its next refresh. The taste-profile + summary leaves `positive_titles` out, so its `signal_counts` holds only + kinds of signal. +- A profile with no positively weighted title that has an embedding keeps + its row with a `NULL` taste vector and loses its clusters and cached + personal rows. Vector reads treat it as + having no taste profile. A read of a profile with positive signals but no + personal rows asks for a refresh even at level 0, since its titles may have + gained embeddings since. +- Clusters are seeded from item IDs only, so decaying weights do not reshuffle + them between rebuilds. A cached cluster row carries the title of the build + that produced it. +- The taste-profile summary's genres come from the clusters: the first + dominant genre of each cluster, heaviest first, then the second of each, + without repeats, at most five, so a second interest shows next to the + heaviest. A profile without clusters has none. Favorite directors still + come from titles rated 4 stars or more and favorites. + +## Rows + +Personal rows leave out the profile's exclusion set: watched titles, +favorites (which include taste-seed picks), titles marked Not interested, +every title the profile rated, +and every title it abandoned (progress under 15% left untouched for 14 days, +which its taste profile counts as a dislike), canonicalized episode to +series. A title the profile already judged is not news to it, and one it +rated well would otherwise lead its own row. Watchlist titles stay +recommendable. Rows are built leaving out the whole set; a read drops watched, +favorited, marked and rated titles again, from every row, so rows cached +before a change are cleaned, and abandoned titles leave at the next rebuild. + +A Not interested mark is per profile, on a movie or series only (a series +mark covers its episodes); the API refuses episodes and seasons. Beyond the +personal rows it keeps the title out of the live lists the recommendations +handler filters (Popular, Recently Added, the airings Discover blends in, +Watch Tonight), an item's "More like this" list (left out before ranking, on +the native and Jellyfin surfaces), and the Home and library discovery +sections `sections.HidesNotInterested` names, which drop marked titles after +the shared list cache and may come out shorter: several draw a daily sample +sized by the row's limit, so a wider refetch would swap unrelated titles. Library +browsing, search, item detail, playback, collections, the profile's own +lists, Continue Watching and Next Up keep the title. Because You Watched +never anchors on a marked title. + +Similar Users needs at least 3 peer accounts and at least 2 supporting +accounts per title, counted by account, so no single household's ratings are +shown on their own. Below the floor the row is cached empty. Co-watch pairs, +which feed Because You Watched and similar titles, follow the same rule: an +item needs watchers on 5 accounts, and a pair's shared watchers must span 3 +accounts, so one household's profiles cannot make a pair. + +Every row, personal or not, and the taste-seed picker offer matched movies +and series only, the types in `recommendableMediaTypes` (see Media types +under Ranking). Popular counts login accounts, not profiles: a title needs +at least 2 accounts that watched it in the last 90 days, so a +single-account server has no Popular row, and the cached row keeps 200 titles +for reads to filter. Genre rows rank a genre's titles by catalog rank, with +watching accounts only as a tie-break. Catalog rank (`catalogRatingOrderSQL`) +puts notable titles first (a logo and at least five keywords, since most +titles have no recorded vote count and an obscure title's rating rests on a +handful of votes), then rating reliability (an IMDb rating below 9.6, then a +TMDB rating below 9.5; higher scores are nearly always a few votes or a +copied score), then that rating. Highly Rated, the picker and the quality +prior use the same ratings. The 16 cached genres are those most accounts watched, once at +least 2 share one, then the largest. The picker interleaves its best 600 +candidates by first genre, then continues in rank order. + +Discover and its section pages serve two default rows live: Highly Rated in +Your Library (a catalog rating of at least 7.0, movies and series +interleaved in proportion to how many of each the viewer can see) and +Recently Added (by the title's or its latest episode's addition, with no +window). Reads query them under the viewer's access filter and exclusion +set, so they show on a fresh server, with recommendations disabled, and for +a restricted profile. Highly Rated is never cached. The cached global +Recently Added row uses the same query without the access filter and also +has no window, so on a server with no Popular row a new profile's cached +reads still get a row. + +Both queries read each media type from its own index +(`idx_media_items_type_added_at`, `idx_media_items_type_catalog_rank`) +only as deep as the row needs, so a read's cost does not grow with the +catalog. The index expressions repeat `addedAtSQL` and +`catalogRatingOrderSQL` and must change with them. Each read is planned for +its own exclusion set rather than reusing a generic plan. The per-type +title counts Highly Rated interleaves by are the one part that reads every +title, so each server reuses an access scope's counts for 10 minutes. The +counts only set the mix, so they never fail or hold up a read: expired +counts are served while a background count refreshes them, a read with no +counts yet stops waiting when its request ends, and a failed count falls +back to the scope's last counts, or equal weights, for a minute. +Discover reads the profile's watched set once per request and shares it +between its rows and the airings it blends in; Watch Tonight shares it +between its candidates, its cold-start fallback and the handler's filter. + +Cache rows expire 26 hours after the run that wrote them, past the next daily +cache run. A global row whose rebuild fails keeps its last good version until +the new run's expiry; a global row whose rebuild finds nothing, and a genre row +whose genre left the menu, is deleted. A personal row whose build fails keeps +its cached version and the refresh is retried. A main row fails when any +interest's candidates or embeddings cannot be read, rather than being cached +without that interest, and the cluster rows then keep theirs too, since they +leave out the main row's titles; a cluster row fails the same way. A main or Because You Watched +row whose rebuild finds nothing is deleted, and a cluster row that rebuilds +empty is cached empty (see Cluster rows). + +## Access + +Rows are built under the scope the API resolves for the profile (the policy +viewer resolver, with `access.Resolver` as the legacy fallback): account and +access-group libraries, profile restrictions, hidden libraries and maturity +limits, with PIN verification skipped. A profile +whose scope cannot be resolved is not cached. Profile restriction, hidden +library, account and access-group changes trigger a rebuild; account- and +group-wide changes mark the affected profiles stale in one statement for the +stale sweep. A policy change marks every profile stale the same way, since a +custom scope policy can change any account's scope. Every server announces a +generation when it loads it, but only the first announcement marks: the +generation marked last is stored in `server_settings` +(`recommendations.policy_marked_generation`) and claimed in the same statement +as the mark, so a change costs one full-table update, not one per server. A +refresh, the only thing that clears marks, first checks that its server has +loaded the stored generation and otherwise leaves the profile pending, so no +server rebuilds under the old policy and clears the change's mark. Changing +the server setting `access.unrated_content`, part of every rating ceiling's +maturity limits, marks every profile stale too, and again once servers' +10-second cache of the setting has expired. + +Every read filters again with the viewer's access filter. List endpoints that +return bare identifiers filter before answering, and a list anchored on an +item the viewer cannot see answers as one anchored on an unknown item. +Deleting a profile purges its ratings, Not interested marks, taste profile, +clusters and cached rows on both user-store backends. A refresh that runs afterwards writes no taste row +for a profile the user store no longer lists, and on the Postgres store the +nightly taste job skips such profiles, as the purge migration does. + +## Ranking + +A cached personal row holds `CacheCandidateLimit` (60) titles; a public read +serves the first `ServedRowSize` (20) by default. Ranking shapes those 20 +for the viewer, and the rest is headroom for read-time filters and for +library sections, which scope the whole row to their libraries. + +- **Main row.** A profile with at least 10 positive titles and at least two + clusters gets a row composed per cluster, each cluster an anchor. Anchors + are not merged by centroid cosine: a centroid averages away its titles' + differences, so centroids of large clusters of different genres sit + above 0.9 (most pairs with 50 or more titles on a real catalog), while + centroids of 3-title halves of one genre sit near 0.82. The clustering + decides what is one interest. Anchors get slots in proportion to + their weight by largest remainder, each at least `min(3, limit/anchors)`. + Each anchor fetches 3 candidates per slot around its centroid and ranks + them by MMR, and a smooth weighted round-robin interleaves the anchors' + rankings without repeats, so every prefix of the row holds each interest's + share. Any other profile, or one whose anchors find nothing, gets the + candidates nearest its averaged taste vector, ranked by MMR. +- **Genre pass.** In a row from the averaged taste vector, a stable reorder + of the MMR order keeps each genre to half of the served window while the + row has other titles to offer. It is greedy in rank order and never + revisits a title it let in, so with multi-genre titles a genre can exceed + half when only a different set of titles would meet the cap. It deletes + nothing and moves nothing past the window. A composed row skips it: its slots already + spread the window over the interests by weight, and the cap would cut an + interest heavier than half the profile below its share. +- **Type supplements.** The main row holds at least a fifth of its length of + each of `recommendableMediaTypes` the viewer can see, so a library section + fills. The extra titles go after the served window, replacing tail titles + of types above that floor; a type the viewer has no titles of is not + queried. +- **Cluster rows.** A cluster row is built `max(3, 60 × weight share) + 20` + long (at most 60) from candidates sharing one of its dominant genres, then + loses the main row's first 30, every title its daily rotation can serve, + so the page does not repeat the main row. + A row left with fewer than 10 titles is cached empty, and a row cached + empty counts as built: reads do not ask for a rebuild of it. +- **Turned-away titles.** Before MMR, the main row's pools and type + supplements, the cluster pools, Because You Watched (cached and live) and + Similar Users rank against the titles the + profile marked Not interested (weight 1, fading with a 365-day half-life) + and rated 1-2 stars (weight 0.6, on the taste half-life), the newest 200. + Everything is measured inside the pool being built, so the rule holds + whatever cosines an embedding model gives. A title counts for a pool only + when it is as near the pool's query (taste vector, centroid or source + title) as the pool's median candidate. It then pushes the candidates + nearer it than 90% of the pool, up to its whole weight 2 standard + deviations of the pool's similarities to it further on; pushes from + several titles combine as `1 − Π(1 − push)`. A candidate's score drops by + `2 × sd(pool scores) × aversion`, so a sequel of a marked title leaves the + served window while its genre-mates barely move, and marks in one area + add up. Each counting title also weighs against the pool's interest: an + anchor or cluster of weight `W` keeps `W / (W + 4 × Σ weights)` of its + share, its main-row slots are re-split by the reduced weights, and under + 0.3 the anchor leaves the main row and the cluster row is cached empty. + The anchors left fetch again for the slots they gained, so the row keeps + its length, and their shares are taken again from the wider pools. A + profile whose every anchor leaves gets a main row cached empty, rather + than one around the averaged taste vector, which would serve those + interests again; reads count the empty row as built. + The pushes only reorder the pool, so rows keep their length. A profile + with no turned-away title gets exactly the rows it would without the + step. Rows are not built while the turned-away titles, or a pool's + embeddings, cannot be read; an anchor that leaves the main row also leaves + the pool type supplements are drawn from. +- **Quality prior.** Before MMR a candidate's score becomes + `score + 0.5 × sd(pool scores) × clamp(z, −1, 1)`, where `z` standardizes + its catalog rating (trusted IMDb, else trusted TMDB) among the pool's rated + candidates of the same media type. A type with fewer than 10 rated + candidates or no spread in ratings is left alone, and cluster and Because + You Watched pools under 30 candidates get no prior. +- **Freshness.** Before MMR, on the main row's pools and the cluster pools, + the positive score of a title added in the last 14 days is multiplied by up + to 1.05, less the older it is. A pool with more than a quarter of its + candidates inside the window, such as a freshly imported library, gets no + boost, so it never ranks by scan order. The date is the title's own, so a + new episode of an older series does not count, and the boost cannot reach + a title its candidate query did not retrieve. +- **Labels.** A cluster's label names the genres that set it apart: those + that at least 40% of its titles carry, and carry at least 1.5 times as + often as the profile's positive titles do, most distinctive first, with + its most common genre (a tie goes to the genre of the heavier titles) + among them when at least 40% of its titles carry it, at most two in all. + Genres are joined with ", ", since TMDB genres such as "Sci-Fi & + Fantasy" contain "&". A cluster with no such genre, such as a profile's + only cluster, is labeled by its most common genre, and one whose most + common genre is on fewer than 40% of its titles, or whose titles carry no + genres, has an empty label, titled "Picked from your history". The dominant genres that drive retrieval, the + Discover genre exclusion and the taste-match section stay the top three + by count. The thresholds were tuned on synthetic profiles. +- **Repeated titles.** A read never shows two cluster rows under one title + it can tell apart. Visiting the heaviest cluster first, a row titled like + a heavier one is hidden when their first served items overlap by more + than half (Jaccard index), and otherwise takes the first of its dominant + genres its title does not name yet. A row whose cached title its cluster + no longer has keeps it. A hidden row was built, so it does not count as + missing. A main or cluster row's "see all" page reads the profile's page + rows the same way, for a 20-item window and the day's rotation, and takes + the title and order they give its row, so it opens with the titles of the + row it was opened from; a row they hide keeps its cached title and order. +- **Rotation.** Reads rotate the main row and the cluster rows daily, after + filtering and before trimming; Because You Watched, Similar Users, Watch + Tonight and the global and default rows stay in rank order. The served + window is 20 items whatever a read's limit, so a page of any limit, + Discover and a row's "see all" page agree on its order: a shorter page + serves the window's first titles. A row of at most 20 titles keeps its + order; in a longer one the first 10 titles stay, and the other + `take = 10` are drawn from the next `2 × take`, the title at tail + index `i` with weight `1/(i+5)`: the draw keeps the `take` largest + `ln(u)·(i+5)`, with `u` from `sha256(userID|profileID|rowKey|date|itemID)`. + The draws keep rank order and the titles not drawn follow them, so + nothing is lost. `rowKey` is the row's cache key, so a title two rows + share is drawn independently in each, and `date` is the UTC + `YYYY-MM-DD`, so rows change once a day at 00:00 UTC. The draw needs no + shared state: every node serves the same rotation, whatever its time + zone. Home and library sections rotate the main and taste-match rows + the same way. +- **Titles.** Row titles come from one vocabulary in + `internal/recommendations/titles.go`: For You, `Because you enjoy +