feat(web): add client-aware playback and reproducible UI (#433)

## Summary

- add a versioned playback client profile for browser/runtime protocol,
container, codec, header, proxy, insecure-media, and P2P-loader
capabilities
- generate compatible direct and proxy resources inside each provider
and return a structured incompatibility error when route policy leaves
no viable result
- force provider proxy delivery for browser-forbidden headers, including
affected Bilibili variants, while preserving explicit direct-only
failures
- keep legacy clients compatible and isolate capability-aware playback
cache entries
- serve `/oauth2/callback` through the same Flutter SPA entry point and
keep public discovery anonymous

## Reproducible Web UI

- move Flutter acquisition/build, source configuration, asset manifests,
Brotli/gzip compression, and compile-time embedding into the independent
`synctv-web-ui` crate
- support prebuilt distributions, local projects, and Git sources pinned
to an immutable full commit, with an ignored local override
- fingerprint source, Flutter version, build arguments, dart-defines,
builder generation, output, and compression settings
- validate cached Git repository/revision/commit identity, support
offline cache reuse, and rebuild only when relevant inputs change
- pin and checksum the Flutter SDK in the Docker Web asset stage;
backend-only builds require no Flutter, Git fetch, or network
- serve embedded assets with ETags, compression negotiation, CSP, cache
policy, Origin/CORS handling, and SPA fallback outside API/media routes

## Verification

- `cargo fmt --all -- --check`
- workspace `cargo check`, test/doc-test, and all-targets Clippy with
warnings denied
- provider tests: 200 passed, 1 ignored
- OAuth core/API tests: 72 + 16 passed
- `synctv-web-ui`: 10 passed
- `synctv-api-http --features web-ui`: 12 passed
- default Git source cold build from the pinned App commit, followed by
offline hot-cache reuse in about 1.1 seconds
- `cargo check -p synctv --features web-ui` and `docker buildx build
--check .`
- real Chrome playback, P2P, OAuth/Casdoor, multi-user sync, chat,
media, playlist, upload, settings, and playback-history flows

Companion frontend PR: https://github.com/synctv-org/synctv-app/pull/52

Release pin validation:
https://github.com/synctv-org/synctv-release/pull/9
pull/436/head
zijiren 1 month ago committed by GitHub
parent 9936e555d7
commit 63c370876d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -18,4 +18,6 @@ helm/
scripts/ scripts/
.env .env
.env.* .env.*
**/*.local
**/*.local.*
synctv.yaml synctv.yaml

@ -31,8 +31,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21
components: rustfmt components: rustfmt
- name: Check formatting - name: Check formatting
@ -46,8 +47,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with: with:
toolchain: nightly-2026-08-21
components: clippy components: clippy
- name: Install build dependencies - name: Install build dependencies
@ -69,7 +71,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
@ -88,7 +92,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
@ -108,7 +114,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev
@ -134,7 +142,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install cargo-nextest - name: Install cargo-nextest
uses: taiki-e/install-action@nextest uses: taiki-e/install-action@nextest
@ -181,7 +191,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install cargo-nextest - name: Install cargo-nextest
uses: taiki-e/install-action@nextest uses: taiki-e/install-action@nextest
@ -205,7 +217,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install cargo-audit - name: Install cargo-audit
run: make install-cargo-audit run: make install-cargo-audit
@ -226,7 +240,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install cargo-deny - name: Install cargo-deny
run: make install-cargo-deny run: make install-cargo-deny
@ -258,7 +274,9 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust nightly - name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev

@ -57,10 +57,73 @@ jobs:
echo 'EOF' echo 'EOF'
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
web-ui:
name: Build Web UI
runs-on: ubuntu-24.04
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
artifact-digest: ${{ steps.upload.outputs.artifact-digest }}
env:
CARGO_INCREMENTAL: "0"
CARGO_TARGET_DIR: ${{ github.workspace }}/target/web-ui-cargo
SYNCTV_WEB_CACHE_DIR: ${{ github.workspace }}/target/web-ui-cache
SYNCTV_WEB_CONFIG: synctv-web-ui/web-ui.production.toml
WEB_UI_EXPORT_DIR: ${{ github.workspace }}/synctv-web-ui/dist
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with:
channel: stable
flutter-version: 3.44.8
cache: true
- name: Cache Web build inputs
uses: actions/cache@v5
with:
path: |
~/.pub-cache
target/web-ui-cache
key: web-ui-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'synctv-web-ui/build.rs', 'synctv-web-ui/src/build_support.rs', 'synctv-web-ui/web-ui.production.toml') }}
restore-keys: |
web-ui-${{ runner.os }}-
- name: Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target/web-ui-cargo"
- name: Build Web distribution
run: |
cargo clean -p synctv-web-ui
make web-ui-build
test -f synctv-web-ui/dist/index.html
- name: Upload Web distribution
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: synctv-web-ui
path: synctv-web-ui/dist
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 1
docker-build: docker-build:
name: Docker Build name: Docker Build
needs: docker-prepare needs:
- docker-prepare
- web-ui
permissions: permissions:
actions: read
contents: read contents: read
packages: write packages: write
id-token: write id-token: write
@ -81,6 +144,9 @@ jobs:
cache-scope: synctv-image cache-scope: synctv-image
build-args: | build-args: |
SYNCTV_CARGO_BUILD_PROFILE=${{ github.event_name == 'pull_request' && 'dev' || 'release' }} SYNCTV_CARGO_BUILD_PROFILE=${{ github.event_name == 'pull_request' && 'dev' || 'release' }}
SYNCTV_BUILD_FEATURES=k8s,mimalloc,openapi,web-ui
SYNCTV_WEB_ARTIFACT_URL=https://api.github.com/repos/${{ github.repository }}/actions/artifacts/${{ needs.web-ui.outputs.artifact-id }}/zip
SYNCTV_WEB_ARTIFACT_DIGEST=${{ needs.web-ui.outputs.artifact-digest }}
CARGO_INCREMENTAL=0 CARGO_INCREMENTAL=0
CARGO_TERM_COLOR=always CARGO_TERM_COLOR=always
set-meta-annotations: true set-meta-annotations: true

@ -49,7 +49,9 @@ jobs:
uses: azure/setup-helm@v4 uses: azure/setup-helm@v4
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev

@ -39,7 +39,9 @@ jobs:
uses: azure/setup-helm@v4 uses: azure/setup-helm@v4
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Install build dependencies - name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev

@ -28,7 +28,9 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-08-21
- name: Normalize release version - name: Normalize release version
id: version id: version

5
.gitignore vendored

@ -20,4 +20,9 @@ logs/
.env.synctv .env.synctv
synctv.yaml synctv.yaml
# Local Web UI source/build overrides
synctv-web-ui/web-ui.local.toml
synctv-web-ui/dist/*
!synctv-web-ui/dist/.gitkeep
AGENTS.md AGENTS.md

@ -7858,6 +7858,7 @@ dependencies = [
"synctv-proto", "synctv-proto",
"synctv-proxy", "synctv-proxy",
"synctv-realtime", "synctv-realtime",
"synctv-web-ui",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@ -8325,6 +8326,19 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "synctv-web-ui"
version = "1.0.3"
dependencies = [
"brotli",
"flate2",
"hex",
"serde",
"sha2 0.11.0",
"tempfile",
"toml",
]
[[package]] [[package]]
name = "synctv-xiu" name = "synctv-xiu"
version = "1.0.3" version = "1.0.3"

@ -15,6 +15,7 @@ members = [
"synctv-api", "synctv-api",
"synctv-api-common", "synctv-api-common",
"synctv-api-http", "synctv-api-http",
"synctv-web-ui",
"synctv-api-grpc", "synctv-api-grpc",
"synctv-livestream", "synctv-livestream",
"synctv-cluster", "synctv-cluster",
@ -379,6 +380,7 @@ synctv-adapter = { path = "synctv-adapter", default-features = false }
synctv-api = { path = "synctv-api", default-features = false } synctv-api = { path = "synctv-api", default-features = false }
synctv-api-common = { path = "synctv-api-common", default-features = false } synctv-api-common = { path = "synctv-api-common", default-features = false }
synctv-api-http = { path = "synctv-api-http", default-features = false } synctv-api-http = { path = "synctv-api-http", default-features = false }
synctv-web-ui = { path = "synctv-web-ui" }
synctv-api-grpc = { path = "synctv-api-grpc", default-features = false } synctv-api-grpc = { path = "synctv-api-grpc", default-features = false }
synctv-cluster = { path = "synctv-cluster", default-features = false } synctv-cluster = { path = "synctv-cluster", default-features = false }
synctv-realtime = { path = "synctv-realtime", default-features = false } synctv-realtime = { path = "synctv-realtime", default-features = false }

@ -1,4 +1,4 @@
# Stage 1: Build # Stage 1: Build the backend for the target platform.
FROM rust:slim-trixie AS builder FROM rust:slim-trixie AS builder
# Install build dependencies # Install build dependencies
@ -9,10 +9,12 @@ RUN apt-get update && apt-get install -y \
protobuf-compiler \ protobuf-compiler \
pkg-config \ pkg-config \
build-essential \ build-essential \
lld \
libclang-dev \ libclang-dev \
nasm \ nasm \
cmake \ cmake \
curl \ curl \
unzip \
perl \ perl \
perl-modules-5.40 && rm -rf /var/lib/apt/lists/* perl-modules-5.40 && rm -rf /var/lib/apt/lists/*
@ -32,6 +34,8 @@ ARG SYNCTV_BUILD_NO_DEFAULT_FEATURES=false
ARG SYNCTV_BUILD_FEATURES="k8s,mimalloc,openapi" ARG SYNCTV_BUILD_FEATURES="k8s,mimalloc,openapi"
ARG SYNCTV_CARGO_BUILD_ARGS="" ARG SYNCTV_CARGO_BUILD_ARGS=""
ARG SYNCTV_CARGO_BUILD_PROFILE=release ARG SYNCTV_CARGO_BUILD_PROFILE=release
ARG SYNCTV_WEB_ARTIFACT_URL=""
ARG SYNCTV_WEB_ARTIFACT_DIGEST=""
ARG CARGO_INCREMENTAL=0 ARG CARGO_INCREMENTAL=0
ARG CARGO_TERM_COLOR="auto" ARG CARGO_TERM_COLOR="auto"
ARG TARGETARCH ARG TARGETARCH
@ -43,10 +47,39 @@ ENV CARGO_TERM_COLOR=$CARGO_TERM_COLOR
# Copy entire source tree # Copy entire source tree
COPY . . COPY . .
# CI passes a prebuilt Web distribution. Local builds use synctv-web-ui/dist.
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
if [ -n "$SYNCTV_WEB_ARTIFACT_URL" ]; then \
test -s /run/secrets/GIT_AUTH_TOKEN || { \
echo "GIT_AUTH_TOKEN is required to download the Web UI artifact" >&2; \
exit 1; \
}; \
[ "${#SYNCTV_WEB_ARTIFACT_DIGEST}" -eq 64 ] && \
case "$SYNCTV_WEB_ARTIFACT_DIGEST" in \
*[!0-9a-f]*) false ;; \
*) true ;; \
esac || { \
echo "SYNCTV_WEB_ARTIFACT_DIGEST must be a lowercase SHA-256 digest" >&2; \
exit 1; \
}; \
curl --fail --location --retry 3 --retry-all-errors \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer $(cat /run/secrets/GIT_AUTH_TOKEN)" \
--header "X-GitHub-Api-Version: 2022-11-28" \
--output /tmp/synctv-web-ui.zip \
"$SYNCTV_WEB_ARTIFACT_URL"; \
echo "$SYNCTV_WEB_ARTIFACT_DIGEST /tmp/synctv-web-ui.zip" | sha256sum --check -; \
find synctv-web-ui/dist -mindepth 1 -delete; \
unzip -q /tmp/synctv-web-ui.zip -d synctv-web-ui/dist; \
rm /tmp/synctv-web-ui.zip; \
test -f synctv-web-ui/dist/index.html; \
fi
# Build with cache mounts for cargo registry, git deps, and target directory # Build with cache mounts for cargo registry, git deps, and target directory
# Copy binary out of cache mount before RUN completes # Copy binary out of cache mount before RUN completes
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,id=synctv-rustup-${TARGETARCH},target=/usr/local/rustup,sharing=locked \
--mount=type=cache,id=synctv-target-${SYNCTV_CARGO_BUILD_PROFILE}-${TARGETARCH},target=/app/target,sharing=locked \ --mount=type=cache,id=synctv-target-${SYNCTV_CARGO_BUILD_PROFILE}-${TARGETARCH},target=/app/target,sharing=locked \
case "$SYNCTV_CARGO_BUILD_PROFILE" in \ case "$SYNCTV_CARGO_BUILD_PROFILE" in \
dev) target_profile_dir=debug ;; \ dev) target_profile_dir=debug ;; \
@ -63,8 +96,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
if [ -n "$SYNCTV_BUILD_FEATURES" ]; then \ if [ -n "$SYNCTV_BUILD_FEATURES" ]; then \
build_flags="$build_flags --features $SYNCTV_BUILD_FEATURES"; \ build_flags="$build_flags --features $SYNCTV_BUILD_FEATURES"; \
fi; \ fi; \
cargo +nightly \ RUSTFLAGS="-Clink-arg=-fuse-ld=lld -Clink-arg=-Wl,-z,pack-relative-relocs" \
--config 'build.rustflags=["-Clink-arg=-Wl,-z,pack-relative-relocs"]' \ cargo \
build $build_flags \ build $build_flags \
--bin synctv && \ --bin synctv && \
cp "target/$target_profile_dir/synctv" /synctv cp "target/$target_profile_dir/synctv" /synctv

@ -7,9 +7,8 @@ COMPOSE ?= docker compose
PROD_COMPOSE_FILE ?= docker-compose.yml PROD_COMPOSE_FILE ?= docker-compose.yml
COMPOSE_PROD := $(COMPOSE) -f $(PROD_COMPOSE_FILE) COMPOSE_PROD := $(COMPOSE) -f $(PROD_COMPOSE_FILE)
COMPOSE_ENV_FILES := .env.postgres .env.redis .env.synctv COMPOSE_ENV_FILES := .env.postgres .env.redis .env.synctv
RUST_TOOLCHAIN ?= nightly CARGO ?= cargo
CARGO ?= cargo +$(RUST_TOOLCHAIN) CROSS ?= cargo cross
CROSS ?= cargo cross +$(RUST_TOOLCHAIN)
CARGO_LOCKED ?= --locked CARGO_LOCKED ?= --locked
CARGO_WORKSPACE_ARGS ?= --workspace CARGO_WORKSPACE_ARGS ?= --workspace
CARGO_ALL_TARGETS_ARGS ?= --all-targets CARGO_ALL_TARGETS_ARGS ?= --all-targets
@ -118,7 +117,7 @@ export SYNCTV_MANAGEMENT_TRANSPORT=unix; \
export SYNCTV_MANAGEMENT_UNIX_SOCKET_PATH="$(DEV_SOCKET)" export SYNCTV_MANAGEMENT_UNIX_SOCKET_PATH="$(DEV_SOCKET)"
endef endef
.PHONY: help clean compose-init compose-config compose-pull compose-up compose-down compose-logs compose-ps dev-check dev-env dev-up dev-stack dev-build release-build dev-serve dev-down dev-clean dev-reset dev-data-reset dev-logs dev-ps dev-status dev-wait dev-shell dev-migrate dev-dropdb dev-db dev-redis dev-open dev-smoke fmt fmt-check check check-all-targets build-workspace proto-freshness feature-check feature-check-key-crates-tls-ring-webpki sqlx-prepare nextest nextest-default nextest-ignored doc-test clippy clippy-check install-cargo-audit audit audit-advisories install-cargo-deny deny-check deny-advisories deny-licenses deny-bans deny-sources install-cargo-udeps udeps cargo-workspace-version set-release-version validate-helm require-cross install-cross cross-linux-check cross-windows-check cross-darwin-check cross-linux-clippy cross-windows-clippy cross-darwin-clippy .PHONY: help clean compose-init compose-config compose-pull compose-up compose-down compose-logs compose-ps dev-check dev-env dev-up dev-stack dev-build release-build web-ui-build web-release-build dev-serve dev-down dev-clean dev-reset dev-data-reset dev-logs dev-ps dev-status dev-wait dev-shell dev-migrate dev-dropdb dev-db dev-redis dev-open dev-smoke fmt fmt-check check check-all-targets build-workspace proto-freshness feature-check feature-check-key-crates-tls-ring-webpki sqlx-prepare nextest nextest-default nextest-ignored doc-test clippy clippy-check install-cargo-audit audit audit-advisories install-cargo-deny deny-check deny-advisories deny-licenses deny-bans deny-sources install-cargo-udeps udeps cargo-workspace-version set-release-version validate-helm require-cross install-cross cross-linux-check cross-windows-check cross-darwin-check cross-linux-clippy cross-windows-clippy cross-darwin-clippy
help: ## Show available targets. help: ## Show available targets.
@awk 'BEGIN {FS = ":.*##"; printf "SyncTV targets:\n"} /^[a-zA-Z0-9_.-]+:.*##/ {printf " %-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @awk 'BEGIN {FS = ":.*##"; printf "SyncTV targets:\n"} /^[a-zA-Z0-9_.-]+:.*##/ {printf " %-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@ -187,7 +186,7 @@ dev-check: ## Check required local tools.
@$(COMPOSE) version >/dev/null @$(COMPOSE) version >/dev/null
@command -v rustup >/dev/null @command -v rustup >/dev/null
@$(CARGO) --version >/dev/null @$(CARGO) --version >/dev/null
@printf "Docker, Docker Compose, and Cargo %s are available.\n" "$(RUST_TOOLCHAIN)" @printf "Docker, Docker Compose, and Cargo are available.\n"
dev-env: ## Print local service URLs and credentials. dev-env: ## Print local service URLs and credentials.
@printf "SyncTV: http://127.0.0.1:8080 root / %s\n" "$(DEV_ROOT_PASSWORD)" @printf "SyncTV: http://127.0.0.1:8080 root / %s\n" "$(DEV_ROOT_PASSWORD)"
@ -219,6 +218,18 @@ dev-build: ## Build the local SyncTV binary.
release-build: ## Build the optimized SyncTV release binary. release-build: ## Build the optimized SyncTV release binary.
SQLX_OFFLINE=true $(CARGO) build $(CARGO_BUILD_ARGS) --release -p synctv --bin synctv $(RELEASE_CARGO_FEATURE_ARGS) SQLX_OFFLINE=true $(CARGO) build $(CARGO_BUILD_ARGS) --release -p synctv --bin synctv $(RELEASE_CARGO_FEATURE_ARGS)
WEB_UI_EXPORT_DIR ?= $(CURDIR)/target/web-ui-dist
web-ui-build: ## Build Web assets from synctv-web-ui config into WEB_UI_EXPORT_DIR.
SYNCTV_WEB_EXPORT_DIR="$(WEB_UI_EXPORT_DIR)" \
$(CARGO) check $(CARGO_BUILD_ARGS) -p synctv-web-ui --features embed
web-release-build: ## Build and embed Web UI using synctv-web-ui config. Set SYNCTV_WEB_CONFIG or use web-ui.local.toml.
@features="web-ui"; \
if [ -n "$(RELEASE_FEATURES)" ]; then features="$$features,$(RELEASE_FEATURES)"; fi; \
SQLX_OFFLINE=true \
$(CARGO) build $(CARGO_BUILD_ARGS) --release -p synctv --bin synctv --features "$$features"
dev-serve: dev-up ## Run SyncTV locally with development defaults. dev-serve: dev-up ## Run SyncTV locally with development defaults.
mkdir -p "$(DEV_DATA_DIR)/run" mkdir -p "$(DEV_DATA_DIR)/run"
@if [ -n "$(DEV_BIN)" ]; then \ @if [ -n "$(DEV_BIN)" ]; then \
@ -399,10 +410,10 @@ cargo-workspace-version: ## Print the Cargo workspace version.
set-release-version: ## Synchronize release files. Set VERSION=x.y.z. set-release-version: ## Synchronize release files. Set VERSION=x.y.z.
@test -n "$(VERSION)" || { printf "VERSION is required.\n" >&2; exit 1; } @test -n "$(VERSION)" || { printf "VERSION is required.\n" >&2; exit 1; }
RUSTUP_TOOLCHAIN="$(RUST_TOOLCHAIN)" scripts/set-release-version.sh "$(VERSION)" scripts/set-release-version.sh "$(VERSION)"
validate-helm: ## Validate Helm charts and rendered SyncTV configuration. validate-helm: ## Validate Helm charts and rendered SyncTV configuration.
RUSTUP_TOOLCHAIN="$(RUST_TOOLCHAIN)" scripts/validate-helm.sh scripts/validate-helm.sh
require-cross: require-cross:
@command -v cargo-cross >/dev/null || { printf "cargo-cross is required; run 'make install-cross'.\n" >&2; exit 1; } @command -v cargo-cross >/dev/null || { printf "cargo-cross is required; run 'make install-cross'.\n" >&2; exit 1; }

@ -88,6 +88,28 @@ Start locally:
cargo +nightly run -p synctv --bin synctv -- serve cargo +nightly run -p synctv --bin synctv -- serve
``` ```
### Embedded Web client
The optional `web-ui` feature embeds a Flutter Web distribution in the HTTP
server. The browser client always uses the page origin, so one deployed Web UI
belongs to one SyncTV server. Build the configured Web distribution and server:
```bash
make web-ui-build
make web-release-build
```
[`synctv-web-ui/README.md`](synctv-web-ui/README.md) documents prebuilt,
local-project, and immutable Git sources together with cache and offline
controls. The server provides SPA fallback, content types, ETags, Brotli/gzip
variants, cache policy, and CSP. OAuth uses the normal SPA entry point, while
provider verification uses its dedicated static page. API and media routes
remain outside the application-shell cache.
Keep the app and server protobuf snapshots aligned. Browser playback sends a
versioned `PlaybackClientProfile`; Providers use it with the configured proxy
policy to select direct or proxy routes before returning playback output.
## Documentation ## Documentation
Read the complete documentation at [docs.syncs.tv](https://docs.syncs.tv). Read the complete documentation at [docs.syncs.tv](https://docs.syncs.tv).

@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "nightly" channel = "nightly-2026-08-21"
profile = "minimal" profile = "minimal"
components = ["clippy", "rustfmt"] components = ["clippy", "rustfmt"]

@ -17,6 +17,7 @@ pub mod error_codes {
pub const CONFLICT: i32 = 2003; pub const CONFLICT: i32 = 2003;
pub const INVALID_ARGUMENT: i32 = 3000; pub const INVALID_ARGUMENT: i32 = 3000;
pub const FAILED_PRECONDITION: i32 = 3001;
pub const PERMISSION_DENIED: i32 = 4000; pub const PERMISSION_DENIED: i32 = 4000;
pub const FORBIDDEN: i32 = 4001; pub const FORBIDDEN: i32 = 4001;
@ -35,6 +36,7 @@ pub enum ErrorKind {
AlreadyExists, AlreadyExists,
Conflict, Conflict,
InvalidArgument, InvalidArgument,
FailedPrecondition,
RateLimited, RateLimited,
ServiceUnavailable, ServiceUnavailable,
Timeout, Timeout,
@ -51,6 +53,7 @@ impl ErrorKind {
Self::AlreadyExists => error_codes::ALREADY_EXISTS, Self::AlreadyExists => error_codes::ALREADY_EXISTS,
Self::Conflict => error_codes::CONFLICT, Self::Conflict => error_codes::CONFLICT,
Self::InvalidArgument => error_codes::INVALID_ARGUMENT, Self::InvalidArgument => error_codes::INVALID_ARGUMENT,
Self::FailedPrecondition => error_codes::FAILED_PRECONDITION,
Self::RateLimited => error_codes::RESOURCE_EXHAUSTED, Self::RateLimited => error_codes::RESOURCE_EXHAUSTED,
Self::ServiceUnavailable => error_codes::SERVICE_UNAVAILABLE, Self::ServiceUnavailable => error_codes::SERVICE_UNAVAILABLE,
Self::Timeout => error_codes::TIMEOUT, Self::Timeout => error_codes::TIMEOUT,
@ -79,6 +82,7 @@ pub fn classified_error_to_tonic_status(error: &impl ClassifiedError) -> tonic::
ErrorKind::AlreadyExists => tonic::Status::already_exists(message), ErrorKind::AlreadyExists => tonic::Status::already_exists(message),
ErrorKind::Conflict => tonic::Status::aborted(message), ErrorKind::Conflict => tonic::Status::aborted(message),
ErrorKind::InvalidArgument => tonic::Status::invalid_argument(message), ErrorKind::InvalidArgument => tonic::Status::invalid_argument(message),
ErrorKind::FailedPrecondition => tonic::Status::failed_precondition(message),
ErrorKind::RateLimited => tonic::Status::resource_exhausted(message), ErrorKind::RateLimited => tonic::Status::resource_exhausted(message),
ErrorKind::ServiceUnavailable => tonic::Status::unavailable(message), ErrorKind::ServiceUnavailable => tonic::Status::unavailable(message),
ErrorKind::Timeout => tonic::Status::deadline_exceeded(message), ErrorKind::Timeout => tonic::Status::deadline_exceeded(message),
@ -107,6 +111,7 @@ impl ClassifiedError for ProviderError {
| Self::InvalidCredentialType | Self::InvalidCredentialType
| Self::EncryptionRequired(_) | Self::EncryptionRequired(_)
| Self::JsonError(_) => ErrorKind::InvalidArgument, | Self::JsonError(_) => ErrorKind::InvalidArgument,
Self::ClientIncompatible { .. } => ErrorKind::FailedPrecondition,
Self::NotFound | Self::InstanceNotFound(_) | Self::CredentialNotFound(_) => { Self::NotFound | Self::InstanceNotFound(_) | Self::CredentialNotFound(_) => {
ErrorKind::NotFound ErrorKind::NotFound
} }
@ -145,6 +150,7 @@ impl ClassifiedError for ProviderError {
| Self::CredentialExpired(message) | Self::CredentialExpired(message)
| Self::RouteRegistrationFailed(message) | Self::RouteRegistrationFailed(message)
| Self::Internal(message) => Cow::Borrowed(message), | Self::Internal(message) => Cow::Borrowed(message),
Self::ClientIncompatible { reason, .. } => Cow::Borrowed(reason),
Self::NotFound => Cow::Borrowed(synctv_common::messages::RESOURCE_NOT_FOUND), Self::NotFound => Cow::Borrowed(synctv_common::messages::RESOURCE_NOT_FOUND),
Self::MissingInstance => Cow::Borrowed("Provider instance not configured"), Self::MissingInstance => Cow::Borrowed("Provider instance not configured"),
Self::AuthRequired => Cow::Borrowed(synctv_common::messages::AUTHENTICATION_REQUIRED), Self::AuthRequired => Cow::Borrowed(synctv_common::messages::AUTHENTICATION_REQUIRED),

@ -47,6 +47,16 @@ impl GoogleApiError {
operation.as_str().to_string(), operation.as_str().to_string(),
); );
} }
if let crate::impls::ApiError::ClientIncompatible {
required_capability: Some(required_capability),
..
} = err
{
metadata.insert(
"requiredCapability".to_string(),
required_capability.clone(),
);
}
let mut details = ErrorDetails::new(); let mut details = ErrorDetails::new();
details.set_error_info(classification.reason, ERROR_DOMAIN, metadata); details.set_error_info(classification.reason, ERROR_DOMAIN, metadata);
@ -195,6 +205,11 @@ impl ErrorClassification {
http_status: StatusCode::BAD_REQUEST, http_status: StatusCode::BAD_REQUEST,
reason: "INVALID_ARGUMENT", reason: "INVALID_ARGUMENT",
}, },
crate::impls::ErrorKind::FailedPrecondition => Self {
grpc_code: Code::FailedPrecondition,
http_status: StatusCode::PRECONDITION_FAILED,
reason: "CLIENT_INCOMPATIBLE",
},
crate::impls::ErrorKind::RateLimited => Self { crate::impls::ErrorKind::RateLimited => Self {
grpc_code: Code::ResourceExhausted, grpc_code: Code::ResourceExhausted,
http_status: StatusCode::TOO_MANY_REQUESTS, http_status: StatusCode::TOO_MANY_REQUESTS,
@ -356,4 +371,29 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn client_incompatible_uses_failed_precondition_and_capability_metadata() -> anyhow::Result<()>
{
let error = GoogleApiError::from_api_error(&crate::impls::ApiError::ClientIncompatible {
message: "Browser cannot attach the required media headers".to_string(),
required_capability: Some("custom_http_headers".to_string()),
});
assert_eq!(error.grpc_code, tonic::Code::FailedPrecondition);
assert_eq!(error.http_status, StatusCode::PRECONDITION_FAILED);
let json: serde_json::Value = serde_json::from_slice(&error.to_protojson_bytes()?)?;
let error_info = detail_by_type(&json, "google.rpc.ErrorInfo")
.ok_or_else(|| anyhow::anyhow!("missing ErrorInfo detail: {json}"))?;
assert_eq!(error_info["reason"], "CLIENT_INCOMPATIBLE");
assert_eq!(
error_info["metadata"]["requiredCapability"],
"custom_http_headers"
);
assert_eq!(
error_info["metadata"][ERROR_CODE_METADATA_KEY],
crate::impls::error_codes::FAILED_PRECONDITION.to_string()
);
Ok(())
}
} }

@ -726,6 +726,19 @@ mod tests {
assert_eq!(app_err.message(), "upstream provider down"); assert_eq!(app_err.message(), "upstream provider down");
} }
#[test]
fn test_from_provider_client_incompatible_maps_to_precondition_failed() {
let app_err = AppError::from(synctv_core::provider::ProviderError::ClientIncompatible {
reason: "Browser cannot attach the required media headers".to_string(),
required_capability: Some("custom_http_headers".to_string()),
});
assert_eq!(app_err.status(), StatusCode::PRECONDITION_FAILED);
assert_eq!(
app_err.api_error.code(),
crate::impls::error_codes::FAILED_PRECONDITION
);
}
#[test] #[test]
fn test_from_provider_upstream_http_404_maps_to_not_found() { fn test_from_provider_upstream_http_404_maps_to_not_found() {
let app_err = AppError::from(synctv_core::provider::ProviderError::UpstreamHttp { let app_err = AppError::from(synctv_core::provider::ProviderError::UpstreamHttp {

@ -2251,6 +2251,38 @@ pub fn playback_client_profile_from_proto(
}; };
let default_profile = synctv_core::provider::PlaybackClientProfile::default(); let default_profile = synctv_core::provider::PlaybackClientProfile::default();
let uses_legacy_defaults = profile.profile_version == 0;
if !matches!(profile.profile_version, 0 | 2) {
return Err(crate::impls::ApiError::InvalidInput(format!(
"Unsupported playback client profile version {}",
profile.profile_version
)));
}
let environment =
match synctv_proto::client::PlaybackClientEnvironment::try_from(profile.environment)
.map_err(|_| {
crate::impls::ApiError::InvalidInput(
"Unsupported playback client environment".to_string(),
)
})? {
synctv_proto::client::PlaybackClientEnvironment::Unspecified
if uses_legacy_defaults =>
{
default_profile.environment
}
synctv_proto::client::PlaybackClientEnvironment::Unspecified => {
return Err(crate::impls::ApiError::InvalidInput(
"Playback client environment is required for profile version 2".to_string(),
));
}
synctv_proto::client::PlaybackClientEnvironment::Native => {
synctv_core::provider::PlaybackClientEnvironment::Native
}
synctv_proto::client::PlaybackClientEnvironment::Web => {
synctv_core::provider::PlaybackClientEnvironment::Web
}
};
let stream_preference = let stream_preference =
match synctv_proto::client::PlaybackStreamPreference::try_from(profile.stream_preference) match synctv_proto::client::PlaybackStreamPreference::try_from(profile.stream_preference)
.map_err(|_| { .map_err(|_| {
@ -2270,7 +2302,9 @@ pub fn playback_client_profile_from_proto(
} }
}; };
let supported_video_codecs = if profile.supported_video_codecs.is_empty() { let supported_video_codecs = if uses_legacy_defaults
&& profile.supported_video_codecs.is_empty()
{
default_profile.supported_video_codecs.clone() default_profile.supported_video_codecs.clone()
} else { } else {
profile profile
@ -2301,7 +2335,7 @@ pub fn playback_client_profile_from_proto(
.collect::<Result<Vec<_>, _>>()? .collect::<Result<Vec<_>, _>>()?
}; };
let supported_containers = if profile.supported_containers.is_empty() { let supported_containers = if uses_legacy_defaults && profile.supported_containers.is_empty() {
default_profile.supported_containers.clone() default_profile.supported_containers.clone()
} else { } else {
profile profile
@ -2336,9 +2370,14 @@ pub fn playback_client_profile_from_proto(
"Unsupported playback audio capability".to_string(), "Unsupported playback audio capability".to_string(),
) )
})? { })? {
synctv_proto::client::PlaybackAudioCapability::Unspecified => { synctv_proto::client::PlaybackAudioCapability::Unspecified if uses_legacy_defaults => {
default_profile.audio_capability default_profile.audio_capability
} }
synctv_proto::client::PlaybackAudioCapability::Unspecified => {
return Err(crate::impls::ApiError::InvalidInput(
"Playback audio capability is required for profile version 2".to_string(),
));
}
synctv_proto::client::PlaybackAudioCapability::Stereo => { synctv_proto::client::PlaybackAudioCapability::Stereo => {
synctv_core::provider::PlaybackAudioCapability::Stereo synctv_core::provider::PlaybackAudioCapability::Stereo
} }
@ -2368,47 +2407,342 @@ pub fn playback_client_profile_from_proto(
} }
}; };
let supported_live_transports = if profile.supported_live_transports.is_empty() { let supported_live_transports =
default_profile.supported_live_transports.clone() if uses_legacy_defaults && profile.supported_live_transports.is_empty() {
} else { default_profile.supported_live_transports.clone()
profile } else {
.supported_live_transports profile
.iter() .supported_live_transports
.filter_map(|transport| { .iter()
Some( .filter_map(|transport| {
match synctv_proto::client::PlaybackLiveTransport::try_from(*transport) { Some(
Ok(synctv_proto::client::PlaybackLiveTransport::Unspecified) => { match synctv_proto::client::PlaybackLiveTransport::try_from(*transport) {
return None Ok(synctv_proto::client::PlaybackLiveTransport::Unspecified) => {
} return None
Ok(synctv_proto::client::PlaybackLiveTransport::Hls) => { }
Ok(synctv_core::provider::PlaybackLiveTransport::Hls) Ok(synctv_proto::client::PlaybackLiveTransport::Hls) => {
} Ok(synctv_core::provider::PlaybackLiveTransport::Hls)
Ok(synctv_proto::client::PlaybackLiveTransport::Flv) => { }
Ok(synctv_core::provider::PlaybackLiveTransport::Flv) Ok(synctv_proto::client::PlaybackLiveTransport::Flv) => {
} Ok(synctv_core::provider::PlaybackLiveTransport::Flv)
Err(_) => Err(crate::impls::ApiError::InvalidInput( }
"Unsupported playback live transport".to_string(), Err(_) => Err(crate::impls::ApiError::InvalidInput(
)), "Unsupported playback live transport".to_string(),
}, )),
) },
)
})
.collect::<Result<Vec<_>, _>>()?
};
let media_capabilities = profile
.media_capabilities
.iter()
.map(|capability| {
let transport =
match synctv_proto::client::PlaybackMediaTransport::try_from(capability.transport)
.map_err(|_| {
crate::impls::ApiError::InvalidInput(
"Unsupported playback media transport".to_string(),
)
})? {
synctv_proto::client::PlaybackMediaTransport::Unspecified => {
return Err(crate::impls::ApiError::InvalidInput(
"Playback media transport is required".to_string(),
));
}
synctv_proto::client::PlaybackMediaTransport::Progressive => {
synctv_core::provider::PlaybackMediaTransport::Progressive
}
synctv_proto::client::PlaybackMediaTransport::Hls => {
synctv_core::provider::PlaybackMediaTransport::Hls
}
synctv_proto::client::PlaybackMediaTransport::Dash => {
synctv_core::provider::PlaybackMediaTransport::Dash
}
synctv_proto::client::PlaybackMediaTransport::Flv => {
synctv_core::provider::PlaybackMediaTransport::Flv
}
synctv_proto::client::PlaybackMediaTransport::MpegTs => {
synctv_core::provider::PlaybackMediaTransport::MpegTs
}
};
let pipeline =
match synctv_proto::client::PlaybackMediaPipeline::try_from(capability.pipeline)
.map_err(|_| {
crate::impls::ApiError::InvalidInput(
"Unsupported playback media pipeline".to_string(),
)
})? {
synctv_proto::client::PlaybackMediaPipeline::Unspecified => {
return Err(crate::impls::ApiError::InvalidInput(
"Playback media pipeline is required".to_string(),
));
}
synctv_proto::client::PlaybackMediaPipeline::Native => {
synctv_core::provider::PlaybackMediaPipeline::Native
}
synctv_proto::client::PlaybackMediaPipeline::MediaSource => {
synctv_core::provider::PlaybackMediaPipeline::MediaSource
}
synctv_proto::client::PlaybackMediaPipeline::ManagedMediaSource => {
synctv_core::provider::PlaybackMediaPipeline::ManagedMediaSource
}
};
let container = capability
.container
.map(synctv_proto::client::PlaybackContainer::try_from)
.transpose()
.map_err(|_| {
crate::impls::ApiError::InvalidInput(
"Unsupported playback capability container".to_string(),
)
})?
.map(|container| match container {
synctv_proto::client::PlaybackContainer::Unspecified => {
Err(crate::impls::ApiError::InvalidInput(
"Playback capability container cannot be unspecified".to_string(),
))
}
synctv_proto::client::PlaybackContainer::Mp4 => {
Ok(synctv_core::provider::PlaybackContainer::Mp4)
}
synctv_proto::client::PlaybackContainer::Mkv => {
Ok(synctv_core::provider::PlaybackContainer::Mkv)
}
synctv_proto::client::PlaybackContainer::Webm => {
Ok(synctv_core::provider::PlaybackContainer::Webm)
}
})
.transpose()?;
let video_codec = capability
.video_codec
.map(synctv_proto::client::PlaybackVideoCodec::try_from)
.transpose()
.map_err(|_| {
crate::impls::ApiError::InvalidInput(
"Unsupported playback capability video codec".to_string(),
)
})?
.map(|codec| match codec {
synctv_proto::client::PlaybackVideoCodec::Unspecified => {
Err(crate::impls::ApiError::InvalidInput(
"Playback capability video codec cannot be unspecified".to_string(),
))
}
synctv_proto::client::PlaybackVideoCodec::H264 => {
Ok(synctv_core::provider::PlaybackVideoCodec::H264)
}
synctv_proto::client::PlaybackVideoCodec::Hevc => {
Ok(synctv_core::provider::PlaybackVideoCodec::Hevc)
}
synctv_proto::client::PlaybackVideoCodec::Vp9 => {
Ok(synctv_core::provider::PlaybackVideoCodec::Vp9)
}
synctv_proto::client::PlaybackVideoCodec::Av1 => {
Ok(synctv_core::provider::PlaybackVideoCodec::Av1)
}
})
.transpose()?;
let audio_codec = capability
.audio_codec
.map(synctv_proto::client::PlaybackAudioCodec::try_from)
.transpose()
.map_err(|_| {
crate::impls::ApiError::InvalidInput(
"Unsupported playback capability audio codec".to_string(),
)
})?
.map(|codec| match codec {
synctv_proto::client::PlaybackAudioCodec::Unspecified => {
Err(crate::impls::ApiError::InvalidInput(
"Playback capability audio codec cannot be unspecified".to_string(),
))
}
synctv_proto::client::PlaybackAudioCodec::Aac => {
Ok(synctv_core::provider::PlaybackAudioCodec::Aac)
}
synctv_proto::client::PlaybackAudioCodec::Mp3 => {
Ok(synctv_core::provider::PlaybackAudioCodec::Mp3)
}
synctv_proto::client::PlaybackAudioCodec::Opus => {
Ok(synctv_core::provider::PlaybackAudioCodec::Opus)
}
synctv_proto::client::PlaybackAudioCodec::Vorbis => {
Ok(synctv_core::provider::PlaybackAudioCodec::Vorbis)
}
synctv_proto::client::PlaybackAudioCodec::Ac3 => {
Ok(synctv_core::provider::PlaybackAudioCodec::Ac3)
}
synctv_proto::client::PlaybackAudioCodec::Eac3 => {
Ok(synctv_core::provider::PlaybackAudioCodec::Eac3)
}
synctv_proto::client::PlaybackAudioCodec::Flac => {
Ok(synctv_core::provider::PlaybackAudioCodec::Flac)
}
})
.transpose()?;
Ok(synctv_core::provider::PlaybackMediaCapability {
transport,
container,
video_codec,
audio_codec,
pipeline,
codec_string: capability.codec_string.clone(),
}) })
.collect::<Result<Vec<_>, _>>()? })
}; .collect::<Result<Vec<_>, crate::impls::ApiError>>()?;
Ok(Some(synctv_core::provider::PlaybackClientProfile { Ok(Some(synctv_core::provider::PlaybackClientProfile {
profile_version: profile.profile_version,
environment,
stream_preference, stream_preference,
max_streaming_bitrate: profile.max_streaming_bitrate, max_streaming_bitrate: profile.max_streaming_bitrate,
max_audio_channels: profile max_audio_channels: if uses_legacy_defaults {
.max_audio_channels profile
.or(default_profile.max_audio_channels), .max_audio_channels
.or(default_profile.max_audio_channels)
} else {
profile.max_audio_channels
},
supported_video_codecs, supported_video_codecs,
supported_containers, supported_containers,
audio_capability, audio_capability,
subtitle_preference, subtitle_preference,
supported_live_transports, supported_live_transports,
media_capabilities,
supports_custom_http_headers: if uses_legacy_defaults {
default_profile.supports_custom_http_headers
} else {
profile.supports_custom_http_headers
},
supports_provider_proxy: if uses_legacy_defaults {
default_profile.supports_provider_proxy
} else {
profile.supports_provider_proxy
},
supports_insecure_http_media: if uses_legacy_defaults {
default_profile.supports_insecure_http_media
} else {
profile.supports_insecure_http_media
},
})) }))
} }
#[cfg(test)]
mod playback_client_profile_conversion_tests {
use super::*;
use synctv_core::provider::{
PlaybackAudioCodec, PlaybackClientEnvironment, PlaybackContainer, PlaybackMediaPipeline,
PlaybackMediaTransport, PlaybackVideoCodec,
};
use synctv_proto::client as proto;
fn v2_profile() -> proto::PlaybackClientProfile {
proto::PlaybackClientProfile {
profile_version: 2,
environment: proto::PlaybackClientEnvironment::Web as i32,
audio_capability: proto::PlaybackAudioCapability::Stereo as i32,
..Default::default()
}
}
#[test]
fn v0_empty_capability_lists_restore_legacy_defaults() {
let converted =
playback_client_profile_from_proto(Some(&proto::PlaybackClientProfile::default()))
.expect("legacy profile should convert")
.expect("profile should be present");
let defaults = synctv_core::provider::PlaybackClientProfile::default();
assert_eq!(converted.environment, defaults.environment);
assert_eq!(
converted.supported_video_codecs,
defaults.supported_video_codecs
);
assert_eq!(
converted.supported_containers,
defaults.supported_containers
);
assert_eq!(
converted.supported_live_transports,
defaults.supported_live_transports
);
assert_eq!(
converted.supports_custom_http_headers,
defaults.supports_custom_http_headers
);
assert_eq!(
converted.supports_provider_proxy,
defaults.supports_provider_proxy
);
}
#[test]
fn v2_empty_capability_lists_remain_explicitly_empty() {
let converted = playback_client_profile_from_proto(Some(&v2_profile()))
.expect("v2 profile should convert")
.expect("profile should be present");
assert_eq!(converted.environment, PlaybackClientEnvironment::Web);
assert!(converted.supported_video_codecs.is_empty());
assert!(converted.supported_containers.is_empty());
assert!(converted.supported_live_transports.is_empty());
assert!(converted.media_capabilities.is_empty());
assert!(!converted.supports_custom_http_headers);
assert!(!converted.supports_provider_proxy);
}
#[test]
fn unsupported_profile_version_is_rejected() {
let mut profile = v2_profile();
profile.profile_version = 3;
let error = playback_client_profile_from_proto(Some(&profile))
.expect_err("unknown versions must fail closed");
assert!(matches!(
error,
crate::impls::ApiError::InvalidInput(message)
if message.contains("Unsupported playback client profile version 3")
));
}
#[test]
fn v2_exact_media_capability_combination_survives_conversion() {
let mut profile = v2_profile();
profile.media_capabilities = vec![proto::PlaybackMediaCapability {
transport: proto::PlaybackMediaTransport::Dash as i32,
container: Some(proto::PlaybackContainer::Mp4 as i32),
video_codec: Some(proto::PlaybackVideoCodec::Hevc as i32),
audio_codec: Some(proto::PlaybackAudioCodec::Eac3 as i32),
pipeline: proto::PlaybackMediaPipeline::ManagedMediaSource as i32,
codec_string: Some("hvc1.1.6.L93.B0,ec-3".to_string()),
}];
let converted = playback_client_profile_from_proto(Some(&profile))
.expect("exact profile should convert")
.expect("profile should be present");
let capability = converted
.media_capabilities
.first()
.expect("capability should be retained");
assert_eq!(capability.transport, PlaybackMediaTransport::Dash);
assert_eq!(capability.container, Some(PlaybackContainer::Mp4));
assert_eq!(capability.video_codec, Some(PlaybackVideoCodec::Hevc));
assert_eq!(capability.audio_codec, Some(PlaybackAudioCodec::Eac3));
assert_eq!(
capability.pipeline,
PlaybackMediaPipeline::ManagedMediaSource
);
assert_eq!(
capability.codec_string.as_deref(),
Some("hvc1.1.6.L93.B0,ec-3")
);
}
}
pub fn proto_role_to_room_role( pub fn proto_role_to_room_role(
role_i32: i32, role_i32: i32,
) -> Result<synctv_core::models::RoomRole, crate::impls::ApiError> { ) -> Result<synctv_core::models::RoomRole, crate::impls::ApiError> {
@ -5849,12 +6183,9 @@ mod playback_conversion_tests {
let key = signing_key(); let key = signing_key();
let signing = signing_context(&key); let signing = signing_context(&key);
let proto = try_playback_to_proto( let result = playback_result_with_mode("direct", info);
&playback_result_with_mode("direct", info), let proto = try_playback_to_proto(&result, &codec(), Some(&signing))
&codec(), .expect("playback should convert");
Some(&signing),
)
.expect("playback should convert");
let delivery = proto.playback_infos["direct"].medias[0] let delivery = proto.playback_infos["direct"].medias[0]
.p2p_delivery .p2p_delivery
.as_ref() .as_ref()
@ -5937,12 +6268,9 @@ mod playback_conversion_tests {
.build(); .build();
let key = signing_key(); let key = signing_key();
let signing = signing_context(&key); let signing = signing_context(&key);
let proto = try_playback_to_proto( let result = playback_result_with_mode("direct", info);
&playback_result_with_mode("direct", info), let proto = try_playback_to_proto(&result, &codec(), Some(&signing))
&codec(), .expect("playback should convert");
Some(&signing),
)
.expect("playback should convert");
let info = &proto.playback_infos["direct"]; let info = &proto.playback_infos["direct"];
let media = info.medias[0] let media = info.medias[0]
.p2p_delivery .p2p_delivery

@ -613,6 +613,7 @@ fn watch_playback_observe_builds_playback_resource_only() {
audio_capability: synctv_proto::client::PlaybackAudioCapability::Unspecified as i32, audio_capability: synctv_proto::client::PlaybackAudioCapability::Unspecified as i32,
subtitle_preference: synctv_proto::client::PlaybackSubtitlePreference::Unspecified subtitle_preference: synctv_proto::client::PlaybackSubtitlePreference::Unspecified
as i32, as i32,
..Default::default()
}), }),
}), }),
}) })

@ -427,6 +427,10 @@ pub enum ApiError {
message: String, message: String,
violations: Vec<ApiFieldViolation>, violations: Vec<ApiFieldViolation>,
}, },
ClientIncompatible {
message: String,
required_capability: Option<String>,
},
PayloadTooLarge(String), PayloadTooLarge(String),
RangeNotSatisfiable { RangeNotSatisfiable {
total_size: u64, total_size: u64,
@ -508,6 +512,13 @@ impl From<synctv_core::Error> for ApiError {
Self::Conflict("Resource modified concurrently".to_string()) Self::Conflict("Resource modified concurrently".to_string())
} }
synctv_core::Error::InvalidInput(msg) => Self::InvalidInput(msg), synctv_core::Error::InvalidInput(msg) => Self::InvalidInput(msg),
synctv_core::Error::ClientIncompatible {
reason,
required_capability,
} => Self::ClientIncompatible {
message: reason,
required_capability,
},
synctv_core::Error::RangeNotSatisfiable { total_size } => { synctv_core::Error::RangeNotSatisfiable { total_size } => {
Self::RangeNotSatisfiable { total_size } Self::RangeNotSatisfiable { total_size }
} }
@ -577,6 +588,13 @@ impl From<synctv_core::provider::ProviderError> for ApiError {
| ProviderError::InvalidUrl(msg) | ProviderError::InvalidUrl(msg)
| ProviderError::MissingField(msg) | ProviderError::MissingField(msg)
| ProviderError::UnsupportedFormat(msg) => Self::InvalidInput(msg), | ProviderError::UnsupportedFormat(msg) => Self::InvalidInput(msg),
ProviderError::ClientIncompatible {
reason,
required_capability,
} => Self::ClientIncompatible {
message: reason,
required_capability,
},
ProviderError::NotFound => { ProviderError::NotFound => {
Self::NotFound(synctv_common::messages::RESOURCE_NOT_FOUND.to_string()) Self::NotFound(synctv_common::messages::RESOURCE_NOT_FOUND.to_string())
} }
@ -627,6 +645,7 @@ impl ApiError {
Self::InvalidInput(_) Self::InvalidInput(_)
| Self::InvalidRequest { .. } | Self::InvalidRequest { .. }
| Self::RangeNotSatisfiable { .. } => ErrorKind::InvalidArgument, | Self::RangeNotSatisfiable { .. } => ErrorKind::InvalidArgument,
Self::ClientIncompatible { .. } => ErrorKind::FailedPrecondition,
Self::BadGateway(_) => ErrorKind::ServiceUnavailable, Self::BadGateway(_) => ErrorKind::ServiceUnavailable,
Self::RequestTimeout(_) | Self::Timeout(_) => ErrorKind::Timeout, Self::RequestTimeout(_) | Self::Timeout(_) => ErrorKind::Timeout,
Self::PayloadTooLarge(_) | Self::RateLimited(_) | Self::RateLimitedWithRetry { .. } => { Self::PayloadTooLarge(_) | Self::RateLimited(_) | Self::RateLimitedWithRetry { .. } => {
@ -668,7 +687,8 @@ impl ApiError {
| Self::ServiceUnavailable(msg) | Self::ServiceUnavailable(msg)
| Self::Timeout(msg) | Self::Timeout(msg)
| Self::Internal(msg) => msg, | Self::Internal(msg) => msg,
Self::RateLimitedWithRetry { message, .. } Self::ClientIncompatible { message, .. }
| Self::RateLimitedWithRetry { message, .. }
| Self::OAuth2InvalidState { message } | Self::OAuth2InvalidState { message }
| Self::OAuth2ProviderExchangeFailed { message, .. } | Self::OAuth2ProviderExchangeFailed { message, .. }
| Self::OAuth2MissingTargetUser { message, .. } | Self::OAuth2MissingTargetUser { message, .. }
@ -852,6 +872,10 @@ impl From<String> for ApiError {
ErrorKind::AlreadyExists => Self::AlreadyExists(msg), ErrorKind::AlreadyExists => Self::AlreadyExists(msg),
ErrorKind::Conflict => Self::Conflict(msg), ErrorKind::Conflict => Self::Conflict(msg),
ErrorKind::InvalidArgument => Self::InvalidInput(msg), ErrorKind::InvalidArgument => Self::InvalidInput(msg),
ErrorKind::FailedPrecondition => Self::ClientIncompatible {
message: msg,
required_capability: None,
},
ErrorKind::RateLimited => Self::RateLimited(msg), ErrorKind::RateLimited => Self::RateLimited(msg),
ErrorKind::ServiceUnavailable => Self::ServiceUnavailable(msg), ErrorKind::ServiceUnavailable => Self::ServiceUnavailable(msg),
ErrorKind::Timeout => Self::Timeout(msg), ErrorKind::Timeout => Self::Timeout(msg),

@ -23,7 +23,9 @@ use std::{collections::HashSet, sync::Arc};
use synctv_core::models::{OAuth2Provider, User, UserId, UserRole, UserStatus}; use synctv_core::models::{OAuth2Provider, User, UserId, UserRole, UserStatus};
use synctv_core::oauth2::OAuth2AuthorizationMode; use synctv_core::oauth2::OAuth2AuthorizationMode;
use synctv_core::provider::ExecutionControl; use synctv_core::provider::ExecutionControl;
use synctv_core::service::{OAuth2LinkResult, OAuth2Operation, OAuth2Service, UserService}; use synctv_core::service::{
OAuth2AuthorizationRequest, OAuth2LinkResult, OAuth2Operation, OAuth2Service, UserService,
};
use synctv_proto::client::{ use synctv_proto::client::{
ExchangeAuthorizationCodeRequest, ExchangeAuthorizationCodeResponse, ExchangeAuthorizationCodeRequest, ExchangeAuthorizationCodeResponse,
GetAuthorizationUrlForBindRequest, GetAuthorizationUrlForBindResponse, GetAuthorizationUrlForBindRequest, GetAuthorizationUrlForBindResponse,
@ -383,6 +385,7 @@ impl OAuth2ApiImpl {
pub async fn get_authorization_url_response_with_control( pub async fn get_authorization_url_response_with_control(
&self, &self,
req: GetAuthorizationUrlRequest, req: GetAuthorizationUrlRequest,
request_allowed_redirect_url: Option<String>,
control: Option<&ExecutionControl>, control: Option<&ExecutionControl>,
) -> Result<GetAuthorizationUrlResponse, ApiError> { ) -> Result<GetAuthorizationUrlResponse, ApiError> {
crate::impls::validate_proto_request(&req)?; crate::impls::validate_proto_request(&req)?;
@ -395,11 +398,14 @@ impl OAuth2ApiImpl {
let prepared = self let prepared = self
.oauth2_service .oauth2_service
.prepare_authorization_url_with_control( .prepare_authorization_url_with_control(
&req.provider, OAuth2AuthorizationRequest {
redirect_url, instance_name: &req.provider,
OAuth2Operation::Login, redirect_url,
None, request_allowed_redirect_url,
mode, operation: OAuth2Operation::Login,
target_user_id: None,
mode,
},
control, control,
) )
.await?; .await?;
@ -420,6 +426,7 @@ impl OAuth2ApiImpl {
&self, &self,
user_id: &UserId, user_id: &UserId,
req: GetAuthorizationUrlForBindRequest, req: GetAuthorizationUrlForBindRequest,
request_allowed_redirect_url: Option<String>,
control: Option<&ExecutionControl>, control: Option<&ExecutionControl>,
) -> Result<GetAuthorizationUrlForBindResponse, ApiError> { ) -> Result<GetAuthorizationUrlForBindResponse, ApiError> {
crate::impls::validate_proto_request(&req)?; crate::impls::validate_proto_request(&req)?;
@ -445,11 +452,14 @@ impl OAuth2ApiImpl {
let prepared = self let prepared = self
.oauth2_service .oauth2_service
.prepare_authorization_url_with_control( .prepare_authorization_url_with_control(
&req.provider, OAuth2AuthorizationRequest {
redirect_url, instance_name: &req.provider,
OAuth2Operation::Bind, redirect_url,
Some(*user_id), request_allowed_redirect_url,
mode, operation: OAuth2Operation::Bind,
target_user_id: Some(*user_id),
mode,
},
control, control,
) )
.await .await

@ -101,7 +101,11 @@ impl OAuth2Service for OAuth2GrpcService {
EndpointRateLimitCategory::Read, EndpointRateLimitCategory::Read,
move |request_control| async move { move |request_control| async move {
oauth2_api oauth2_api
.get_authorization_url_response_with_control(req, Some(&request_control)) .get_authorization_url_response_with_control(
req,
None,
Some(&request_control),
)
.await .await
}, },
) )
@ -144,6 +148,7 @@ impl OAuth2Service for OAuth2GrpcService {
.get_authorization_url_for_bind_response_with_control( .get_authorization_url_for_bind_response_with_control(
&authenticated.user_id(), &authenticated.user_id(),
req, req,
None,
Some(&request_control), Some(&request_control),
) )
.await .await

@ -7,6 +7,7 @@ license.workspace = true
[features] [features]
default = ["tls-aws-lc", "tls-webpki-roots"] default = ["tls-aws-lc", "tls-webpki-roots"]
web-ui = ["dep:synctv-web-ui", "synctv-web-ui/embed"]
openapi = [ openapi = [
"synctv-api-common/openapi", "synctv-api-common/openapi",
"synctv-proto/openapi", "synctv-proto/openapi",
@ -89,6 +90,7 @@ synctv-media-providers = { workspace = true, default-features = false }
synctv-livestream = { workspace = true, default-features = false } synctv-livestream = { workspace = true, default-features = false }
synctv-proto = { workspace = true, default-features = false, features = ["main", "providers", "playback-provider"] } synctv-proto = { workspace = true, default-features = false, features = ["main", "providers", "playback-provider"] }
synctv-common = { workspace = true, default-features = false } synctv-common = { workspace = true, default-features = false }
synctv-web-ui = { workspace = true, optional = true }
webauthn-rs.workspace = true webauthn-rs.workspace = true
webauthn-rs-proto.workspace = true webauthn-rs-proto.workspace = true

@ -172,23 +172,24 @@ pub async fn security_headers_middleware(request: Request, next: Next) -> Respon
} }
// Content Security Policy // Content Security Policy
// Default API responses should not grant broad media or framing privileges. // Keep the default restrictive while allowing the same-origin Web client to
// Routes that intentionally serve embeddable frontend/media content can set // render HTML media, MSE blobs, Flutter's generated styles, and its worker.
// their own CSP; this middleware preserves existing endpoint-specific // Endpoint-specific headers still take precedence over this default.
// headers.
if !headers.contains_key("Content-Security-Policy") { if !headers.contains_key("Content-Security-Policy") {
headers.insert( headers.insert(
CONTENT_SECURITY_POLICY.clone(), CONTENT_SECURITY_POLICY.clone(),
axum::http::HeaderValue::from_static( axum::http::HeaderValue::from_static(
"default-src 'self'; \ "default-src 'self'; \
media-src 'none'; \ media-src 'self' blob: https: http:; \
frame-src 'none'; \ frame-src 'self'; \
connect-src 'self' wss: ws:; \ connect-src 'self' blob: https: http: wss: ws:; \
img-src 'self' data: https:; \ img-src 'self' data: blob: https:; \
style-src 'self'; \ font-src 'self' data:; \
script-src 'self'; \ style-src 'self' 'unsafe-inline'; \
script-src 'self' 'wasm-unsafe-eval'; \
worker-src 'self' blob:; \
frame-ancestors 'none'; \ frame-ancestors 'none'; \
base-uri 'none'", base-uri 'self'",
), ),
); );
} }
@ -210,7 +211,8 @@ pub async fn security_headers_middleware(request: Request, next: Next) -> Respon
PERMISSIONS_POLICY.clone(), PERMISSIONS_POLICY.clone(),
axum::http::HeaderValue::from_static( axum::http::HeaderValue::from_static(
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), \ "accelerometer=(), camera=(), geolocation=(), gyroscope=(), \
magnetometer=(), microphone=(), payment=(), usb=()", magnetometer=(), microphone=(self), payment=(), \
picture-in-picture=(self), usb=()",
), ),
); );
} }
@ -345,11 +347,18 @@ mod tests {
let csp = header_str(response.headers(), "Content-Security-Policy")?; let csp = header_str(response.headers(), "Content-Security-Policy")?;
assert!(csp.contains("default-src 'self'")); assert!(csp.contains("default-src 'self'"));
assert!(csp.contains("media-src 'none'")); assert!(csp.contains("media-src 'self' blob: https: http:"));
assert!(csp.contains("frame-src 'none'")); assert!(csp.contains("frame-src 'self'"));
assert!(csp.contains("style-src 'self'")); assert!(csp.contains("connect-src 'self' blob: https: http: wss: ws:"));
assert!(csp.contains("style-src 'self' 'unsafe-inline'"));
assert!(csp.contains("script-src 'self' 'wasm-unsafe-eval'"));
assert!(csp.contains("worker-src 'self' blob:"));
assert!(csp.contains("frame-ancestors 'none'")); assert!(csp.contains("frame-ancestors 'none'"));
assert!(csp.contains("base-uri 'none'")); assert!(csp.contains("base-uri 'self'"));
let permissions = header_str(response.headers(), "Permissions-Policy")?;
assert!(permissions.contains("microphone=(self)"));
assert!(permissions.contains("picture-in-picture=(self)"));
Ok(()) Ok(())
} }

@ -25,19 +25,22 @@ pub(crate) mod room_extra;
pub(crate) mod ticket; pub(crate) mod ticket;
pub(crate) mod user; pub(crate) mod user;
pub(crate) mod validation; pub(crate) mod validation;
#[cfg(feature = "web-ui")]
pub(crate) mod web_ui;
pub(crate) mod webrtc; pub(crate) mod webrtc;
pub(crate) mod websocket; pub(crate) mod websocket;
use crate::providers; use crate::providers;
use axum::{ use axum::{
body::Body, body::Body,
extract::State,
http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
middleware as axum_middleware, middleware as axum_middleware,
response::{IntoResponse, Redirect}, response::IntoResponse,
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
#[cfg(not(feature = "web-ui"))]
use axum::{extract::State, response::Redirect};
use futures::StreamExt; use futures::StreamExt;
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use tower_http::compression::{ use tower_http::compression::{
@ -893,8 +896,18 @@ fn register_websocket_routes() -> Router<AppState> {
} }
fn register_all_routes() -> Router<AppState> { fn register_all_routes() -> Router<AppState> {
let mut router = Router::new() let mut router = Router::new();
.route("/", get(redirect_to_project)) #[cfg(feature = "web-ui")]
{
router = router
.route("/", get(web_ui::index))
.route("/{*webUiPath}", get(web_ui::fallback));
}
#[cfg(not(feature = "web-ui"))]
{
router = router.route("/", get(redirect_to_project));
}
router = router
.route( .route(
"/.well-known/apple-app-site-association", "/.well-known/apple-app-site-association",
get(native_app_association::apple_app_site_association), get(native_app_association::apple_app_site_association),
@ -1543,6 +1556,7 @@ fn register_all_routes() -> Router<AppState> {
router router
} }
#[cfg(not(feature = "web-ui"))]
async fn redirect_to_project(State(state): State<AppState>) -> Redirect { async fn redirect_to_project(State(state): State<AppState>) -> Redirect {
Redirect::temporary(&state.runtime_settings.server.project_url) Redirect::temporary(&state.runtime_settings.server.project_url)
} }
@ -1754,7 +1768,7 @@ fn forwarded_proto_is_https(
Ok(value.eq_ignore_ascii_case("https")) Ok(value.eq_ignore_ascii_case("https"))
} }
fn should_compress_json_response( fn should_compress_application_response(
_status: StatusCode, _status: StatusCode,
_version: axum::http::Version, _version: axum::http::Version,
headers: &HeaderMap, headers: &HeaderMap,
@ -1766,7 +1780,14 @@ fn should_compress_json_response(
.and_then(|value| value.split(';').next()) .and_then(|value| value.split(';').next())
.map(str::trim) .map(str::trim)
.is_some_and(|media_type| { .is_some_and(|media_type| {
media_type.eq_ignore_ascii_case("application/json") || media_type.ends_with("+json") media_type.starts_with("text/")
|| media_type.eq_ignore_ascii_case("application/json")
|| media_type.ends_with("+json")
|| media_type.eq_ignore_ascii_case("application/javascript")
|| media_type.eq_ignore_ascii_case("application/wasm")
|| media_type.eq_ignore_ascii_case("application/xml")
|| media_type.ends_with("+xml")
|| media_type.eq_ignore_ascii_case("image/svg+xml")
}) })
} }
@ -1785,7 +1806,9 @@ fn apply_shared_http_layers(
.br(true) .br(true)
.gzip(true) .gzip(true)
.zstd(true) .zstd(true)
.compress_when(DefaultPredicate::default().and(should_compress_json_response)), .compress_when(
DefaultPredicate::default().and(should_compress_application_response),
),
) )
.layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024))
.layer(axum_middleware::from_fn(middleware::request_id_middleware)) .layer(axum_middleware::from_fn(middleware::request_id_middleware))

@ -23,6 +23,7 @@
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::HeaderMap,
Json, Json,
}; };
use std::sync::Arc; use std::sync::Arc;
@ -135,6 +136,53 @@ fn map_oauth2_exchange_error(error: synctv_api_common::impls::ApiError) -> AppEr
map_api_error(error) map_api_error(error)
} }
fn request_allowed_web_callback(
redirect_url: Option<&str>,
native: Option<bool>,
headers: &HeaderMap,
direct_peer_ip: Option<std::net::IpAddr>,
server: &synctv_api_common::ApiServerSettings,
) -> AppResult<Option<String>> {
let Some(redirect_url) = redirect_url.filter(|_| native != Some(true)) else {
return Ok(None);
};
let Ok(parsed) = url::Url::parse(redirect_url) else {
return Ok(None);
};
if parsed.path() != "/oauth2/callback"
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
{
return Ok(None);
}
let request_scheme = if direct_peer_ip.is_some_and(|ip| server.is_trusted_proxy(&ip)) {
match super::optional_header_str(headers, &super::X_FORWARDED_PROTO)? {
Some(value) if value.eq_ignore_ascii_case("http") => "http",
Some(value) if value.eq_ignore_ascii_case("https") => "https",
Some(_) => {
return Err(AppError::bad_request(
"x-forwarded-proto must be http or https",
));
}
None => "http",
}
} else {
"http"
};
if !parsed.scheme().eq_ignore_ascii_case(request_scheme) {
return Ok(None);
}
let host = super::required_header_str(headers, "host", "Host header is required")?;
Ok(
super::websocket::same_origin_as_host(&parsed, host, Some(request_scheme))?
.then(|| redirect_url.to_string()),
)
}
/// Get `OAuth2` authorization URL for login flow /// Get `OAuth2` authorization URL for login flow
/// ///
/// GET /`api/oauth2/:provider/authorize?redirectUrl`=<url> /// GET /`api/oauth2/:provider/authorize?redirectUrl`=<url>
@ -159,9 +207,17 @@ pub async fn get_authorize_url(
State(state): State<AppState>, State(state): State<AppState>,
Path(path): Path<OAuth2ProviderInstancePathRequest>, Path(path): Path<OAuth2ProviderInstancePathRequest>,
Query(query): Query<AuthorizationUrlQuery>, Query(query): Query<AuthorizationUrlQuery>,
headers: HeaderMap,
) -> AppResult<Json<GetAuthorizationUrlResponse>> { ) -> AppResult<Json<GetAuthorizationUrlResponse>> {
let oauth2_api = require_oauth2_api(&state)?; let oauth2_api = require_oauth2_api(&state)?;
let req = query.into_request(path.provider); let req = query.into_request(path.provider);
let request_allowed_redirect_url = request_allowed_web_callback(
req.redirect_url.as_deref(),
req.native,
&headers,
request_meta.0.socket_ip,
&state.runtime_settings.server,
)?;
let provider_for_log = req.provider.clone(); let provider_for_log = req.provider.clone();
let request_meta = request_meta.0; let request_meta = request_meta.0;
@ -173,7 +229,11 @@ pub async fn get_authorize_url(
EndpointRateLimitCategory::Read, EndpointRateLimitCategory::Read,
move |request_control| async move { move |request_control| async move {
oauth2_api oauth2_api
.get_authorization_url_response_with_control(req, Some(&request_control)) .get_authorization_url_response_with_control(
req,
request_allowed_redirect_url,
Some(&request_control),
)
.await .await
}, },
) )
@ -294,9 +354,17 @@ pub async fn get_bind_authorize_url(
State(state): State<AppState>, State(state): State<AppState>,
Path(path): Path<OAuth2ProviderInstancePathRequest>, Path(path): Path<OAuth2ProviderInstancePathRequest>,
Query(query): Query<BindAuthorizationUrlQuery>, Query(query): Query<BindAuthorizationUrlQuery>,
headers: HeaderMap,
) -> AppResult<Json<GetAuthorizationUrlForBindResponse>> { ) -> AppResult<Json<GetAuthorizationUrlForBindResponse>> {
let oauth2_api = require_oauth2_api(&state)?; let oauth2_api = require_oauth2_api(&state)?;
let req = query.into_request(path.provider); let req = query.into_request(path.provider);
let request_allowed_redirect_url = request_allowed_web_callback(
req.redirect_url.as_deref(),
req.native,
&headers,
request_meta.0.socket_ip,
&state.runtime_settings.server,
)?;
let provider_for_log = req.provider.clone(); let provider_for_log = req.provider.clone();
let request_meta = request_meta.0; let request_meta = request_meta.0;
@ -311,6 +379,7 @@ pub async fn get_bind_authorize_url(
.get_authorization_url_for_bind_response_with_control( .get_authorization_url_for_bind_response_with_control(
&authenticated.user_id(), &authenticated.user_id(),
req, req,
request_allowed_redirect_url,
Some(&request_control), Some(&request_control),
) )
.await .await
@ -480,7 +549,7 @@ pub async fn get_linked_providers(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use axum::http::StatusCode; use axum::http::{header, HeaderMap, StatusCode};
type TestResult<T = ()> = anyhow::Result<T>; type TestResult<T = ()> = anyhow::Result<T>;
@ -539,4 +608,106 @@ mod tests {
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(err.message(), "OAuth2 is not available on this server."); assert_eq!(err.message(), "OAuth2 is not available on this server.");
} }
fn callback_headers(host: &str) -> TestResult<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, host.parse()?);
Ok(headers)
}
#[test]
fn same_origin_web_callback_is_request_allowed() -> TestResult {
let mut server = synctv_api_common::ApiServerSettings::default();
server.trusted_proxies = vec!["127.0.0.1".to_string()];
let mut headers = callback_headers("app.example.test")?;
headers.insert("x-forwarded-proto", "https".parse()?);
let allowed = request_allowed_web_callback(
Some("https://app.example.test/oauth2/callback"),
Some(false),
&headers,
Some("127.0.0.1".parse()?),
&server,
)?;
assert_eq!(
allowed.as_deref(),
Some("https://app.example.test/oauth2/callback")
);
Ok(())
}
#[test]
fn web_callback_requires_exact_origin_and_path() -> TestResult {
let mut server = synctv_api_common::ApiServerSettings::default();
server.trusted_proxies = vec!["127.0.0.1".to_string()];
let mut headers = callback_headers("app.example.test:8443")?;
headers.insert("x-forwarded-proto", "https".parse()?);
let peer = Some("127.0.0.1".parse()?);
for redirect in [
"https://evil.example.test:8443/oauth2/callback",
"https://app.example.test/oauth2/callback",
"https://app.example.test:8443/auth.html",
"https://app.example.test:8443/oauth2/callback?next=/rooms",
"https://app.example.test:8443/oauth2/callback#fragment",
] {
assert_eq!(
request_allowed_web_callback(Some(redirect), Some(false), &headers, peer, &server,)?,
None,
"unexpectedly allowed {redirect}",
);
}
Ok(())
}
#[test]
fn native_and_untrusted_forwarded_callbacks_are_not_request_allowed() -> TestResult {
let server = synctv_api_common::ApiServerSettings::default();
let mut headers = callback_headers("app.example.test")?;
headers.insert("x-forwarded-proto", "https".parse()?);
let redirect = Some("https://app.example.test/oauth2/callback");
assert_eq!(
request_allowed_web_callback(
redirect,
Some(true),
&headers,
Some("127.0.0.1".parse()?),
&server,
)?,
None,
);
assert_eq!(
request_allowed_web_callback(
redirect,
Some(false),
&headers,
Some("198.51.100.10".parse()?),
&server,
)?,
None,
);
Ok(())
}
#[test]
fn trusted_proxy_callback_rejects_invalid_forwarded_proto() -> TestResult {
let mut server = synctv_api_common::ApiServerSettings::default();
server.trusted_proxies = vec!["127.0.0.1".to_string()];
let mut headers = callback_headers("app.example.test")?;
headers.insert("x-forwarded-proto", "javascript".parse()?);
let error = request_allowed_web_callback(
Some("https://app.example.test/oauth2/callback"),
Some(false),
&headers,
Some("127.0.0.1".parse()?),
&server,
)
.expect_err("invalid proxy scheme must fail");
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
Ok(())
}
} }

@ -1,4 +1,6 @@
use axum::http::HeaderMap; use axum::http::HeaderMap;
use base64::Engine as _;
use prost::Message as _;
use super::AppResult; use super::AppResult;
use synctv_proto::client::{ChatMessageType, GetPlaybackRequest, ResourceDeliveryMode}; use synctv_proto::client::{ChatMessageType, GetPlaybackRequest, ResourceDeliveryMode};
@ -23,6 +25,9 @@ pub(super) fn validate_include_message_types(values: Vec<i32>) -> AppResult<Vec<
into_params(parameter_in = Query, rename_all = "camelCase") into_params(parameter_in = Query, rename_all = "camelCase")
)] )]
pub struct GetPlaybackQuery { pub struct GetPlaybackQuery {
/// Base64url-encoded PlaybackClientProfile protobuf. New clients use this
/// lossless representation; the remaining fields are legacy compatibility.
pub client_profile: Option<String>,
pub stream_preference: Option<i32>, pub stream_preference: Option<i32>,
pub max_streaming_bitrate: Option<i64>, pub max_streaming_bitrate: Option<i64>,
pub max_audio_channels: Option<i32>, pub max_audio_channels: Option<i32>,
@ -75,6 +80,7 @@ pub struct WatchPlaylistItemsQuery {
pub struct WatchPlaybackQuery { pub struct WatchPlaybackQuery {
pub delivery_mode: Option<i32>, pub delivery_mode: Option<i32>,
pub format: Option<String>, pub format: Option<String>,
pub client_profile: Option<String>,
pub stream_preference: Option<i32>, pub stream_preference: Option<i32>,
pub max_streaming_bitrate: Option<i64>, pub max_streaming_bitrate: Option<i64>,
pub max_audio_channels: Option<i32>, pub max_audio_channels: Option<i32>,
@ -225,10 +231,27 @@ fn parse_audio_capability(
.map_err(|_| super::super::AppError::bad_request("Invalid audioCapability enum integer")) .map_err(|_| super::super::AppError::bad_request("Invalid audioCapability enum integer"))
} }
fn decode_client_profile(
encoded: &str,
) -> Result<synctv_proto::client::PlaybackClientProfile, super::super::AppError> {
const MAX_ENCODED_PROFILE_BYTES: usize = 16 * 1024;
let encoded = encoded.trim();
if encoded.is_empty() || encoded.len() > MAX_ENCODED_PROFILE_BYTES {
return Err(super::super::AppError::bad_request(
"Invalid clientProfile length",
));
}
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| super::super::AppError::bad_request("Invalid clientProfile encoding"))?;
synctv_proto::client::PlaybackClientProfile::decode(bytes.as_slice())
.map_err(|_| super::super::AppError::bad_request("Invalid clientProfile protobuf"))
}
pub(crate) fn build_get_playback_request( pub(crate) fn build_get_playback_request(
query: &GetPlaybackQuery, query: &GetPlaybackQuery,
) -> AppResult<GetPlaybackRequest> { ) -> AppResult<GetPlaybackRequest> {
let has_profile = query.stream_preference.is_some() let has_legacy_profile = query.stream_preference.is_some()
|| query.max_streaming_bitrate.is_some() || query.max_streaming_bitrate.is_some()
|| query.max_audio_channels.is_some() || query.max_audio_channels.is_some()
|| query.video_codecs.is_some() || query.video_codecs.is_some()
@ -237,7 +260,15 @@ pub(crate) fn build_get_playback_request(
|| query.audio_capability.is_some() || query.audio_capability.is_some()
|| query.subtitle_preference.is_some(); || query.subtitle_preference.is_some();
let playback_client_profile = if has_profile { if query.client_profile.is_some() && has_legacy_profile {
return Err(super::super::AppError::bad_request(
"clientProfile cannot be combined with legacy playback profile parameters",
));
}
let playback_client_profile = if let Some(encoded) = query.client_profile.as_deref() {
Some(decode_client_profile(encoded)?)
} else if has_legacy_profile {
Some(synctv_proto::client::PlaybackClientProfile { Some(synctv_proto::client::PlaybackClientProfile {
stream_preference: parse_stream_preference(query.stream_preference)? as i32, stream_preference: parse_stream_preference(query.stream_preference)? as i32,
max_streaming_bitrate: query.max_streaming_bitrate, max_streaming_bitrate: query.max_streaming_bitrate,
@ -247,6 +278,7 @@ pub(crate) fn build_get_playback_request(
supported_live_transports: parse_live_transports(query.live_transports.as_deref())?, supported_live_transports: parse_live_transports(query.live_transports.as_deref())?,
audio_capability: parse_audio_capability(query.audio_capability)? as i32, audio_capability: parse_audio_capability(query.audio_capability)? as i32,
subtitle_preference: parse_subtitle_preference(query.subtitle_preference)? as i32, subtitle_preference: parse_subtitle_preference(query.subtitle_preference)? as i32,
..Default::default()
}) })
} else { } else {
None None
@ -262,6 +294,7 @@ pub(crate) fn build_playback_client_profile_from_watch_query(
query: &WatchPlaybackQuery, query: &WatchPlaybackQuery,
) -> AppResult<Option<synctv_proto::client::PlaybackClientProfile>> { ) -> AppResult<Option<synctv_proto::client::PlaybackClientProfile>> {
build_get_playback_request(&GetPlaybackQuery { build_get_playback_request(&GetPlaybackQuery {
client_profile: query.client_profile.clone(),
stream_preference: query.stream_preference, stream_preference: query.stream_preference,
max_streaming_bitrate: query.max_streaming_bitrate, max_streaming_bitrate: query.max_streaming_bitrate,
max_audio_channels: query.max_audio_channels, max_audio_channels: query.max_audio_channels,

@ -99,6 +99,7 @@ fn test_watch_after_event_sequence_rejects_non_utf8_last_event_id() -> TestResul
#[test] #[test]
fn test_build_get_playback_request_parses_generic_profile_query() -> TestResult { fn test_build_get_playback_request_parses_generic_profile_query() -> TestResult {
let request = app_ok(build_get_playback_request(&GetPlaybackQuery { let request = app_ok(build_get_playback_request(&GetPlaybackQuery {
client_profile: None,
stream_preference: Some(synctv_proto::client::PlaybackStreamPreference::Transcode as i32), stream_preference: Some(synctv_proto::client::PlaybackStreamPreference::Transcode as i32),
max_streaming_bitrate: Some(8_000_000), max_streaming_bitrate: Some(8_000_000),
max_audio_channels: Some(2), max_audio_channels: Some(2),
@ -172,6 +173,47 @@ fn test_build_get_playback_request_omits_profile_when_query_is_empty() -> TestRe
Ok(()) Ok(())
} }
#[test]
fn test_build_get_playback_request_decodes_versioned_profile() -> TestResult {
use base64::Engine as _;
use prost::Message as _;
let expected = synctv_proto::client::PlaybackClientProfile {
profile_version: 2,
environment: synctv_proto::client::PlaybackClientEnvironment::Web as i32,
media_capabilities: vec![synctv_proto::client::PlaybackMediaCapability {
transport: synctv_proto::client::PlaybackMediaTransport::Dash as i32,
container: Some(synctv_proto::client::PlaybackContainer::Mp4 as i32),
video_codec: Some(synctv_proto::client::PlaybackVideoCodec::H264 as i32),
audio_codec: Some(synctv_proto::client::PlaybackAudioCodec::Aac as i32),
pipeline: synctv_proto::client::PlaybackMediaPipeline::MediaSource as i32,
codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()),
}],
supports_provider_proxy: true,
..Default::default()
};
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(expected.encode_to_vec());
let request = app_ok(build_get_playback_request(&GetPlaybackQuery {
client_profile: Some(encoded),
..Default::default()
}))?;
assert_eq!(request.playback_client_profile, Some(expected));
Ok(())
}
#[test]
fn test_build_get_playback_request_rejects_mixed_profile_encodings() {
let error = build_get_playback_request(&GetPlaybackQuery {
client_profile: Some("AA".to_string()),
stream_preference: Some(1),
..Default::default()
})
.expect_err("profile encodings must be unambiguous");
assert!(error.message().contains("cannot be combined"), "{error:?}");
}
#[test] #[test]
fn test_handwritten_room_queries_ignore_unknown_fields() { fn test_handwritten_room_queries_ignore_unknown_fields() {
let playback_query = let playback_query =
@ -240,6 +282,7 @@ fn test_handwritten_room_queries_ignore_unknown_fields() {
#[test] #[test]
fn test_build_get_playback_request_rejects_invalid_video_codec() { fn test_build_get_playback_request_rejects_invalid_video_codec() {
let error = build_get_playback_request(&GetPlaybackQuery { let error = build_get_playback_request(&GetPlaybackQuery {
client_profile: None,
stream_preference: None, stream_preference: None,
max_streaming_bitrate: None, max_streaming_bitrate: None,
max_audio_channels: None, max_audio_channels: None,
@ -257,6 +300,7 @@ fn test_build_get_playback_request_rejects_invalid_video_codec() {
#[test] #[test]
fn test_build_get_playback_request_rejects_invalid_stream_preference() { fn test_build_get_playback_request_rejects_invalid_stream_preference() {
let error = build_get_playback_request(&GetPlaybackQuery { let error = build_get_playback_request(&GetPlaybackQuery {
client_profile: None,
stream_preference: Some(999), stream_preference: Some(999),
max_streaming_bitrate: None, max_streaming_bitrate: None,
max_audio_channels: None, max_audio_channels: None,
@ -274,6 +318,7 @@ fn test_build_get_playback_request_rejects_invalid_stream_preference() {
#[test] #[test]
fn test_build_get_playback_request_rejects_invalid_container() { fn test_build_get_playback_request_rejects_invalid_container() {
let error = build_get_playback_request(&GetPlaybackQuery { let error = build_get_playback_request(&GetPlaybackQuery {
client_profile: None,
stream_preference: None, stream_preference: None,
max_streaming_bitrate: None, max_streaming_bitrate: None,
max_audio_channels: None, max_audio_channels: None,
@ -291,6 +336,7 @@ fn test_build_get_playback_request_rejects_invalid_container() {
#[test] #[test]
fn test_build_get_playback_request_rejects_invalid_audio_capability() { fn test_build_get_playback_request_rejects_invalid_audio_capability() {
let error = build_get_playback_request(&GetPlaybackQuery { let error = build_get_playback_request(&GetPlaybackQuery {
client_profile: None,
stream_preference: None, stream_preference: None,
max_streaming_bitrate: None, max_streaming_bitrate: None,
max_audio_channels: None, max_audio_channels: None,
@ -308,6 +354,7 @@ fn test_build_get_playback_request_rejects_invalid_audio_capability() {
#[test] #[test]
fn test_build_get_playback_request_rejects_invalid_subtitle_preference() -> TestResult { fn test_build_get_playback_request_rejects_invalid_subtitle_preference() -> TestResult {
let error = app_err(build_get_playback_request(&GetPlaybackQuery { let error = app_err(build_get_playback_request(&GetPlaybackQuery {
client_profile: None,
stream_preference: None, stream_preference: None,
max_streaming_bitrate: None, max_streaming_bitrate: None,
max_audio_channels: None, max_audio_channels: None,

@ -22,6 +22,14 @@ use tower::ServiceExt;
type TestResult<T = ()> = anyhow::Result<T>; type TestResult<T = ()> = anyhow::Result<T>;
#[cfg(feature = "web-ui")]
#[test]
fn web_ui_routes_can_merge_with_a_grpc_style_fallback() {
let grpc_router = Router::<super::AppState>::new().fallback(StatusCode::NOT_FOUND);
let _combined = register_all_routes().merge(grpc_router);
}
fn test_error(message: impl Into<String>) -> anyhow::Error { fn test_error(message: impl Into<String>) -> anyhow::Error {
anyhow::anyhow!(message.into()) anyhow::anyhow!(message.into())
} }
@ -1694,6 +1702,7 @@ async fn test_playback_patch_route_is_reachable_via_project_router() -> TestResu
#[tokio::test] #[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"] #[ignore = "Requires Docker-backed PostgreSQL"]
#[cfg(not(feature = "web-ui"))]
async fn test_api_root_redirects_to_configured_project_url() -> TestResult { async fn test_api_root_redirects_to_configured_project_url() -> TestResult {
let mut state = test_app_state(); let mut state = test_app_state();
Arc::make_mut(&mut Arc::make_mut(&mut state.router_options).runtime_settings) Arc::make_mut(&mut Arc::make_mut(&mut state.router_options).runtime_settings)
@ -2872,7 +2881,7 @@ async fn test_transport_layers_preserve_shared_http_metadata_without_global_time
#[tokio::test] #[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"] #[ignore = "Requires Docker-backed PostgreSQL"]
async fn json_compression_is_enabled_without_compressing_media_responses() -> TestResult { async fn application_compression_is_enabled_without_compressing_media_responses() -> TestResult {
let state = test_app_state(); let state = test_app_state();
let app = apply_global_layers( let app = apply_global_layers(
Router::new() Router::new()
@ -2888,6 +2897,24 @@ async fn json_compression_is_enabled_without_compressing_media_responses() -> Te
.expect("JSON response should build") .expect("JSON response should build")
}), }),
) )
.route(
"/javascript",
get(|| async {
Response::builder()
.header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
.body(Body::from("x".repeat(256)))
.expect("JavaScript response should build")
}),
)
.route(
"/wasm",
get(|| async {
Response::builder()
.header(header::CONTENT_TYPE, "application/wasm")
.body(Body::from("x".repeat(256)))
.expect("Wasm response should build")
}),
)
.route( .route(
"/flv", "/flv",
get(|| async { get(|| async {
@ -2918,6 +2945,27 @@ async fn json_compression_is_enabled_without_compressing_media_responses() -> Te
Some("gzip") Some("gzip")
); );
for path in ["/javascript", "/wasm"] {
let response = test_response(
app.clone()
.oneshot(test_request(
Request::builder()
.uri(path)
.header(header::ACCEPT_ENCODING, "gzip")
.body(Body::empty()),
)?)
.await,
)?;
assert_eq!(
response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok()),
Some("gzip"),
"{path} should use transport compression"
);
}
let flv_response = test_response( let flv_response = test_response(
app.oneshot(test_request( app.oneshot(test_request(
Request::builder() Request::builder()

@ -0,0 +1,593 @@
use axum::body::Body;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use synctv_web_ui::{Asset, ASSETS, WEB_UI_AVAILABLE};
const PROVIDER_VERIFICATION_PAGE: &str = "provider_verification.html";
const PROVIDER_VERIFICATION_CSP: &str = "default-src 'none'; \
script-src 'self' https://static.geetest.com https://*.geetest.com https://dn-staticdown.qbox.me; \
connect-src https://geetest.com https://*.geetest.com https://monitor.geetest.com https://dn-staticdown.qbox.me; \
img-src data: blob: https://geetest.com https://*.geetest.com https://dn-staticdown.qbox.me; \
style-src 'self' 'unsafe-inline' https://*.geetest.com; \
font-src data: https://*.geetest.com; \
frame-src https://*.geetest.com; \
frame-ancestors 'self'; \
base-uri 'none'; \
form-action 'none'";
pub async fn index(headers: HeaderMap) -> Response {
serve_path("index.html", true, &headers)
}
pub async fn fallback(uri: Uri, headers: HeaderMap) -> Response {
let path = uri.path().trim_start_matches('/');
if path.is_empty() {
return serve_path("index.html", true, &headers);
}
if path.starts_with("api/")
|| path == "api"
|| path.starts_with("ws/")
|| path == "ws"
|| path.starts_with("grpc/")
|| path == "grpc"
{
return not_found();
}
if path.contains("..") || path.contains('\\') {
return not_found();
}
if let Some(response) = find_asset(path).map(|asset| asset_response(asset, false, &headers)) {
return response;
}
let accepts_html = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value.split(',').any(|part| {
part.split(';').next().is_some_and(|media_type| {
matches!(media_type.trim(), "text/html" | "application/xhtml+xml")
})
})
});
if !path.contains('.') && (accepts_html || headers.get(header::ACCEPT).is_none()) {
return serve_path("index.html", true, &headers);
}
not_found()
}
fn serve_path(path: &str, html_navigation: bool, headers: &HeaderMap) -> Response {
if !WEB_UI_AVAILABLE {
return (
StatusCode::SERVICE_UNAVAILABLE,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
"The embedded SyncTV Web client is not available in this build.",
)
.into_response();
}
find_asset(path)
.map(|asset| asset_response(asset, html_navigation, headers))
.unwrap_or_else(not_found)
}
fn find_asset(path: &str) -> Option<&'static Asset> {
ASSETS.iter().find(|asset| asset.path == path)
}
fn asset_response(asset: &'static Asset, html_navigation: bool, headers: &HeaderMap) -> Response {
let cache_control = if asset.path == PROVIDER_VERIFICATION_PAGE {
"no-store"
} else if versioned_playback_asset(asset.path) {
"public, max-age=31536000, immutable"
} else if html_navigation || update_metadata_asset(asset.path) {
"no-cache"
} else {
"public, max-age=0, must-revalidate"
};
let representation = match select_representation(asset, headers) {
Some(representation) => representation,
None => return StatusCode::NOT_ACCEPTABLE.into_response(),
};
if if_none_match_matches(headers, representation.etag) {
let mut response = StatusCode::NOT_MODIFIED.into_response();
response
.headers_mut()
.insert(header::ETAG, HeaderValue::from_static(representation.etag));
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control),
);
apply_representation_headers(response.headers_mut(), representation);
apply_asset_security_headers(asset.path, response.headers_mut());
return response;
}
let mut response = Response::new(Body::from(representation.bytes));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(asset.content_type),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control),
);
response
.headers_mut()
.insert(header::ETAG, HeaderValue::from_static(representation.etag));
apply_representation_headers(response.headers_mut(), representation);
apply_asset_security_headers(asset.path, response.headers_mut());
response
}
fn apply_asset_security_headers(path: &str, headers: &mut HeaderMap) {
if path != PROVIDER_VERIFICATION_PAGE {
return;
}
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(PROVIDER_VERIFICATION_CSP),
);
headers.insert(
header::HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("SAMEORIGIN"),
);
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
headers.insert(
header::HeaderName::from_static("permissions-policy"),
HeaderValue::from_static(
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), \
magnetometer=(), microphone=(), payment=(), picture-in-picture=(), usb=()",
),
);
headers.insert(
header::HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
);
}
#[derive(Clone, Copy)]
struct Representation {
bytes: &'static [u8],
etag: &'static str,
encoding: Option<&'static str>,
}
fn select_representation(asset: &'static Asset, headers: &HeaderMap) -> Option<Representation> {
let qualities = accepted_encoding_qualities(headers);
let brotli_quality = asset.brotli.map_or(0, |_| qualities.brotli);
let gzip_quality = asset.gzip.map_or(0, |_| qualities.gzip);
if brotli_quality > 0 && brotli_quality >= gzip_quality && brotli_quality >= qualities.identity
{
let encoded = asset.brotli?;
return Some(Representation {
bytes: encoded.bytes,
etag: encoded.etag,
encoding: Some("br"),
});
}
if gzip_quality > 0 && gzip_quality >= qualities.identity {
let encoded = asset.gzip?;
return Some(Representation {
bytes: encoded.bytes,
etag: encoded.etag,
encoding: Some("gzip"),
});
}
(qualities.identity > 0).then_some(Representation {
bytes: asset.bytes,
etag: asset.etag,
encoding: None,
})
}
fn apply_representation_headers(headers: &mut HeaderMap, representation: Representation) {
headers.append(header::VARY, HeaderValue::from_static("Accept-Encoding"));
if let Some(encoding) = representation.encoding {
headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(encoding));
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct EncodingQualities {
brotli: u16,
gzip: u16,
identity: u16,
}
fn accepted_encoding_qualities(headers: &HeaderMap) -> EncodingQualities {
if !headers.contains_key(header::ACCEPT_ENCODING) {
return EncodingQualities {
brotli: 0,
gzip: 0,
identity: 1000,
};
}
let mut brotli = None;
let mut gzip = None;
let mut identity = None;
let mut wildcard = None;
for value in headers.get_all(header::ACCEPT_ENCODING) {
let Ok(value) = value.to_str() else {
continue;
};
for item in value.split(',') {
let mut parts = item.trim().split(';');
let name = parts.next().unwrap_or_default().trim();
if name.is_empty() {
continue;
}
let mut quality = 1000;
for parameter in parts {
let Some((key, value)) = parameter.trim().split_once('=') else {
quality = 0;
break;
};
if key.trim().eq_ignore_ascii_case("q") {
quality = parse_quality(value.trim()).unwrap_or(0);
}
}
match name.to_ascii_lowercase().as_str() {
"br" => brotli = Some(quality),
"gzip" | "x-gzip" => gzip = Some(quality),
"identity" => identity = Some(quality),
"*" => wildcard = Some(quality),
_ => {}
}
}
}
EncodingQualities {
brotli: brotli.or(wildcard).unwrap_or(0),
gzip: gzip.or(wildcard).unwrap_or(0),
identity: identity.unwrap_or_else(|| if wildcard == Some(0) { 0 } else { 1000 }),
}
}
fn parse_quality(value: &str) -> Option<u16> {
if value == "0" {
return Some(0);
}
if value == "1" {
return Some(1000);
}
let (whole, fraction) = value.split_once('.')?;
if !matches!(whole, "0" | "1") || fraction.len() > 3 || fraction.is_empty() {
return None;
}
let mut fraction_value = fraction.parse::<u16>().ok()?;
if whole == "1" && fraction_value != 0 {
return None;
}
for _ in fraction.len()..3 {
fraction_value *= 10;
}
Some(if whole == "1" { 1000 } else { fraction_value })
}
fn versioned_playback_asset(path: &str) -> bool {
path.starts_with("playback/") && path.as_bytes().iter().any(u8::is_ascii_digit)
}
fn update_metadata_asset(path: &str) -> bool {
path.ends_with(".html")
|| matches!(
path,
"manifest.json" | "version.json" | "flutter_service_worker.js"
)
}
fn if_none_match_matches(headers: &HeaderMap, current_etag: &str) -> bool {
headers
.get_all(header::IF_NONE_MATCH)
.iter()
.any(|value| etag_list_matches(value.as_bytes(), current_etag.as_bytes()))
}
fn etag_list_matches(value: &[u8], current_etag: &[u8]) -> bool {
let mut index = 0;
let mut matched = false;
while index < value.len() {
skip_optional_whitespace(value, &mut index);
if value.get(index) == Some(&b'*') {
index += 1;
skip_optional_whitespace(value, &mut index);
return index == value.len();
}
if value.get(index..index + 2) == Some(b"W/") {
index += 2;
}
if value.get(index) != Some(&b'"') {
return false;
}
let tag_start = index;
index += 1;
while let Some(byte) = value.get(index) {
if *byte == b'"' {
break;
}
if !matches!(*byte, 0x21 | 0x23..=0x7e | 0x80..=0xff) {
return false;
}
index += 1;
}
if value.get(index) != Some(&b'"') {
return false;
}
index += 1;
matched |= &value[tag_start..index] == current_etag;
skip_optional_whitespace(value, &mut index);
if index == value.len() {
return matched;
}
if value.get(index) != Some(&b',') {
return false;
}
index += 1;
if index == value.len() {
return false;
}
}
false
}
fn skip_optional_whitespace(value: &[u8], index: &mut usize) {
while value
.get(*index)
.is_some_and(|byte| matches!(byte, b' ' | b'\t'))
{
*index += 1;
}
}
fn not_found() -> Response {
StatusCode::NOT_FOUND.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
const ETAG: &str = "\"0123456789abcdef-42\"";
#[test]
fn entity_tag_lists_use_weak_comparison_and_support_wildcards() {
assert!(etag_list_matches(ETAG.as_bytes(), ETAG.as_bytes()));
assert!(etag_list_matches(
b"W/\"0123456789abcdef-42\"",
ETAG.as_bytes()
));
assert!(etag_list_matches(
b"\"different\", W/\"0123456789abcdef-42\"",
ETAG.as_bytes()
));
assert!(etag_list_matches(b" * \t", ETAG.as_bytes()));
}
#[test]
fn malformed_or_partial_entity_tags_do_not_match() {
assert!(!etag_list_matches(
b"\"0123456789abcdef-42",
ETAG.as_bytes()
));
assert!(!etag_list_matches(
b"\"0123456789abcdef-42\" trailing",
ETAG.as_bytes()
));
assert!(!etag_list_matches(
b"\"different, \"0123456789abcdef-42\"",
ETAG.as_bytes()
));
assert!(!etag_list_matches(b"*, \"other\"", ETAG.as_bytes()));
}
#[test]
fn only_versioned_playback_assets_are_immutable() {
assert!(versioned_playback_asset("playback/hls-1.7.1.min.js"));
assert!(!versioned_playback_asset("main.dart.js"));
assert!(!versioned_playback_asset("playback/engine.min.js"));
}
#[test]
fn browser_update_metadata_always_revalidates() {
assert!(update_metadata_asset("index.html"));
assert!(update_metadata_asset("manifest.json"));
assert!(update_metadata_asset("version.json"));
assert!(update_metadata_asset("flutter_service_worker.js"));
assert!(!update_metadata_asset("main.dart.js"));
}
#[tokio::test]
async fn oauth_callback_uses_the_spa_entrypoint() {
let response = fallback(
"/oauth2/callback"
.parse::<Uri>()
.expect("valid callback URI"),
HeaderMap::new(),
)
.await;
if !WEB_UI_AVAILABLE {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
return;
}
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CACHE_CONTROL),
Some(&HeaderValue::from_static("no-cache"))
);
}
#[test]
fn provider_verification_page_has_dedicated_security_policy() {
let mut headers = HeaderMap::new();
apply_asset_security_headers(PROVIDER_VERIFICATION_PAGE, &mut headers);
let csp = headers
.get(header::CONTENT_SECURITY_POLICY)
.and_then(|value| value.to_str().ok())
.expect("verification CSP should be valid");
assert!(csp.contains("default-src 'none'"));
assert!(csp.contains("script-src 'self' https://static.geetest.com"));
assert!(csp.contains("https://monitor.geetest.com"));
assert!(csp.contains("https://dn-staticdown.qbox.me"));
assert!(csp.contains("frame-ancestors 'self'"));
assert!(!csp.contains("unsafe-eval"));
assert_eq!(
headers.get("x-frame-options"),
Some(&HeaderValue::from_static("SAMEORIGIN"))
);
assert_eq!(
headers.get(header::REFERRER_POLICY),
Some(&HeaderValue::from_static("no-referrer"))
);
assert_eq!(
headers.get("cross-origin-resource-policy"),
Some(&HeaderValue::from_static("same-origin"))
);
}
#[test]
fn ordinary_assets_keep_the_global_security_policy() {
let mut headers = HeaderMap::new();
apply_asset_security_headers("index.html", &mut headers);
assert!(!headers.contains_key(header::CONTENT_SECURITY_POLICY));
assert!(!headers.contains_key("x-frame-options"));
}
#[test]
fn content_encoding_quality_prefers_brotli_then_gzip() {
let mut headers = HeaderMap::new();
headers.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("gzip, deflate, br"),
);
assert_eq!(
accepted_encoding_qualities(&headers),
EncodingQualities {
brotli: 1000,
gzip: 1000,
identity: 1000,
}
);
headers.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("br;q=0.4, gzip;q=0.8, identity;q=0.1"),
);
assert_eq!(
accepted_encoding_qualities(&headers),
EncodingQualities {
brotli: 400,
gzip: 800,
identity: 100,
}
);
}
#[test]
fn content_encoding_quality_honors_identity_and_wildcard_exclusions() {
let mut headers = HeaderMap::new();
headers.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("br;q=0.5"),
);
assert_eq!(accepted_encoding_qualities(&headers).identity, 1000);
headers.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("*;q=0, gzip;q=0.7"),
);
assert_eq!(
accepted_encoding_qualities(&headers),
EncodingQualities {
brotli: 0,
gzip: 700,
identity: 0,
}
);
}
#[test]
fn quality_parser_rejects_values_outside_http_range() {
assert_eq!(parse_quality("0.5"), Some(500));
assert_eq!(parse_quality("1.000"), Some(1000));
assert_eq!(parse_quality("1.1"), None);
assert_eq!(parse_quality("0.0000"), None);
assert_eq!(parse_quality("invalid"), None);
}
#[tokio::test]
async fn fallback_negotiates_encoded_assets_and_revalidates_each_representation() {
if !WEB_UI_AVAILABLE {
return;
}
let asset = ASSETS
.iter()
.find(|asset| asset.brotli.is_some() && asset.gzip.is_some())
.expect("the Web UI build should contain a compressible asset");
let uri = format!("/{}", asset.path).parse::<Uri>().unwrap();
let mut brotli_headers = HeaderMap::new();
brotli_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
let brotli_response = fallback(uri.clone(), brotli_headers).await;
assert_eq!(brotli_response.status(), StatusCode::OK);
assert_eq!(
brotli_response.headers().get(header::CONTENT_ENCODING),
Some(&HeaderValue::from_static("br"))
);
assert_eq!(
brotli_response.headers().get(header::VARY),
Some(&HeaderValue::from_static("Accept-Encoding"))
);
let brotli_etag = brotli_response
.headers()
.get(header::ETAG)
.cloned()
.expect("Brotli response should include an ETag");
let mut gzip_headers = HeaderMap::new();
gzip_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip"));
let gzip_response = fallback(uri.clone(), gzip_headers).await;
assert_eq!(gzip_response.status(), StatusCode::OK);
assert_eq!(
gzip_response.headers().get(header::CONTENT_ENCODING),
Some(&HeaderValue::from_static("gzip"))
);
assert_ne!(
gzip_response.headers().get(header::ETAG),
Some(&brotli_etag)
);
let mut revalidation_headers = HeaderMap::new();
revalidation_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br"));
revalidation_headers.insert(header::IF_NONE_MATCH, brotli_etag.clone());
let revalidation_response = fallback(uri.clone(), revalidation_headers).await;
assert_eq!(revalidation_response.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
revalidation_response
.headers()
.get(header::CONTENT_ENCODING),
Some(&HeaderValue::from_static("br"))
);
assert_eq!(
revalidation_response.headers().get(header::ETAG),
Some(&brotli_etag)
);
let mut rejected_headers = HeaderMap::new();
rejected_headers.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("br;q=0, gzip;q=0, identity;q=0"),
);
let rejected_response = fallback(uri, rejected_headers).await;
assert_eq!(rejected_response.status(), StatusCode::NOT_ACCEPTABLE);
}
}

@ -511,7 +511,7 @@ fn validate_websocket_origin(
)) ))
} }
fn same_origin_as_host( pub(crate) fn same_origin_as_host(
origin: &url::Url, origin: &url::Url,
host_header: &str, host_header: &str,
forwarded_proto: Option<&str>, forwarded_proto: Option<&str>,

@ -7,6 +7,7 @@ license.workspace = true
[features] [features]
default = ["tls-aws-lc", "tls-webpki-roots"] default = ["tls-aws-lc", "tls-webpki-roots"]
web-ui = ["synctv-api-http/web-ui"]
openapi = ["synctv-api-common/openapi", "synctv-api-http/openapi"] openapi = ["synctv-api-common/openapi", "synctv-api-http/openapi"]
k8s = [ k8s = [
"synctv-api-common/k8s", "synctv-api-common/k8s",

@ -395,9 +395,10 @@ mod security_headers {
.unwrap(); .unwrap();
assert!(csp.contains("default-src 'self'")); assert!(csp.contains("default-src 'self'"));
assert!(csp.contains("frame-ancestors 'none'")); assert!(csp.contains("frame-ancestors 'none'"));
assert!(csp.contains("media-src 'none'")); assert!(csp.contains("media-src 'self' blob: https: http:"));
assert!(csp.contains("frame-src 'none'")); assert!(csp.contains("frame-src 'self'"));
assert!(csp.contains("connect-src 'self' wss: ws:")); assert!(csp.contains("connect-src 'self' blob: https: http: wss: ws:"));
assert!(csp.contains("worker-src 'self' blob:"));
let pp = resp let pp = resp
.headers() .headers()
@ -406,7 +407,8 @@ mod security_headers {
.to_str() .to_str()
.unwrap(); .unwrap();
assert!(pp.contains("camera=()")); assert!(pp.contains("camera=()"));
assert!(pp.contains("microphone=()")); assert!(pp.contains("microphone=(self)"));
assert!(pp.contains("picture-in-picture=(self)"));
assert!(pp.contains("geolocation=()")); assert!(pp.contains("geolocation=()"));
let cc = resp let cc = resp

@ -62,6 +62,11 @@ pub enum CloneableError {
Conflict(String), Conflict(String),
#[error("Invalid input: {0}")] #[error("Invalid input: {0}")]
InvalidInput(String), InvalidInput(String),
#[error("Client cannot play this resource: {reason}")]
ClientIncompatible {
reason: String,
required_capability: Option<String>,
},
#[error("Rate limited: {0}")] #[error("Rate limited: {0}")]
RateLimited(String), RateLimited(String),
#[error("Service unavailable: {0}")] #[error("Service unavailable: {0}")]
@ -105,6 +110,13 @@ impl From<Error> for CloneableError {
Error::AlreadyExists(message) => Self::AlreadyExists(message), Error::AlreadyExists(message) => Self::AlreadyExists(message),
Error::Conflict(message) => Self::Conflict(message), Error::Conflict(message) => Self::Conflict(message),
Error::InvalidInput(message) => Self::InvalidInput(message), Error::InvalidInput(message) => Self::InvalidInput(message),
Error::ClientIncompatible {
reason,
required_capability,
} => Self::ClientIncompatible {
reason,
required_capability,
},
Error::RangeNotSatisfiable { total_size } => { Error::RangeNotSatisfiable { total_size } => {
Self::InvalidInput(format!("Range not satisfiable: total size {total_size}")) Self::InvalidInput(format!("Range not satisfiable: total size {total_size}"))
} }
@ -128,6 +140,13 @@ impl From<CloneableError> for Error {
CloneableError::AlreadyExists(message) => Self::AlreadyExists(message), CloneableError::AlreadyExists(message) => Self::AlreadyExists(message),
CloneableError::Conflict(message) => Self::Conflict(message), CloneableError::Conflict(message) => Self::Conflict(message),
CloneableError::InvalidInput(message) => Self::InvalidInput(message), CloneableError::InvalidInput(message) => Self::InvalidInput(message),
CloneableError::ClientIncompatible {
reason,
required_capability,
} => Self::ClientIncompatible {
reason,
required_capability,
},
CloneableError::RateLimited(message) => Self::RateLimited(message), CloneableError::RateLimited(message) => Self::RateLimited(message),
CloneableError::ServiceUnavailable(message) => Self::ServiceUnavailable(message), CloneableError::ServiceUnavailable(message) => Self::ServiceUnavailable(message),
CloneableError::Internal(message) => Self::Internal(message), CloneableError::Internal(message) => Self::Internal(message),

@ -41,6 +41,12 @@ pub enum Error {
#[error("Invalid input: {0}")] #[error("Invalid input: {0}")]
InvalidInput(String), InvalidInput(String),
#[error("Client cannot play this resource: {reason}")]
ClientIncompatible {
reason: String,
required_capability: Option<String>,
},
#[error("Range not satisfiable: total size {total_size}")] #[error("Range not satisfiable: total size {total_size}")]
RangeNotSatisfiable { total_size: u64 }, RangeNotSatisfiable { total_size: u64 },
@ -230,6 +236,13 @@ impl From<crate::provider::ProviderError> for Error {
ProviderError::UnsupportedFormat(fmt) => { ProviderError::UnsupportedFormat(fmt) => {
Self::InvalidInput(format!("Unsupported format: {fmt}")) Self::InvalidInput(format!("Unsupported format: {fmt}"))
} }
ProviderError::ClientIncompatible {
reason,
required_capability,
} => Self::ClientIncompatible {
reason,
required_capability,
},
ProviderError::ParseError(msg) => Self::InvalidInput(format!("Parse error: {msg}")), ProviderError::ParseError(msg) => Self::InvalidInput(format!("Parse error: {msg}")),
// Route registration // Route registration
ProviderError::RouteRegistrationFailed(msg) => { ProviderError::RouteRegistrationFailed(msg) => {

@ -3120,6 +3120,21 @@ impl PlaybackSubtitle {
| PlaybackSubtitleProvider::TikTok(_) => std::collections::HashMap::new(), | PlaybackSubtitleProvider::TikTok(_) => std::collections::HashMap::new(),
} }
} }
#[must_use]
pub fn requires_provider_url(&self) -> bool {
!matches!(
self.provider,
PlaybackSubtitleProvider::Cloudreve(PlaybackCloudreveSubtitle::Direct { .. })
| PlaybackSubtitleProvider::Bilibili(PlaybackBilibiliSubtitle::Direct { .. })
| PlaybackSubtitleProvider::DirectUrl(PlaybackDirectUrlSubtitle::Direct { .. })
| PlaybackSubtitleProvider::Emby(PlaybackEmbySubtitle::Direct { .. })
| PlaybackSubtitleProvider::Fnos(PlaybackFnosSubtitle::Direct { .. })
| PlaybackSubtitleProvider::Alist(PlaybackAlistSubtitle::Refresh { .. })
| PlaybackSubtitleProvider::Youtube(PlaybackYoutubeSubtitle::Refresh { .. })
| PlaybackSubtitleProvider::TikTok(PlaybackTikTokSubtitle::Refresh { .. })
)
}
} }
impl PlaybackDanmaku { impl PlaybackDanmaku {
@ -3205,6 +3220,15 @@ impl PlaybackDanmaku {
} }
} }
#[must_use]
pub fn requires_provider_url(&self) -> bool {
!matches!(
self.provider,
PlaybackDanmakuProvider::DirectUrl(_)
| PlaybackDanmakuProvider::Bilibili(PlaybackBilibiliDanmaku::FileDirect { .. })
)
}
#[must_use] #[must_use]
pub fn format(&self) -> Option<&str> { pub fn format(&self) -> Option<&str> {
self.format.as_deref() self.format.as_deref()

@ -597,21 +597,60 @@ const fn acfun_format_name(format: AcFunPlaybackFormat) -> &'static str {
} }
} }
fn mark_acfun_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_acfun_playback_resources(
result: &mut PlaybackResult,
version: &str,
expires_at: i64,
client_profile: Option<&super::PlaybackClientProfile>,
) {
let original_default = result.default_mode.clone();
for (mode_name, info) in &mut result.playback_infos { for (mode_name, info) in &mut result.playback_infos {
for (media_index, media) in info.medias.iter_mut().enumerate() { let source_medias = std::mem::take(&mut info.medias);
if matches!( let supported_indices = source_medias
media.provider, .iter()
PlaybackMediaProvider::AcFun(PlaybackAcFunMedia::Refresh { .. }) .enumerate()
) { .filter_map(|(media_index, media)| {
media.provider = PlaybackMediaProvider::AcFun(PlaybackAcFunMedia::Proxy { super::proxy_playback_media_supported_by_client(client_profile, mode_name, media)
version: version.to_string(), .then_some(media_index)
expires_at, })
mode_name: mode_name.clone(), .collect::<std::collections::HashSet<_>>();
media_index, let (medias, default_media_index) = super::map_playback_resources(
}); &source_medias,
} info.default_media_index,
} |media_index, media| {
if !supported_indices.contains(&media_index) {
return None;
}
let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::AcFun(PlaybackAcFunMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::AcFun(PlaybackAcFunMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
info.medias = medias;
info.default_media_index = default_media_index;
let (danmakus, default_danmaku_index) = super::map_playback_resources(
&info.danmakus,
info.default_danmaku_index,
|_, danmaku| match &danmaku.provider {
PlaybackDanmakuProvider::AcFun(
PlaybackAcFunDanmaku::FileRefresh { media_index }
| PlaybackAcFunDanmaku::LiveRefresh { media_index },
) if !supported_indices.contains(media_index) => None,
_ => Some(danmaku.clone()),
},
);
info.danmakus = danmakus;
info.default_danmaku_index = default_danmaku_index;
for danmaku in &mut info.danmakus { for danmaku in &mut info.danmakus {
danmaku.provider = match &danmaku.provider { danmaku.provider = match &danmaku.provider {
PlaybackDanmakuProvider::AcFun(PlaybackAcFunDanmaku::FileRefresh { PlaybackDanmakuProvider::AcFun(PlaybackAcFunDanmaku::FileRefresh {
@ -634,6 +673,10 @@ fn mark_acfun_playback_resources(result: &mut PlaybackResult, version: &str, exp
}; };
} }
} }
result
.playback_infos
.retain(|_, info| !info.medias.is_empty());
super::select_generated_playback_default(result, &original_default, true);
} }
#[async_trait] #[async_trait]
@ -684,15 +727,23 @@ impl MediaProvider for AcFunProvider {
} else { } else {
Duration::from_hours(2) Duration::from_hours(2)
}; };
super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&Self::cache_key(&resource), &Self::cache_key(&resource),
cache_ttl, cache_ttl,
ctx, ctx,
mark_acfun_playback_resources, |result, version, expires_at| {
mark_acfun_playback_resources(result, version, expires_at, client_profile);
},
|| async { Self::playback_result(self.client.resolve(&resource, None).await?) }, || async { Self::playback_result(self.client.resolve(&resource, None).await?) },
) )
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn validate_source_config( async fn validate_source_config(

@ -570,6 +570,7 @@ fn mark_alist_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&PlaybackClientProfile>,
) { ) {
// Alist returns upstream playback modes and SyncTV proxy siblings in the // Alist returns upstream playback modes and SyncTV proxy siblings in the
// same result. The proxy default keeps clients independent from upstream // same result. The proxy default keeps clients independent from upstream
@ -592,7 +593,13 @@ fn mark_alist_playback_resources(
default_selection = selection; default_selection = selection;
} }
if selection.direct { if selection.direct {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
@ -601,11 +608,17 @@ fn mark_alist_playback_resources(
let proxy_mode_name = format!("proxy_{mode_name}"); let proxy_mode_name = format!("proxy_{mode_name}");
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |url_index, media| {
.filter_map(|(url_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let url = media.upstream_url()?.to_string(); let url = media.upstream_url()?.to_string();
let mut proxy = playback_media( let mut proxy = playback_media(
media.name.clone(), media.name.clone(),
@ -635,8 +648,10 @@ fn mark_alist_playback_resources(
); );
proxy.p2p_swarm_id.clone_from(&media.p2p_swarm_id); proxy.p2p_swarm_id.clone_from(&media.p2p_swarm_id);
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
proxy_info.subtitles = original_info proxy_info.subtitles = original_info
.subtitles .subtitles
.iter() .iter()
@ -2559,13 +2574,19 @@ impl MediaProvider for AlistProvider {
let cache_ttl = Duration::from_mins(15); let cache_ttl = Duration::from_mins(15);
let proxy_mode = config.proxy_mode; let proxy_mode = config.proxy_mode;
Box::pin(super::cached_versioned_playback_or_fill( let result = Box::pin(super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&cache_key, &cache_key,
cache_ttl, cache_ttl,
_ctx, _ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_alist_playback_resources(result, version, expires_at, proxy_mode); mark_alist_playback_resources(
result,
version,
expires_at,
proxy_mode,
playback_client_profile,
);
}, },
|| async { || async {
let resolved = self.resolve_config(_ctx, config.clone()).await?; let resolved = self.resolve_config(_ctx, config.clone()).await?;
@ -2573,7 +2594,8 @@ impl MediaProvider for AlistProvider {
.await .await
}, },
)) ))
.await .await?;
super::require_compatible_playback_route(result, proxy_mode, playback_client_profile)
} }
fn as_dynamic_playlist_provider(&self) -> Option<&dyn DynamicPlaylistProvider> { fn as_dynamic_playlist_provider(&self) -> Option<&dyn DynamicPlaylistProvider> {
@ -3878,6 +3900,7 @@ mod tests {
"version", "version",
transport_expires_at, transport_expires_at,
crate::models::PlaybackProxyMode::Auto, crate::models::PlaybackProxyMode::Auto,
None,
); );
let proxy = &result.playback_infos["proxy_direct"]; let proxy = &result.playback_infos["proxy_direct"];
assert_eq!( assert_eq!(

@ -2368,6 +2368,7 @@ fn mark_bilibili_playback_resources(
// while proxy siblings remain as a server-mediated fallback. // while proxy siblings remain as a server-mediated fallback.
attach_bilibili_live_danmaku(result, context); attach_bilibili_live_danmaku(result, context);
let selection = bilibili_route_selection(proxy_mode); let selection = bilibili_route_selection(proxy_mode);
let client_profile = context.and_then(ProviderContext::playback_client_profile);
let original_default_mode = result.default_mode.clone(); let original_default_mode = result.default_mode.clone();
let original_modes = result let original_modes = result
.playback_infos .playback_infos
@ -2388,7 +2389,13 @@ fn mark_bilibili_playback_resources(
) )
{ {
let mut direct_info = original_info.clone(); let mut direct_info = original_info.clone();
if selection.direct { if selection.direct
&& super::direct_playback_media_supported_by_client(
client_profile,
&mode_name,
&direct_info.medias[0],
)
{
if let PlaybackMediaProvider::Bilibili( if let PlaybackMediaProvider::Bilibili(
PlaybackBilibiliMedia::DirectDurlManifest { PlaybackBilibiliMedia::DirectDurlManifest {
version: resource_version, version: resource_version,
@ -2402,13 +2409,25 @@ fn mark_bilibili_playback_resources(
*resource_expires_at = expires_at; *resource_expires_at = expires_at;
resource_mode_name.clone_from(&mode_name); resource_mode_name.clone_from(&mode_name);
} }
generated.insert(mode_name.clone(), direct_info); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&direct_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
// Direct DURL playback serves a generated manifest with Bilibili // Direct DURL playback serves a generated manifest with Bilibili
// segment URLs. The proxy sibling preserves server forwarding and // segment URLs. The proxy sibling preserves server forwarding and
// backup CDN candidate selection for proxy playback modes. // backup CDN candidate selection for proxy playback modes.
if selection.proxy { if selection.proxy
&& super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
&original_info.medias[0],
)
{
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
if let Some(media) = proxy_info.medias.first_mut() { if let Some(media) = proxy_info.medias.first_mut() {
if let PlaybackMediaProvider::Bilibili( if let PlaybackMediaProvider::Bilibili(
@ -2427,14 +2446,16 @@ fn mark_bilibili_playback_resources(
}); });
} }
} }
populate_bilibili_proxy_attachments( if !proxy_info.medias.is_empty() {
&original_info, populate_bilibili_proxy_attachments(
&mut proxy_info, &original_info,
version, &mut proxy_info,
expires_at, version,
&mode_name, expires_at,
); &mode_name,
generated.insert(format!("proxy_{mode_name}"), proxy_info); );
generated.insert(format!("proxy_{mode_name}"), proxy_info);
}
} }
continue; continue;
} }
@ -2498,11 +2519,32 @@ fn mark_bilibili_playback_resources(
), ),
) )
}) })
.filter(|media| {
super::direct_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
)
})
.collect(); .collect();
direct_info.default_media_index = Some(0); if !direct_info.medias.is_empty() {
generated.insert(mode_name.clone(), direct_info); direct_info.default_media_index = Some(0);
if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&direct_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
}
} else if selection.direct { } else if selection.direct {
generated.insert(mode_name.clone(), direct_info); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&direct_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if selection.proxy { if selection.proxy {
@ -2526,14 +2568,27 @@ fn mark_bilibili_playback_resources(
), ),
) )
}) })
.filter(|media| {
super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
)
})
.collect(); .collect();
proxy_info.default_media_index = Some(0); proxy_info.default_media_index = (!proxy_info.medias.is_empty()).then_some(0);
} else { } else {
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |url_index, media| {
.filter_map(|(url_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let url = media.upstream_url()?.to_string(); let url = media.upstream_url()?.to_string();
Some(playback_media( Some(playback_media(
media.name.clone(), media.name.clone(),
@ -2562,31 +2617,30 @@ fn mark_bilibili_playback_resources(
}, },
), ),
)) ))
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
}
if !proxy_info.medias.is_empty() {
populate_bilibili_proxy_attachments(
&original_info,
&mut proxy_info,
version,
expires_at,
&mode_name,
);
generated.insert(proxy_mode_name, proxy_info);
} }
populate_bilibili_proxy_attachments(
&original_info,
&mut proxy_info,
version,
expires_at,
&mode_name,
);
generated.insert(proxy_mode_name, proxy_info);
} }
} }
result.playback_infos = generated; result.playback_infos = generated;
let proxy_default_mode = format!("proxy_{original_default_mode}"); super::select_generated_playback_default(
let direct_default_available = result.playback_infos.contains_key(&original_default_mode); result,
let proxy_default_available = result.playback_infos.contains_key(&proxy_default_mode); &original_default_mode,
result.default_mode = if direct_default_available && !selection.prefer_proxy { selection.prefer_proxy,
original_default_mode );
} else if proxy_default_available {
proxy_default_mode
} else {
original_default_mode
};
} }
fn bilibili_credential_server_id() -> String { fn bilibili_credential_server_id() -> String {
@ -3499,7 +3553,7 @@ impl MediaProvider for BilibiliProvider {
)?; )?;
let proxy_mode = config.proxy_mode(); let proxy_mode = config.proxy_mode();
Box::pin(super::cached_versioned_playback_or_fill( let result = Box::pin(super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&cache_key, &cache_key,
cache_ttl, cache_ttl,
@ -3524,7 +3578,8 @@ impl MediaProvider for BilibiliProvider {
.await .await
}, },
)) ))
.await .await?;
super::require_compatible_playback_route(result, proxy_mode, _ctx.playback_client_profile())
} }
async fn validate_source_config( async fn validate_source_config(
@ -4450,9 +4505,11 @@ mod tests {
}; };
use crate::models::{BilibiliTarget, PlaylistId, ProviderTarget, RoomId}; use crate::models::{BilibiliTarget, PlaylistId, ProviderTarget, RoomId};
use crate::provider::{ use crate::provider::{
InMemoryProviderStore, PlaybackInfo, PlaybackResult, PlaybackTransportAction, InMemoryProviderStore, PlaybackAudioCodec, PlaybackClientEnvironment,
PlaybackClientProfile, PlaybackInfo, PlaybackMediaCapability, PlaybackMediaPipeline,
PlaybackMediaTransport, PlaybackResult, PlaybackTransportAction, PlaybackVideoCodec,
ProviderActor, ProviderContext, ProviderStore, ProviderStoreExt, SourceConfig, ProviderActor, ProviderContext, ProviderStore, ProviderStoreExt, SourceConfig,
VersionedPlayback, VersionedPlayback, CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@ -4463,6 +4520,57 @@ mod tests {
result.map_err(|error| anyhow::anyhow!(error.to_string())) result.map_err(|error| anyhow::anyhow!(error.to_string()))
} }
fn web_hls_profile() -> PlaybackClientProfile {
PlaybackClientProfile {
profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: PlaybackClientEnvironment::Web,
supported_video_codecs: vec![PlaybackVideoCodec::H264],
media_capabilities: vec![PlaybackMediaCapability {
transport: PlaybackMediaTransport::Hls,
container: None,
video_codec: Some(PlaybackVideoCodec::H264),
audio_codec: Some(PlaybackAudioCodec::Aac),
pipeline: PlaybackMediaPipeline::MediaSource,
codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()),
}],
supports_custom_http_headers: false,
supports_provider_proxy: true,
..PlaybackClientProfile::default()
}
}
fn durl_test_result() -> TestResult<PlaybackResult> {
let media = provider_ok(bilibili_durl_media(
"MP4",
[(
"https://cdn.example/video.mp4?deadline=200".to_string(),
Vec::new(),
1_000,
)],
"sm3_test_durl_web".to_string(),
))?;
Ok(PlaybackResult {
playback_infos: HashMap::from([(
"durl".to_string(),
PlaybackInfo {
thumbnail: None,
medias: vec![media],
default_media_index: Some(0),
subtitles: Vec::new(),
default_subtitle_index: None,
danmakus: Vec::new(),
default_danmaku_index: None,
},
)]),
default_mode: "durl".to_string(),
provider: crate::models::SourceProvider::Bilibili,
provider_instance_name: None,
duration_seconds: None,
playback_kind: Some(crate::models::PlaybackKind::Regular),
metadata: None,
})
}
#[test] #[test]
fn playback_cache_key_uses_the_codec_route_schema_version() -> TestResult { fn playback_cache_key_uses_the_codec_route_schema_version() -> TestResult {
let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig { let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig {
@ -4848,6 +4956,22 @@ mod tests {
}; };
assert!(!super::bilibili_live_uses_hls(Some(&native_profile))); assert!(!super::bilibili_live_uses_hls(Some(&native_profile)));
let web_flv_profile = super::super::PlaybackClientProfile {
profile_version: super::super::CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: super::super::PlaybackClientEnvironment::Web,
media_capabilities: vec![super::super::PlaybackMediaCapability {
transport: super::super::PlaybackMediaTransport::Flv,
container: None,
video_codec: Some(super::super::PlaybackVideoCodec::H264),
audio_codec: Some(super::super::PlaybackAudioCodec::Aac),
pipeline: super::super::PlaybackMediaPipeline::MediaSource,
codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()),
}],
supported_live_transports: Vec::new(),
..Default::default()
};
assert!(!super::bilibili_live_uses_hls(Some(&web_flv_profile)));
let config = super::BilibiliSourceConfig::Live(crate::models::BilibiliLiveSourceConfig { let config = super::BilibiliSourceConfig::Live(crate::models::BilibiliLiveSourceConfig {
room_id: 42, room_id: 42,
shared: false, shared: false,
@ -4863,8 +4987,14 @@ mod tests {
"anonymous", "anonymous",
Some(&native_profile), Some(&native_profile),
))?; ))?;
let (web_flv_key, _) = provider_ok(super::playback_cache_entry(
&config,
"anonymous",
Some(&web_flv_profile),
))?;
assert!(hls_key.ends_with(":hls")); assert!(hls_key.ends_with(":hls"));
assert!(flv_key.ends_with(":flv")); assert!(flv_key.ends_with(":flv"));
assert!(web_flv_key.ends_with(":flv"));
assert_ne!(hls_key, flv_key); assert_ne!(hls_key, flv_key);
Ok(()) Ok(())
} }
@ -5056,6 +5186,57 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn web_durl_with_required_headers_only_exposes_the_proxy_route() -> TestResult {
let profile = web_hls_profile();
let context = ProviderContext::new("test", ProviderActor::System)
.with_playback_client_profile(Some(profile));
let mut result = durl_test_result()?;
mark_bilibili_playback_resources(
&mut result,
"version",
123,
crate::models::PlaybackProxyMode::DirectPrefer,
Some(&context),
);
assert_eq!(result.default_mode, "proxy_durl");
assert!(!result.playback_infos.contains_key("durl"));
assert!(result.playback_infos.contains_key("proxy_durl"));
Ok(())
}
#[test]
fn web_direct_only_durl_returns_structured_client_incompatibility() -> TestResult {
let profile = web_hls_profile();
let context = ProviderContext::new("test", ProviderActor::System)
.with_playback_client_profile(Some(profile.clone()));
let mut result = durl_test_result()?;
mark_bilibili_playback_resources(
&mut result,
"version",
123,
crate::models::PlaybackProxyMode::DirectOnly,
Some(&context),
);
let error = super::super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::DirectOnly,
Some(&profile),
)
.expect_err("DirectOnly cannot satisfy browser header restrictions");
assert!(matches!(
error,
super::ProviderError::ClientIncompatible {
required_capability: Some(ref capability),
..
} if capability == "browser_direct_media_access_or_provider_proxy"
));
Ok(())
}
#[tokio::test] #[tokio::test]
async fn bilibili_subtitle_uses_full_response_cache_transport() -> TestResult { async fn bilibili_subtitle_uses_full_response_cache_transport() -> TestResult {
let store: Arc<dyn ProviderStore> = Arc::new(InMemoryProviderStore::new(8)); let store: Arc<dyn ProviderStore> = Arc::new(InMemoryProviderStore::new(8));
@ -6213,9 +6394,8 @@ fn bilibili_live_streams_request(
fn bilibili_live_uses_hls(profile: Option<&super::PlaybackClientProfile>) -> bool { fn bilibili_live_uses_hls(profile: Option<&super::PlaybackClientProfile>) -> bool {
!profile.is_some_and(|profile| { !profile.is_some_and(|profile| {
profile profile.supports_transport(super::PlaybackMediaTransport::Flv)
.supported_live_transports && !profile.supports_transport(super::PlaybackMediaTransport::Hls)
.contains(&super::PlaybackLiveTransport::Flv)
}) })
} }

@ -254,22 +254,48 @@ const fn cctv_stream_kind_name(kind: CctvPlaybackStreamKind) -> &'static str {
} }
} }
fn mark_cctv_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_cctv_playback_resources(
result: &mut PlaybackResult,
version: &str,
expires_at: i64,
client_profile: Option<&super::PlaybackClientProfile>,
) {
let original_default = result.default_mode.clone();
for (mode_name, info) in &mut result.playback_infos { for (mode_name, info) in &mut result.playback_infos {
for (media_index, media) in info.medias.iter_mut().enumerate() { let source_medias = std::mem::take(&mut info.medias);
if matches!( let (medias, default_media_index) = super::map_playback_resources(
media.provider, &source_medias,
PlaybackMediaProvider::Cctv(PlaybackCctvMedia::Refresh { .. }) info.default_media_index,
) { |media_index, media| {
media.provider = PlaybackMediaProvider::Cctv(PlaybackCctvMedia::Proxy { if !super::proxy_playback_media_supported_by_client(
version: version.to_string(), client_profile,
expires_at, mode_name,
mode_name: mode_name.clone(), media,
media_index, ) {
}); return None;
} }
} let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::Cctv(PlaybackCctvMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::Cctv(PlaybackCctvMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
info.medias = medias;
info.default_media_index = default_media_index;
} }
result
.playback_infos
.retain(|_, info| !info.medias.is_empty());
super::select_generated_playback_default(result, &original_default, true);
} }
#[async_trait] #[async_trait]
@ -287,17 +313,25 @@ impl MediaProvider for CctvProvider {
.map_err(|error| ProviderError::NetworkError(error.to_string()))?; .map_err(|error| ProviderError::NetworkError(error.to_string()))?;
let config = Self::config(source_config)?; let config = Self::config(source_config)?;
let resource = Self::resource(config)?; let resource = Self::resource(config)?;
super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&format!("playback:{}", config.resource), &format!("playback:{}", config.resource),
Duration::from_hours(2), Duration::from_hours(2),
ctx, ctx,
mark_cctv_playback_resources, |result, version, expires_at| {
mark_cctv_playback_resources(result, version, expires_at, client_profile);
},
|| async { || async {
Self::playback_result(&config.resource, self.client.resolve(&resource).await?) Self::playback_result(&config.resource, self.client.resolve(&resource).await?)
}, },
) )
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn validate_source_config( async fn validate_source_config(

@ -854,6 +854,7 @@ fn mark_cloudreve_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default_mode = result.default_mode.clone(); let original_default_mode = result.default_mode.clone();
let mut default_selection = super::PlaybackRouteSelection::DIRECT_ONLY; let mut default_selection = super::PlaybackRouteSelection::DIRECT_ONLY;
@ -873,7 +874,13 @@ fn mark_cloudreve_playback_resources(
default_selection = selection; default_selection = selection;
} }
if selection.direct { if selection.direct {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
@ -881,17 +888,20 @@ fn mark_cloudreve_playback_resources(
let proxy_mode_name = format!("proxy_{mode_name}"); let proxy_mode_name = format!("proxy_{mode_name}");
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |media_index, media| {
.filter(|(_, media)| { if !super::proxy_playback_media_supported_by_client(
matches!( client_profile,
&mode_name,
media,
) || !matches!(
media.provider, media.provider,
PlaybackMediaProvider::Cloudreve(PlaybackCloudreveMedia::Direct { .. }) PlaybackMediaProvider::Cloudreve(PlaybackCloudreveMedia::Direct { .. })
) ) {
}) return None;
.map(|(media_index, media)| { }
let mut proxy = media.clone(); let mut proxy = media.clone();
proxy.expire_at = chrono::DateTime::from_timestamp(expires_at, 0); proxy.expire_at = chrono::DateTime::from_timestamp(expires_at, 0);
proxy.provider = PlaybackMediaProvider::Cloudreve( proxy.provider = PlaybackMediaProvider::Cloudreve(
@ -911,9 +921,11 @@ fn mark_cloudreve_playback_resources(
} }
}, },
); );
proxy Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
proxy_info.subtitles = original_info proxy_info.subtitles = original_info
.subtitles .subtitles
.iter() .iter()
@ -1094,7 +1106,7 @@ impl MediaProvider for CloudreveProvider {
metadata: None, metadata: None,
}; };
let proxy_mode = config.proxy_mode; let proxy_mode = config.proxy_mode;
super::cached_versioned_playback_or_fill( let result = super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&format!( &format!(
"playback:{user_id}:{}:room:{}:{}", "playback:{user_id}:{}:room:{}:{}",
@ -1106,11 +1118,18 @@ impl MediaProvider for CloudreveProvider {
PLAYBACK_CACHE_TTL, PLAYBACK_CACHE_TTL,
ctx, ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_cloudreve_playback_resources(result, version, expires_at, proxy_mode); mark_cloudreve_playback_resources(
result,
version,
expires_at,
proxy_mode,
ctx.playback_client_profile(),
);
}, },
|| async { Ok(result) }, || async { Ok(result) },
) )
.await .await?;
super::require_compatible_playback_route(result, proxy_mode, ctx.playback_client_profile())
} }
async fn validate_source_config( async fn validate_source_config(
@ -1830,6 +1849,7 @@ mod tests {
"version-1", "version-1",
1_900_000_000, 1_900_000_000,
crate::models::PlaybackProxyMode::Prefer, crate::models::PlaybackProxyMode::Prefer,
None,
); );
let direct = &result.playback_infos["direct"]; let direct = &result.playback_infos["direct"];
@ -1864,6 +1884,7 @@ mod tests {
"version-1", "version-1",
1_900_000_000, 1_900_000_000,
crate::models::PlaybackProxyMode::Auto, crate::models::PlaybackProxyMode::Auto,
None,
); );
assert!(matches!( assert!(matches!(
@ -1880,6 +1901,7 @@ mod tests {
"version-1", "version-1",
1_900_000_000, 1_900_000_000,
crate::models::PlaybackProxyMode::Prefer, crate::models::PlaybackProxyMode::Prefer,
None,
); );
assert_eq!(preferred.default_mode, "proxy_direct"); assert_eq!(preferred.default_mode, "proxy_direct");
assert!(preferred.playback_infos.contains_key("direct")); assert!(preferred.playback_infos.contains_key("direct"));
@ -1890,6 +1912,7 @@ mod tests {
"version-1", "version-1",
1_900_000_000, 1_900_000_000,
crate::models::PlaybackProxyMode::Only, crate::models::PlaybackProxyMode::Only,
None,
); );
assert_eq!(proxy_only.default_mode, "proxy_direct"); assert_eq!(proxy_only.default_mode, "proxy_direct");
assert!(!proxy_only.playback_infos.contains_key("direct")); assert!(!proxy_only.playback_infos.contains_key("direct"));

@ -452,6 +452,7 @@ fn mark_direct_url_playback_resources(
expires_at: i64, expires_at: i64,
selection: super::PlaybackRouteSelection, selection: super::PlaybackRouteSelection,
auto_mode: bool, auto_mode: bool,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default_mode = result.default_mode.clone(); let original_default_mode = result.default_mode.clone();
let auto_default_prefers_proxy = auto_mode let auto_default_prefers_proxy = auto_mode
@ -559,7 +560,11 @@ fn mark_direct_url_playback_resources(
.map(|(_, danmaku)| danmaku.clone()) .map(|(_, danmaku)| danmaku.clone())
.collect(); .collect();
} }
if !direct_info.medias.is_empty() { if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&direct_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info); generated.insert(mode_name.clone(), direct_info);
} }
} }
@ -575,6 +580,13 @@ fn mark_direct_url_playback_resources(
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(url_index, media)| { .filter_map(|(url_index, media)| {
if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let url = media.upstream_url()?.to_string(); let url = media.upstream_url()?.to_string();
let headers = media.upstream_headers(); let headers = media.upstream_headers();
Some(( Some((
@ -925,6 +937,7 @@ impl MediaProvider for DirectUrlProvider {
}; };
let route_selection = direct_url_route_selection(config); let route_selection = direct_url_route_selection(config);
let auto_mode = config.proxy_mode == crate::models::PlaybackProxyMode::Auto; let auto_mode = config.proxy_mode == crate::models::PlaybackProxyMode::Auto;
let client_profile = _ctx.playback_client_profile();
Self::validate_config_shape(config)?; Self::validate_config_shape(config)?;
for media in &config.medias { for media in &config.medias {
Self::validate_source_url(&media.url, &self.ssrf_guard)?; Self::validate_source_url(&media.url, &self.ssrf_guard)?;
@ -990,6 +1003,7 @@ impl MediaProvider for DirectUrlProvider {
expires_at, expires_at,
route_selection, route_selection,
auto_mode, auto_mode,
client_profile,
); );
}, },
) )
@ -1196,7 +1210,7 @@ impl MediaProvider for DirectUrlProvider {
metadata: Some(metadata), metadata: Some(metadata),
}; };
super::cache_versioned_playback_and_build_response( let result = super::cache_versioned_playback_and_build_response(
result, result,
Self::NAME, Self::NAME,
&cache_key, &cache_key,
@ -1209,10 +1223,12 @@ impl MediaProvider for DirectUrlProvider {
expires_at, expires_at,
route_selection, route_selection,
auto_mode, auto_mode,
client_profile,
); );
}, },
) )
.await .await?;
super::require_compatible_playback_route(result, config.proxy_mode, client_profile)
} }
} }
@ -1227,6 +1243,82 @@ mod tests {
)) ))
} }
fn web_progressive_profile() -> super::super::PlaybackClientProfile {
super::super::PlaybackClientProfile {
profile_version: super::super::CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: super::super::PlaybackClientEnvironment::Web,
media_capabilities: vec![super::super::PlaybackMediaCapability {
transport: super::super::PlaybackMediaTransport::Progressive,
container: Some(super::super::PlaybackContainer::Mp4),
video_codec: Some(super::super::PlaybackVideoCodec::H264),
audio_codec: Some(super::super::PlaybackAudioCodec::Aac),
pipeline: super::super::PlaybackMediaPipeline::Native,
codec_string: None,
}],
supports_custom_http_headers: false,
supports_provider_proxy: true,
supports_insecure_http_media: false,
..Default::default()
}
}
#[tokio::test]
async fn web_header_bound_media_only_generates_the_proxy_route() {
let provider = DirectUrlProvider::new();
let ctx = test_context().with_playback_client_profile(Some(web_progressive_profile()));
let mut config = crate::models::DirectUrlMediaSourceConfig::single(
"https://example.com/video.mp4".to_string(),
HashMap::from([("Authorization".to_string(), "Bearer secret".to_string())]),
);
config.proxy_mode = crate::models::PlaybackProxyMode::DirectPrefer;
let result = provider
.generate_playback(&ctx, &crate::models::MediaSourceConfig::DirectUrl(config))
.await
.expect("the provider proxy satisfies browser header restrictions");
assert!(!result.playback_infos.contains_key("direct"));
assert!(result.playback_infos.contains_key("proxy_direct"));
assert_eq!(result.default_mode, "proxy_direct");
}
#[tokio::test]
async fn proxy_locator_keeps_the_cached_source_index_after_capability_selection() {
let provider = DirectUrlProvider::new();
let ctx = test_context().with_playback_client_profile(Some(web_progressive_profile()));
let mut config = crate::models::DirectUrlMediaSourceConfig::single(
"https://example.com/live.m3u8".to_string(),
HashMap::new(),
);
config.proxy_mode = crate::models::PlaybackProxyMode::Only;
config
.medias
.push(crate::models::DirectUrlMediaResourceConfig {
name: "compatible".to_string(),
url: "https://example.com/video.mp4".to_string(),
headers: HashMap::new(),
format: "mp4".to_string(),
expires_at: None,
});
config.default_media_index = Some(1);
let result = provider
.generate_playback(&ctx, &crate::models::MediaSourceConfig::DirectUrl(config))
.await
.expect("the compatible fallback should generate");
let info = &result.playback_infos["proxy_direct"];
assert_eq!(info.medias.len(), 1);
assert_eq!(info.default_media_index, Some(0));
assert!(matches!(
info.medias[0].provider,
PlaybackMediaProvider::DirectUrl(PlaybackDirectUrlMedia::ProxyStream {
url_index: 1,
..
})
));
}
#[tokio::test] #[tokio::test]
async fn generate_playback_marks_plain_file_video_probeable() { async fn generate_playback_marks_plain_file_video_probeable() {
let provider = DirectUrlProvider::new(); let provider = DirectUrlProvider::new();

@ -835,21 +835,65 @@ impl DouyinProvider {
} }
} }
fn mark_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_playback_resources(
for (mode_name, info) in &mut result.playback_infos { result: &mut PlaybackResult,
for (media_index, media) in info.medias.iter_mut().enumerate() { version: &str,
if matches!( expires_at: i64,
media.provider, client_profile: Option<&super::PlaybackClientProfile>,
PlaybackMediaProvider::Douyin(PlaybackDouyinMedia::Refresh { .. }) ) {
) { let original_default = result.default_mode.clone();
media.provider = PlaybackMediaProvider::Douyin(PlaybackDouyinMedia::Proxy { let original_modes = std::mem::take(&mut result.playback_infos);
version: version.to_string(), for (mode_name, mut info) in original_modes {
expires_at, let source_medias = std::mem::take(&mut info.medias);
mode_name: mode_name.clone(), let supported_indices = source_medias
media_index, .iter()
}); .enumerate()
} .filter_map(|(media_index, media)| {
super::proxy_playback_media_supported_by_client(client_profile, &mode_name, media)
.then_some(media_index)
})
.collect::<std::collections::HashSet<_>>();
let (medias, default_media_index) = super::map_playback_resources(
&source_medias,
info.default_media_index,
|media_index, media| {
if !supported_indices.contains(&media_index) {
return None;
}
let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::Douyin(PlaybackDouyinMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::Douyin(PlaybackDouyinMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
if medias.is_empty() {
continue;
} }
info.medias = medias;
info.default_media_index = default_media_index;
let (danmakus, default_danmaku_index) = super::map_playback_resources(
&info.danmakus,
info.default_danmaku_index,
|_, danmaku| {
(!matches!(
&danmaku.provider,
PlaybackDanmakuProvider::Douyin(PlaybackDouyinDanmaku::Refresh { media_index })
if !supported_indices.contains(media_index)
))
.then(|| danmaku.clone())
},
);
info.danmakus = danmakus;
info.default_danmaku_index = default_danmaku_index;
for danmaku in &mut info.danmakus { for danmaku in &mut info.danmakus {
let PlaybackDanmakuProvider::Douyin(PlaybackDouyinDanmaku::Refresh { media_index }) = let PlaybackDanmakuProvider::Douyin(PlaybackDouyinDanmaku::Refresh { media_index }) =
&danmaku.provider &danmaku.provider
@ -863,7 +907,9 @@ fn mark_playback_resources(result: &mut PlaybackResult, version: &str, expires_a
media_index: *media_index, media_index: *media_index,
}); });
} }
result.playback_infos.insert(mode_name, info);
} }
super::select_generated_playback_default(result, &original_default, true);
} }
fn stream_format(format: DouyinStreamFormat) -> &'static str { fn stream_format(format: DouyinStreamFormat) -> &'static str {
@ -964,12 +1010,15 @@ impl MediaProvider for DouyinProvider {
credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()), credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()),
Self::credential_server_id_for_instance(provider_instance_name.as_deref()) Self::credential_server_id_for_instance(provider_instance_name.as_deref())
); );
super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&cache_key, &cache_key,
Duration::from_mins(30), Duration::from_mins(30),
ctx, ctx,
mark_playback_resources, |result, version, expires_at| {
mark_playback_resources(result, version, expires_at, client_profile);
},
|| async { || async {
let media = self.resolve_media(config, &session).await?; let media = self.resolve_media(config, &session).await?;
Self::playback_result( Self::playback_result(
@ -980,7 +1029,12 @@ impl MediaProvider for DouyinProvider {
) )
}, },
) )
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn media_metadata( async fn media_metadata(

@ -353,21 +353,61 @@ const fn playback_format(format: DouyuStreamFormat) -> DouyuPlaybackFormat {
} }
} }
fn mark_douyu_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_douyu_playback_resources(
result: &mut PlaybackResult,
version: &str,
expires_at: i64,
client_profile: Option<&super::PlaybackClientProfile>,
) {
let original_default = result.default_mode.clone();
for (mode_name, info) in &mut result.playback_infos { for (mode_name, info) in &mut result.playback_infos {
for (media_index, media) in info.medias.iter_mut().enumerate() { let source_medias = std::mem::take(&mut info.medias);
if matches!( let supported_indices = source_medias
media.provider, .iter()
PlaybackMediaProvider::Douyu(PlaybackDouyuMedia::Refresh { .. }) .enumerate()
) { .filter_map(|(media_index, media)| {
media.provider = PlaybackMediaProvider::Douyu(PlaybackDouyuMedia::Proxy { super::proxy_playback_media_supported_by_client(client_profile, mode_name, media)
version: version.to_string(), .then_some(media_index)
expires_at, })
mode_name: mode_name.clone(), .collect::<std::collections::HashSet<_>>();
media_index, let (medias, default_media_index) = super::map_playback_resources(
}); &source_medias,
} info.default_media_index,
} |media_index, media| {
if !supported_indices.contains(&media_index) {
return None;
}
let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::Douyu(PlaybackDouyuMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::Douyu(PlaybackDouyuMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
info.medias = medias;
info.default_media_index = default_media_index;
let (danmakus, default_danmaku_index) = super::map_playback_resources(
&info.danmakus,
info.default_danmaku_index,
|_, danmaku| {
(!matches!(
&danmaku.provider,
PlaybackDanmakuProvider::Douyu(PlaybackDouyuDanmaku::Refresh { media_index })
if !supported_indices.contains(media_index)
))
.then(|| danmaku.clone())
},
);
info.danmakus = danmakus;
info.default_danmaku_index = default_danmaku_index;
for danmaku in &mut info.danmakus { for danmaku in &mut info.danmakus {
let PlaybackDanmakuProvider::Douyu(PlaybackDouyuDanmaku::Refresh { media_index }) = let PlaybackDanmakuProvider::Douyu(PlaybackDouyuDanmaku::Refresh { media_index }) =
&danmaku.provider &danmaku.provider
@ -382,6 +422,10 @@ fn mark_douyu_playback_resources(result: &mut PlaybackResult, version: &str, exp
}); });
} }
} }
result
.playback_infos
.retain(|_, info| !info.medias.is_empty());
super::select_generated_playback_default(result, &original_default, true);
} }
#[async_trait] #[async_trait]
@ -419,15 +463,23 @@ impl MediaProvider for DouyuProvider {
ctx.check_active() ctx.check_active()
.map_err(|error| ProviderError::NetworkError(error.to_string()))?; .map_err(|error| ProviderError::NetworkError(error.to_string()))?;
let resource = Self::resource(Self::config(source_config)?)?; let resource = Self::resource(Self::config(source_config)?)?;
super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&Self::cache_key(&resource), &Self::cache_key(&resource),
Duration::from_mins(2), Duration::from_mins(2),
ctx, ctx,
mark_douyu_playback_resources, |result, version, expires_at| {
mark_douyu_playback_resources(result, version, expires_at, client_profile);
},
|| async { Self::playback_result(self.client.resolve(&resource, None).await?) }, || async { Self::playback_result(self.client.resolve(&resource, None).await?) },
) )
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn validate_source_config( async fn validate_source_config(

@ -156,6 +156,7 @@ fn mark_emby_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&PlaybackClientProfile>,
) { ) {
// Emby exposes upstream modes and SyncTV proxy siblings together. // Emby exposes upstream modes and SyncTV proxy siblings together.
// Upstream token headers remain visible by product policy; administrators // Upstream token headers remain visible by product policy; administrators
@ -178,7 +179,13 @@ fn mark_emby_playback_resources(
default_selection = selection; default_selection = selection;
} }
if selection.direct { if selection.direct {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
@ -187,11 +194,17 @@ fn mark_emby_playback_resources(
let proxy_mode_name = format!("proxy_{mode_name}"); let proxy_mode_name = format!("proxy_{mode_name}");
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |url_index, media| {
.filter_map(|(url_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let url = media.upstream_url()?.to_string(); let url = media.upstream_url()?.to_string();
let mut proxy = playback_media( let mut proxy = playback_media(
media.name.clone(), media.name.clone(),
@ -221,8 +234,10 @@ fn mark_emby_playback_resources(
); );
proxy.p2p_swarm_id.clone_from(&media.p2p_swarm_id); proxy.p2p_swarm_id.clone_from(&media.p2p_swarm_id);
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
proxy_info.subtitles = original_info proxy_info.subtitles = original_info
.subtitles .subtitles
.iter() .iter()
@ -1953,7 +1968,13 @@ impl MediaProvider for EmbyProvider {
cache_ttl, cache_ttl,
_ctx, _ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_emby_playback_resources(result, version, expires_at, proxy_mode); mark_emby_playback_resources(
result,
version,
expires_at,
proxy_mode,
playback_client_profile,
);
}, },
|| async { || async {
self.resolve_from_api(&resolved, _ctx.request_context(), playback_client_profile) self.resolve_from_api(&resolved, _ctx.request_context(), playback_client_profile)
@ -1962,6 +1983,9 @@ impl MediaProvider for EmbyProvider {
)) ))
.await?; .await?;
let result =
super::require_compatible_playback_route(result, proxy_mode, playback_client_profile)?;
let Some(play_session_id) = emby_play_session_id(&result) else { let Some(play_session_id) = emby_play_session_id(&result) else {
return Ok(result); return Ok(result);
}; };
@ -3148,6 +3172,7 @@ mod tests {
"version-1", "version-1",
1234, 1234,
crate::models::PlaybackProxyMode::Prefer, crate::models::PlaybackProxyMode::Prefer,
None,
); );
assert!(matches!( assert!(matches!(

@ -39,6 +39,12 @@ pub enum ProviderError {
#[error("Unsupported format: {0}")] #[error("Unsupported format: {0}")]
UnsupportedFormat(String), UnsupportedFormat(String),
#[error("Client cannot play this resource: {reason}")]
ClientIncompatible {
reason: String,
required_capability: Option<String>,
},
#[error("Parse error: {0}")] #[error("Parse error: {0}")]
ParseError(String), ParseError(String),

@ -1626,6 +1626,7 @@ fn mark_fnos_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
selection: super::PlaybackRouteSelection, selection: super::PlaybackRouteSelection,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default_mode = result.default_mode.clone(); let original_default_mode = result.default_mode.clone();
let original_modes = result let original_modes = result
@ -1640,7 +1641,13 @@ fn mark_fnos_playback_resources(
} }
if mode_name.starts_with("direct_") { if mode_name.starts_with("direct_") {
if selection.direct { if selection.direct {
generated.insert(mode_name, original_info); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name, direct_info);
}
} }
continue; continue;
} }
@ -1648,11 +1655,17 @@ fn mark_fnos_playback_resources(
continue; continue;
} }
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |media_index, media| {
.filter_map(|(media_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let (credential_owner_id, server_id, resource) = match &media.provider { let (credential_owner_id, server_id, resource) = match &media.provider {
PlaybackMediaProvider::Fnos(PlaybackFnosMedia::FileRefresh { PlaybackMediaProvider::Fnos(PlaybackFnosMedia::FileRefresh {
credential_owner_id, credential_owner_id,
@ -1711,8 +1724,10 @@ fn mark_fnos_playback_resources(
resource, resource,
}); });
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
if proxy_info.medias.is_empty() { if proxy_info.medias.is_empty() {
continue; continue;
} }
@ -2425,18 +2440,22 @@ impl MediaProvider for FnosProvider {
Duration::from_hours(2), Duration::from_hours(2),
ctx, ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_fnos_playback_resources(result, version, expires_at, selection); mark_fnos_playback_resources(
result,
version,
expires_at,
selection,
ctx.playback_client_profile(),
);
}, },
|| async { Ok(result) }, || async { Ok(result) },
) )
.await?; .await?;
if config.proxy_mode == crate::models::PlaybackProxyMode::DirectOnly let result = super::require_compatible_playback_route(
&& result.playback_infos.is_empty() result,
{ config.proxy_mode,
return Err(ProviderError::UnsupportedFormat( ctx.playback_client_profile(),
"This FNOS media source cannot provide a direct playback route".to_string(), )?;
));
}
let media = result let media = result
.metadata .metadata
@ -3450,6 +3469,7 @@ mod tests {
"version", "version",
123, 123,
fnos_route_selection(crate::models::PlaybackProxyMode::Auto), fnos_route_selection(crate::models::PlaybackProxyMode::Auto),
None,
); );
assert!(matches!( assert!(matches!(
@ -3520,6 +3540,7 @@ mod tests {
"version", "version",
123, 123,
fnos_route_selection(crate::models::PlaybackProxyMode::Auto), fnos_route_selection(crate::models::PlaybackProxyMode::Auto),
None,
); );
let info = &result.playback_infos["proxy_direct"]; let info = &result.playback_infos["proxy_direct"];

@ -407,21 +407,61 @@ const fn huya_format_name(format: HuyaPlaybackFormat) -> &'static str {
} }
} }
fn mark_huya_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_huya_playback_resources(
result: &mut PlaybackResult,
version: &str,
expires_at: i64,
client_profile: Option<&super::PlaybackClientProfile>,
) {
let original_default = result.default_mode.clone();
for (mode_name, info) in &mut result.playback_infos { for (mode_name, info) in &mut result.playback_infos {
for (media_index, media) in info.medias.iter_mut().enumerate() { let source_medias = std::mem::take(&mut info.medias);
if matches!( let supported_indices = source_medias
media.provider, .iter()
PlaybackMediaProvider::Huya(PlaybackHuyaMedia::Refresh { .. }) .enumerate()
) { .filter_map(|(media_index, media)| {
media.provider = PlaybackMediaProvider::Huya(PlaybackHuyaMedia::Proxy { super::proxy_playback_media_supported_by_client(client_profile, mode_name, media)
version: version.to_string(), .then_some(media_index)
expires_at, })
mode_name: mode_name.clone(), .collect::<std::collections::HashSet<_>>();
media_index, let (medias, default_media_index) = super::map_playback_resources(
}); &source_medias,
} info.default_media_index,
} |media_index, media| {
if !supported_indices.contains(&media_index) {
return None;
}
let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::Huya(PlaybackHuyaMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::Huya(PlaybackHuyaMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
info.medias = medias;
info.default_media_index = default_media_index;
let (danmakus, default_danmaku_index) = super::map_playback_resources(
&info.danmakus,
info.default_danmaku_index,
|_, danmaku| {
(!matches!(
&danmaku.provider,
PlaybackDanmakuProvider::Huya(PlaybackHuyaDanmaku::Refresh { media_index })
if !supported_indices.contains(media_index)
))
.then(|| danmaku.clone())
},
);
info.danmakus = danmakus;
info.default_danmaku_index = default_danmaku_index;
for danmaku in &mut info.danmakus { for danmaku in &mut info.danmakus {
let PlaybackDanmakuProvider::Huya(PlaybackHuyaDanmaku::Refresh { media_index }) = let PlaybackDanmakuProvider::Huya(PlaybackHuyaDanmaku::Refresh { media_index }) =
&danmaku.provider &danmaku.provider
@ -436,6 +476,10 @@ fn mark_huya_playback_resources(result: &mut PlaybackResult, version: &str, expi
}); });
} }
} }
result
.playback_infos
.retain(|_, info| !info.medias.is_empty());
super::select_generated_playback_default(result, &original_default, true);
} }
#[async_trait] #[async_trait]
@ -485,15 +529,23 @@ impl MediaProvider for HuyaProvider {
HuyaResourceKind::Live => Duration::from_mins(2), HuyaResourceKind::Live => Duration::from_mins(2),
HuyaResourceKind::Video => Duration::from_hours(2), HuyaResourceKind::Video => Duration::from_hours(2),
}; };
super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&Self::cache_key(&resource), &Self::cache_key(&resource),
cache_ttl, cache_ttl,
ctx, ctx,
mark_huya_playback_resources, |result, version, expires_at| {
mark_huya_playback_resources(result, version, expires_at, client_profile);
},
|| async { Self::playback_result(self.client.resolve(&resource, None).await?) }, || async { Self::playback_result(self.client.resolve(&resource, None).await?) },
) )
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn validate_source_config( async fn validate_source_config(

@ -235,12 +235,23 @@ impl LiveProxyProvider {
} }
} }
fn mark_live_proxy_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_live_proxy_playback_resources(
result: &mut PlaybackResult,
version: &str,
expires_at: i64,
client_profile: Option<&super::PlaybackClientProfile>,
) {
// Live proxy playback is owned by SyncTV, so generated modes point at // Live proxy playback is owned by SyncTV, so generated modes point at
// provider-playback transport actions directly. // provider-playback transport actions directly.
let default_mode = result.default_mode.clone(); let default_mode = result.default_mode.clone();
for (mode_name, info) in &mut result.playback_infos { let original_modes = std::mem::take(&mut result.playback_infos);
let is_hls = super::playback_info_is_hls(mode_name, info); for (mode_name, info) in original_modes {
let Some(mut info) =
super::build_proxy_playback_info_for_client(&mode_name, &info, client_profile)
else {
continue;
};
let is_hls = super::playback_info_is_hls(&mode_name, &info);
let media_count = info.medias.len(); let media_count = info.medias.len();
for media in &mut info.medias { for media in &mut info.medias {
let (room_id, media_id) = match &media.provider { let (room_id, media_id) = match &media.provider {
@ -262,7 +273,7 @@ fn mark_live_proxy_playback_resources(result: &mut PlaybackResult, version: &str
room_id, room_id,
media_id, media_id,
}) })
} else if mode_name == "flv" || (mode_name == &default_mode && media_count == 1) { } else if mode_name == "flv" || (mode_name == default_mode && media_count == 1) {
PlaybackMediaProvider::LiveProxy(PlaybackLiveProxyMedia::FlvStream { PlaybackMediaProvider::LiveProxy(PlaybackLiveProxyMedia::FlvStream {
version: version.to_string(), version: version.to_string(),
expires_at, expires_at,
@ -273,7 +284,9 @@ fn mark_live_proxy_playback_resources(result: &mut PlaybackResult, version: &str
continue; continue;
}; };
} }
result.playback_infos.insert(mode_name, info);
} }
super::select_generated_playback_default(result, &default_mode, true);
} }
fn default_live_proxy_mode(profile: Option<&super::PlaybackClientProfile>) -> &'static str { fn default_live_proxy_mode(profile: Option<&super::PlaybackClientProfile>) -> &'static str {
@ -329,15 +342,23 @@ impl MediaProvider for LiveProxyProvider {
let cache_key = format!("playback:{room_id}:{media_id}"); let cache_key = format!("playback:{room_id}:{media_id}");
let cache_ttl = Duration::from_mins(5); let cache_ttl = Duration::from_mins(5);
super::cache_versioned_playback_and_build_response( let client_profile = ctx.playback_client_profile();
let result = super::cache_versioned_playback_and_build_response(
result, result,
Self::NAME, Self::NAME,
&cache_key, &cache_key,
cache_ttl, cache_ttl,
ctx, ctx,
mark_live_proxy_playback_resources, |result, version, expires_at| {
mark_live_proxy_playback_resources(result, version, expires_at, client_profile);
},
)
.await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
) )
.await
} }
async fn validate_source_config( async fn validate_source_config(

@ -65,8 +65,10 @@ pub use p2p_media::{
P2pResourceDelivery, P2pResourceDelivery,
}; };
pub use playback_profile::{ pub use playback_profile::{
PlaybackAudioCapability, PlaybackClientProfile, PlaybackContainer, PlaybackLiveTransport, PlaybackAudioCapability, PlaybackAudioCodec, PlaybackClientEnvironment, PlaybackClientProfile,
PlaybackStreamPreference, PlaybackSubtitlePreference, PlaybackVideoCodec, PlaybackContainer, PlaybackLiveTransport, PlaybackMediaCapability, PlaybackMediaPipeline,
PlaybackMediaTransport, PlaybackStreamPreference, PlaybackSubtitlePreference,
PlaybackVideoCodec, CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
}; };
pub use playback_transport::{ pub use playback_transport::{
HlsResourceRequest, LiveFlvAccess, PlaybackResourceProxyStrategy, PlaybackTransportAction, HlsResourceRequest, LiveFlvAccess, PlaybackResourceProxyStrategy, PlaybackTransportAction,
@ -123,6 +125,312 @@ pub(crate) fn playback_profile_cache_token(profile: Option<&PlaybackClientProfil
) )
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PlaybackMediaRequirements {
pub transport: PlaybackMediaTransport,
pub container: Option<PlaybackContainer>,
pub video_codec: Option<PlaybackVideoCodec>,
pub audio_codec: Option<PlaybackAudioCodec>,
}
fn playback_video_codec(value: &str) -> Option<PlaybackVideoCodec> {
let value = value.trim().to_ascii_lowercase();
if value.starts_with("avc") || value.starts_with("h264") {
Some(PlaybackVideoCodec::H264)
} else if value.starts_with("hev")
|| value.starts_with("hvc")
|| value.starts_with("hevc")
|| value.starts_with("h265")
{
Some(PlaybackVideoCodec::Hevc)
} else if value.starts_with("vp9") || value.starts_with("vp09") {
Some(PlaybackVideoCodec::Vp9)
} else if value.starts_with("av1") || value.starts_with("av01") {
Some(PlaybackVideoCodec::Av1)
} else {
None
}
}
pub(crate) fn playback_media_requirements(
mode_name: &str,
media: &PlaybackMedia,
) -> Option<PlaybackMediaRequirements> {
let format = media.format.trim().to_ascii_lowercase();
let mode_name = mode_name.trim().to_ascii_lowercase();
let video_codec = media
.metadata
.as_ref()
.and_then(|metadata| metadata.codec.as_deref())
.and_then(playback_video_codec)
.or_else(|| playback_video_codec(&mode_name));
let (transport, container, default_video_codec, audio_codec) = match format.as_str() {
"mpd" | "dash" => (
PlaybackMediaTransport::Dash,
Some(PlaybackContainer::Mp4),
None,
Some(PlaybackAudioCodec::Aac),
),
"m3u8" | "hls" => (
PlaybackMediaTransport::Hls,
None,
(mode_name.contains("durl") || mode_name == "mp4").then_some(PlaybackVideoCodec::H264),
(mode_name.contains("durl") || mode_name == "mp4").then_some(PlaybackAudioCodec::Aac),
),
"flv" => (
PlaybackMediaTransport::Flv,
None,
None,
Some(PlaybackAudioCodec::Aac),
),
"ts" | "mpegts" | "mpeg-ts" => (
PlaybackMediaTransport::MpegTs,
None,
None,
Some(PlaybackAudioCodec::Aac),
),
"mp4" | "m4v" => (
PlaybackMediaTransport::Progressive,
Some(PlaybackContainer::Mp4),
None,
None,
),
"mkv" => (
PlaybackMediaTransport::Progressive,
Some(PlaybackContainer::Mkv),
None,
None,
),
"webm" => (
PlaybackMediaTransport::Progressive,
Some(PlaybackContainer::Webm),
None,
None,
),
_ => return None,
};
Some(PlaybackMediaRequirements {
transport,
container,
video_codec: video_codec.or(default_video_codec),
audio_codec,
})
}
pub(crate) fn playback_media_supported_by_client(
profile: Option<&PlaybackClientProfile>,
mode_name: &str,
media: &PlaybackMedia,
) -> bool {
let Some(profile) = profile.filter(|profile| profile.uses_explicit_capabilities()) else {
return true;
};
playback_media_requirements(mode_name, media).is_some_and(|requirements| {
profile.supports_media(
requirements.transport,
requirements.container,
requirements.video_codec,
requirements.audio_codec,
)
})
}
pub(crate) fn direct_playback_media_supported_by_client(
profile: Option<&PlaybackClientProfile>,
mode_name: &str,
media: &PlaybackMedia,
) -> bool {
let Some(profile) = profile.filter(|profile| profile.uses_explicit_capabilities()) else {
return true;
};
if !media.upstream_headers().is_empty() && !profile.supports_custom_http_headers {
return false;
}
if !direct_http_resource_supported_by_client(profile, media.upstream_url()) {
return false;
}
let Some(requirements) = playback_media_requirements(mode_name, media) else {
return false;
};
if !profile.supports_media(
requirements.transport,
requirements.container,
requirements.video_codec,
requirements.audio_codec,
) {
return false;
}
// JavaScript MSE loaders fetch manifests and segments and therefore depend
// on upstream CORS. A native HTML media pipeline can consume a direct URL;
// other Web pipelines use the same-origin provider proxy.
!profile.is_web()
|| profile.supports_media_with_pipeline(
requirements.transport,
requirements.container,
requirements.video_codec,
requirements.audio_codec,
PlaybackMediaPipeline::Native,
)
}
fn direct_http_resource_supported_by_client(
profile: &PlaybackClientProfile,
url: Option<&str>,
) -> bool {
!profile.is_web()
|| profile.supports_insecure_http_media
|| url.is_none_or(|url| {
!url::Url::parse(url).is_ok_and(|parsed| parsed.scheme().eq_ignore_ascii_case("http"))
})
}
pub(crate) fn proxy_playback_media_supported_by_client(
profile: Option<&PlaybackClientProfile>,
mode_name: &str,
media: &PlaybackMedia,
) -> bool {
profile.is_none_or(|profile| {
(!profile.uses_explicit_capabilities() || profile.supports_provider_proxy)
&& playback_media_supported_by_client(Some(profile), mode_name, media)
})
}
pub(crate) fn require_compatible_playback_route(
result: PlaybackResult,
proxy_mode: crate::models::PlaybackProxyMode,
profile: Option<&PlaybackClientProfile>,
) -> Result<PlaybackResult, ProviderError> {
if !result.playback_infos.is_empty() {
return Ok(result);
}
if profile.is_some_and(PlaybackClientProfile::uses_explicit_capabilities) {
let required_capability = if matches!(
proxy_mode,
crate::models::PlaybackProxyMode::Only | crate::models::PlaybackProxyMode::Auto
) && profile
.is_some_and(|profile| !profile.supports_provider_proxy)
{
Some("provider_proxy".to_string())
} else if matches!(proxy_mode, crate::models::PlaybackProxyMode::DirectOnly)
&& profile.is_some_and(|profile| {
profile.is_web()
&& (!profile.supports_custom_http_headers
|| !profile.supports_insecure_http_media)
})
{
Some("browser_direct_media_access_or_provider_proxy".to_string())
} else {
Some("media_transport_codec_combination".to_string())
};
return Err(ProviderError::ClientIncompatible {
reason: format!(
"No playback route matches the client capabilities and proxy mode {proxy_mode:?}"
),
required_capability,
});
}
require_direct_playback_route(result, proxy_mode)
}
pub(crate) fn map_playback_resources<T, U>(
resources: &[T],
default_index: Option<usize>,
mut map: impl FnMut(usize, &T) -> Option<U>,
) -> (Vec<U>, Option<usize>) {
let mut mapped_default = None;
let mut mapped = Vec::with_capacity(resources.len());
for (source_index, resource) in resources.iter().enumerate() {
let Some(resource) = map(source_index, resource) else {
continue;
};
if default_index == Some(source_index) {
mapped_default = Some(mapped.len());
}
mapped.push(resource);
}
(mapped, mapped_default)
}
/// Build one direct route from a provider's cached upstream response.
///
/// Providers call this before inserting the route into the generated result,
/// so unsupported resources never become public playback routes.
pub(crate) fn build_direct_playback_info_for_client(
mode_name: &str,
source: &PlaybackInfo,
profile: Option<&PlaybackClientProfile>,
) -> Option<PlaybackInfo> {
if source.medias.is_empty() {
return None;
}
let Some(profile) = profile.filter(|profile| profile.uses_explicit_capabilities()) else {
return Some(source.clone());
};
let (medias, default_media_index) =
map_playback_resources(&source.medias, source.default_media_index, |_, media| {
direct_playback_media_supported_by_client(Some(profile), mode_name, media)
.then(|| media.clone())
});
if medias.is_empty() {
return None;
}
let (subtitles, default_subtitle_index) = map_playback_resources(
&source.subtitles,
source.default_subtitle_index,
|_, subtitle| {
(subtitle.requires_provider_url()
|| (direct_http_resource_supported_by_client(
profile,
Some(subtitle.upstream_url()),
) && (profile.supports_custom_http_headers
|| subtitle.upstream_headers().is_empty())))
.then(|| subtitle.clone())
},
);
let (danmakus, default_danmaku_index) = map_playback_resources(
&source.danmakus,
source.default_danmaku_index,
|_, danmaku| {
(danmaku.requires_provider_url()
|| (direct_http_resource_supported_by_client(profile, danmaku.upstream_url())
&& (profile.supports_custom_http_headers
|| danmaku.upstream_headers().is_empty())))
.then(|| danmaku.clone())
},
);
Some(PlaybackInfo {
thumbnail: source.thumbnail.clone(),
medias,
default_media_index,
subtitles,
default_subtitle_index,
danmakus,
default_danmaku_index,
})
}
/// Build one provider-proxy route from a provider's cached upstream response.
pub(crate) fn build_proxy_playback_info_for_client(
mode_name: &str,
source: &PlaybackInfo,
profile: Option<&PlaybackClientProfile>,
) -> Option<PlaybackInfo> {
let (medias, default_media_index) =
map_playback_resources(&source.medias, source.default_media_index, |_, media| {
proxy_playback_media_supported_by_client(profile, mode_name, media)
.then(|| media.clone())
});
(!medias.is_empty()).then(|| PlaybackInfo {
thumbnail: source.thumbnail.clone(),
medias,
default_media_index,
subtitles: source.subtitles.clone(),
default_subtitle_index: source.default_subtitle_index,
danmakus: source.danmakus.clone(),
default_danmaku_index: source.default_danmaku_index,
})
}
use crate::models::media::{ use crate::models::media::{
PlaybackDanmaku, PlaybackMedia, PlaybackMediaProvider, PlaybackRtmpMedia, PlaybackSubtitle, PlaybackDanmaku, PlaybackMedia, PlaybackMediaProvider, PlaybackRtmpMedia, PlaybackSubtitle,
}; };
@ -530,6 +838,11 @@ enum ProviderPlaybackFillError {
UpstreamHttp { status: u16, url: String }, UpstreamHttp { status: u16, url: String },
#[error("Unsupported format: {0}")] #[error("Unsupported format: {0}")]
UnsupportedFormat(String), UnsupportedFormat(String),
#[error("Client cannot play this resource: {reason}")]
ClientIncompatible {
reason: String,
required_capability: Option<String>,
},
#[error("Parse error: {0}")] #[error("Parse error: {0}")]
ParseError(String), ParseError(String),
#[error("Missing provider instance")] #[error("Missing provider instance")]
@ -567,6 +880,13 @@ impl From<ProviderError> for ProviderPlaybackFillError {
ProviderError::ApiError(message) => Self::ApiError(message), ProviderError::ApiError(message) => Self::ApiError(message),
ProviderError::UpstreamHttp { status, url } => Self::UpstreamHttp { status, url }, ProviderError::UpstreamHttp { status, url } => Self::UpstreamHttp { status, url },
ProviderError::UnsupportedFormat(message) => Self::UnsupportedFormat(message), ProviderError::UnsupportedFormat(message) => Self::UnsupportedFormat(message),
ProviderError::ClientIncompatible {
reason,
required_capability,
} => Self::ClientIncompatible {
reason,
required_capability,
},
ProviderError::ParseError(message) => Self::ParseError(message), ProviderError::ParseError(message) => Self::ParseError(message),
ProviderError::MissingInstance => Self::MissingInstance, ProviderError::MissingInstance => Self::MissingInstance,
ProviderError::InstanceNotFound(message) => Self::InstanceNotFound(message), ProviderError::InstanceNotFound(message) => Self::InstanceNotFound(message),
@ -602,6 +922,13 @@ impl From<ProviderPlaybackFillError> for ProviderError {
ProviderPlaybackFillError::UnsupportedFormat(message) => { ProviderPlaybackFillError::UnsupportedFormat(message) => {
Self::UnsupportedFormat(message) Self::UnsupportedFormat(message)
} }
ProviderPlaybackFillError::ClientIncompatible {
reason,
required_capability,
} => Self::ClientIncompatible {
reason,
required_capability,
},
ProviderPlaybackFillError::ParseError(message) => Self::ParseError(message), ProviderPlaybackFillError::ParseError(message) => Self::ParseError(message),
ProviderPlaybackFillError::MissingInstance => Self::MissingInstance, ProviderPlaybackFillError::MissingInstance => Self::MissingInstance,
ProviderPlaybackFillError::InstanceNotFound(message) => Self::InstanceNotFound(message), ProviderPlaybackFillError::InstanceNotFound(message) => Self::InstanceNotFound(message),
@ -1458,6 +1785,487 @@ where
.await .await
} }
#[cfg(test)]
mod playback_route_capability_tests {
use super::*;
use crate::models::media::{
PlaybackDanmakuProvider, PlaybackDirectUrlDanmaku, PlaybackDirectUrlMedia,
PlaybackDirectUrlSubtitle, PlaybackMediaProvider, PlaybackSubtitleProvider,
};
use std::collections::HashMap;
fn web_profile() -> PlaybackClientProfile {
PlaybackClientProfile {
profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: PlaybackClientEnvironment::Web,
media_capabilities: vec![PlaybackMediaCapability {
transport: PlaybackMediaTransport::Progressive,
container: Some(PlaybackContainer::Mp4),
video_codec: Some(PlaybackVideoCodec::H264),
audio_codec: Some(PlaybackAudioCodec::Aac),
pipeline: PlaybackMediaPipeline::Native,
codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()),
}],
supports_custom_http_headers: false,
supports_provider_proxy: true,
supports_insecure_http_media: false,
..Default::default()
}
}
fn web_streaming_profile(
transport: PlaybackMediaTransport,
pipeline: PlaybackMediaPipeline,
) -> PlaybackClientProfile {
PlaybackClientProfile {
profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: PlaybackClientEnvironment::Web,
media_capabilities: vec![PlaybackMediaCapability {
transport,
container: None,
video_codec: Some(PlaybackVideoCodec::H264),
audio_codec: Some(PlaybackAudioCodec::Aac),
pipeline,
codec_string: None,
}],
supports_custom_http_headers: false,
supports_provider_proxy: true,
supports_insecure_http_media: false,
..Default::default()
}
}
fn media(provider: PlaybackMediaProvider) -> PlaybackMedia {
media_with_format("mp4", provider)
}
fn media_with_format(format: &str, provider: PlaybackMediaProvider) -> PlaybackMedia {
PlaybackMedia {
name: "video".to_string(),
format: format.to_string(),
expire_at: None,
metadata: None,
p2p_swarm_id: None,
provider,
}
}
fn info(media: PlaybackMedia) -> PlaybackInfo {
PlaybackInfo {
thumbnail: None,
medias: vec![media],
default_media_index: Some(0),
subtitles: Vec::new(),
default_subtitle_index: None,
danmakus: Vec::new(),
default_danmaku_index: None,
}
}
fn result(playback_infos: std::collections::HashMap<String, PlaybackInfo>) -> PlaybackResult {
PlaybackResult {
playback_infos,
default_mode: "direct".to_string(),
provider: crate::models::SourceProvider::DirectUrl,
provider_instance_name: None,
duration_seconds: None,
playback_kind: Some(crate::models::PlaybackKind::Regular),
metadata: None,
}
}
fn generate_test_routes(
mut result: PlaybackResult,
proxy_mode: crate::models::PlaybackProxyMode,
profile: Option<&PlaybackClientProfile>,
) -> Result<PlaybackResult, ProviderError> {
let original_default = result.default_mode.clone();
result.playback_infos = std::mem::take(&mut result.playback_infos)
.into_iter()
.filter_map(|(mode_name, info)| {
let prepared = if mode_name.starts_with("proxy_") {
build_proxy_playback_info_for_client(&mode_name, &info, profile)
} else {
build_direct_playback_info_for_client(&mode_name, &info, profile)
};
prepared.map(|info| (mode_name, info))
})
.collect();
select_generated_playback_default(
&mut result,
&original_default,
matches!(
proxy_mode,
crate::models::PlaybackProxyMode::Only | crate::models::PlaybackProxyMode::Prefer
),
);
require_compatible_playback_route(result, proxy_mode, profile)
}
#[test]
fn mapped_playback_resources_keep_source_indices_and_remap_the_default() {
let resources = ["unsupported", "selected", "fallback"];
let (mapped, default_index) =
map_playback_resources(&resources, Some(1), |source_index, resource| {
(source_index > 0).then_some((source_index, *resource))
});
assert_eq!(mapped, vec![(1, "selected"), (2, "fallback")]);
assert_eq!(default_index, Some(0));
}
#[test]
fn empty_direct_route_is_not_emitted_without_a_client_profile() {
let empty = PlaybackInfo {
thumbnail: None,
medias: Vec::new(),
default_media_index: None,
subtitles: Vec::new(),
default_subtitle_index: None,
danmakus: Vec::new(),
default_danmaku_index: None,
};
assert!(build_direct_playback_info_for_client("direct", &empty, None).is_none());
}
#[test]
fn web_header_bound_direct_media_falls_back_to_provider_proxy() {
let headers = HashMap::from([("Referer".to_string(), "https://example.test".to_string())]);
let direct = media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::Direct {
url: "https://cdn.example.test/video.mp4".to_string(),
headers: headers.clone(),
},
));
let proxy = media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::ProxyStream {
version: "v1".to_string(),
expires_at: 1,
mode_name: "direct".to_string(),
url_index: 0,
url: "https://cdn.example.test/video.mp4".to_string(),
headers,
},
));
let playback = result(HashMap::from([
("direct".to_string(), info(direct)),
("proxy_direct".to_string(), info(proxy)),
]));
let filtered = generate_test_routes(
playback,
crate::models::PlaybackProxyMode::DirectPrefer,
Some(&web_profile()),
)
.expect("proxy route should remain compatible");
assert!(!filtered.playback_infos.contains_key("direct"));
assert!(filtered.playback_infos.contains_key("proxy_direct"));
assert_eq!(filtered.default_mode, "proxy_direct");
}
#[test]
fn web_media_source_flv_uses_the_provider_proxy_route() {
let direct = media_with_format(
"flv",
PlaybackMediaProvider::DirectUrl(PlaybackDirectUrlMedia::Direct {
url: "https://cdn.example.test/live.flv".to_string(),
headers: HashMap::new(),
}),
);
let proxy = media_with_format(
"flv",
PlaybackMediaProvider::DirectUrl(PlaybackDirectUrlMedia::ProxyStream {
version: "v1".to_string(),
expires_at: 1,
mode_name: "flv".to_string(),
url_index: 0,
url: "https://cdn.example.test/live.flv".to_string(),
headers: HashMap::new(),
}),
);
let playback = result(HashMap::from([
("flv".to_string(), info(direct)),
("proxy_flv".to_string(), info(proxy)),
]));
let profile = web_streaming_profile(
PlaybackMediaTransport::Flv,
PlaybackMediaPipeline::MediaSource,
);
let filtered = generate_test_routes(
playback,
crate::models::PlaybackProxyMode::DirectPrefer,
Some(&profile),
)
.expect("same-origin FLV proxy should support a MediaSource loader");
assert!(!filtered.playback_infos.contains_key("flv"));
assert!(filtered.playback_infos.contains_key("proxy_flv"));
assert_eq!(filtered.default_mode, "proxy_flv");
}
#[test]
fn web_native_hls_can_keep_a_direct_route() {
let direct = media_with_format(
"m3u8",
PlaybackMediaProvider::DirectUrl(PlaybackDirectUrlMedia::Direct {
url: "https://cdn.example.test/live.m3u8".to_string(),
headers: HashMap::new(),
}),
);
let profile =
web_streaming_profile(PlaybackMediaTransport::Hls, PlaybackMediaPipeline::Native);
let filtered = generate_test_routes(
result(HashMap::from([("hls".to_string(), info(direct))])),
crate::models::PlaybackProxyMode::DirectOnly,
Some(&profile),
)
.expect("native browser HLS should consume a public direct URL");
assert!(filtered.playback_infos.contains_key("hls"));
}
#[test]
fn secure_web_client_replaces_insecure_direct_media_with_proxy() {
let direct = media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::Direct {
url: "http://media.example.test/video.mp4".to_string(),
headers: HashMap::new(),
},
));
let proxy = media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::ProxyStream {
version: "v1".to_string(),
expires_at: 1,
mode_name: "direct".to_string(),
url_index: 0,
url: "http://media.example.test/video.mp4".to_string(),
headers: HashMap::new(),
},
));
let filtered = generate_test_routes(
result(HashMap::from([
("direct".to_string(), info(direct)),
("proxy_direct".to_string(), info(proxy)),
])),
crate::models::PlaybackProxyMode::DirectPrefer,
Some(&web_profile()),
)
.expect("same-origin proxy should replace mixed-content direct media");
assert!(!filtered.playback_infos.contains_key("direct"));
assert!(filtered.playback_infos.contains_key("proxy_direct"));
assert_eq!(filtered.default_mode, "proxy_direct");
}
#[test]
fn secure_web_direct_only_reports_mixed_content_capability() {
let direct = media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::Direct {
url: "http://media.example.test/video.mp4".to_string(),
headers: HashMap::new(),
},
));
let error = generate_test_routes(
result(HashMap::from([("direct".to_string(), info(direct))])),
crate::models::PlaybackProxyMode::DirectOnly,
Some(&web_profile()),
)
.expect_err("mixed-content direct-only playback must fail clearly");
assert!(matches!(
error,
ProviderError::ClientIncompatible {
required_capability: Some(ref capability),
..
} if capability == "browser_direct_media_access_or_provider_proxy"
));
}
#[test]
fn secure_web_client_filters_insecure_direct_attachments() {
let mut playback_info = info(media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::Direct {
url: "https://media.example.test/video.mp4".to_string(),
headers: HashMap::new(),
},
)));
playback_info.subtitles = vec![PlaybackSubtitle {
name: "mixed content".to_string(),
language: "en".to_string(),
format: "vtt".to_string(),
p2p_swarm_id: None,
provider: PlaybackSubtitleProvider::DirectUrl(PlaybackDirectUrlSubtitle::Direct {
url: "http://media.example.test/subtitle.vtt".to_string(),
headers: HashMap::new(),
expire_at: None,
}),
}];
playback_info.default_subtitle_index = Some(0);
playback_info.danmakus = vec![PlaybackDanmaku {
name: "mixed content".to_string(),
format: Some("xml".to_string()),
p2p_swarm_id: None,
provider: PlaybackDanmakuProvider::DirectUrl(PlaybackDirectUrlDanmaku {
url: "http://media.example.test/danmaku.xml".to_string(),
headers: HashMap::new(),
expire_at: None,
}),
}];
playback_info.default_danmaku_index = Some(0);
let filtered = generate_test_routes(
result(HashMap::from([("direct".to_string(), playback_info)])),
crate::models::PlaybackProxyMode::DirectOnly,
Some(&web_profile()),
)
.expect("secure media remains playable without mixed-content attachments");
let info = &filtered.playback_infos["direct"];
assert!(info.subtitles.is_empty());
assert_eq!(info.default_subtitle_index, None);
assert!(info.danmakus.is_empty());
assert_eq!(info.default_danmaku_index, None);
}
#[test]
fn unsupported_live_transport_returns_structured_client_incompatibility() {
let proxy = media_with_format(
"flv",
PlaybackMediaProvider::DirectUrl(PlaybackDirectUrlMedia::ProxyStream {
version: "v1".to_string(),
expires_at: 1,
mode_name: "flv".to_string(),
url_index: 0,
url: "https://cdn.example.test/live.flv".to_string(),
headers: HashMap::new(),
}),
);
let profile = web_streaming_profile(
PlaybackMediaTransport::Hls,
PlaybackMediaPipeline::MediaSource,
);
let error = generate_test_routes(
result(HashMap::from([("proxy_flv".to_string(), info(proxy))])),
crate::models::PlaybackProxyMode::Only,
Some(&profile),
)
.expect_err("an HLS-only browser must reject FLV playback");
assert!(matches!(
error,
ProviderError::ClientIncompatible {
required_capability: Some(ref capability),
..
} if capability == "media_transport_codec_combination"
));
}
#[test]
fn header_bound_direct_only_reports_the_missing_browser_capability() {
let headers = HashMap::from([("Referer".to_string(), "https://example.test".to_string())]);
let direct = media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::Direct {
url: "https://cdn.example.test/video.mp4".to_string(),
headers,
},
));
let error = generate_test_routes(
result(HashMap::from([("direct".to_string(), info(direct))])),
crate::models::PlaybackProxyMode::DirectOnly,
Some(&web_profile()),
)
.expect_err("browser direct playback cannot attach a Referer header");
assert!(matches!(
error,
ProviderError::ClientIncompatible {
required_capability: Some(ref capability),
..
} if capability == "browser_direct_media_access_or_provider_proxy"
));
}
#[test]
fn header_filtering_remaps_attachment_default_indices() {
let mut playback_info = info(media(PlaybackMediaProvider::DirectUrl(
PlaybackDirectUrlMedia::Direct {
url: "https://cdn.example.test/video.mp4".to_string(),
headers: HashMap::new(),
},
)));
let headers = HashMap::from([("Authorization".to_string(), "secret".to_string())]);
playback_info.subtitles = vec![
PlaybackSubtitle {
name: "header-bound".to_string(),
language: "en".to_string(),
format: "vtt".to_string(),
p2p_swarm_id: None,
provider: PlaybackSubtitleProvider::DirectUrl(PlaybackDirectUrlSubtitle::Direct {
url: "https://cdn.example.test/private.vtt".to_string(),
headers: headers.clone(),
expire_at: None,
}),
},
PlaybackSubtitle {
name: "public".to_string(),
language: "en".to_string(),
format: "vtt".to_string(),
p2p_swarm_id: None,
provider: PlaybackSubtitleProvider::DirectUrl(PlaybackDirectUrlSubtitle::Direct {
url: "https://cdn.example.test/public.vtt".to_string(),
headers: HashMap::new(),
expire_at: None,
}),
},
];
playback_info.default_subtitle_index = Some(1);
playback_info.danmakus = vec![
PlaybackDanmaku {
name: "header-bound".to_string(),
format: Some("xml".to_string()),
p2p_swarm_id: None,
provider: PlaybackDanmakuProvider::DirectUrl(PlaybackDirectUrlDanmaku {
url: "https://cdn.example.test/private.xml".to_string(),
headers,
expire_at: None,
}),
},
PlaybackDanmaku {
name: "public".to_string(),
format: Some("xml".to_string()),
p2p_swarm_id: None,
provider: PlaybackDanmakuProvider::DirectUrl(PlaybackDirectUrlDanmaku {
url: "https://cdn.example.test/public.xml".to_string(),
headers: HashMap::new(),
expire_at: None,
}),
},
];
playback_info.default_danmaku_index = Some(1);
let filtered = generate_test_routes(
result(HashMap::from([("direct".to_string(), playback_info)])),
crate::models::PlaybackProxyMode::DirectOnly,
Some(&web_profile()),
)
.expect("public direct resources should remain compatible");
let info = &filtered.playback_infos["direct"];
assert_eq!(info.subtitles.len(), 1);
assert_eq!(info.default_subtitle_index, Some(0));
assert_eq!(info.danmakus.len(), 1);
assert_eq!(info.default_danmaku_index, Some(0));
}
}
#[cfg(test)] #[cfg(test)]
mod playback_transport_expiry_tests { mod playback_transport_expiry_tests {
use super::*; use super::*;

@ -816,12 +816,22 @@ impl MediaProvider for NextcloudProvider {
PLAYBACK_CACHE_TTL, PLAYBACK_CACHE_TTL,
ctx, ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_playback_resources(result, version, expires_at, config.proxy_mode); mark_playback_resources(
result,
version,
expires_at,
config.proxy_mode,
ctx.playback_client_profile(),
);
}, },
|| async { Ok(result) }, || async { Ok(result) },
) )
.await?; .await?;
super::require_direct_playback_route(result, config.proxy_mode) super::require_compatible_playback_route(
result,
config.proxy_mode,
ctx.playback_client_profile(),
)
} }
async fn validate_source_config( async fn validate_source_config(
@ -1185,6 +1195,7 @@ fn mark_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default = result.default_mode.clone(); let original_default = result.default_mode.clone();
let prefer_proxy = matches!( let prefer_proxy = matches!(
@ -1207,17 +1218,29 @@ fn mark_playback_resources(
.iter() .iter()
.any(|media| !media.requires_provider_url()); .any(|media| !media.requires_provider_url());
if selection.direct && direct_available { if selection.direct && direct_available {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
} }
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |media_index, media| {
.filter_map(|(media_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let PlaybackMediaProvider::Nextcloud(PlaybackNextcloudMedia::Refresh { let PlaybackMediaProvider::Nextcloud(PlaybackNextcloudMedia::Refresh {
credential_owner_id, credential_owner_id,
server_id, server_id,
@ -1239,8 +1262,10 @@ fn mark_playback_resources(
file_id: *file_id, file_id: *file_id,
}); });
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
if proxy_info.medias.is_empty() { if proxy_info.medias.is_empty() {
continue; continue;
} }

@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PlaybackStreamPreference { pub enum PlaybackStreamPreference {
#[default] #[default]
@ -111,6 +113,115 @@ impl PlaybackAudioCapability {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PlaybackClientEnvironment {
#[default]
Native,
Web,
}
impl PlaybackClientEnvironment {
#[must_use]
pub const fn cache_token(self) -> &'static str {
match self {
Self::Native => "native",
Self::Web => "web",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlaybackMediaTransport {
Progressive,
Hls,
Dash,
Flv,
MpegTs,
}
impl PlaybackMediaTransport {
#[must_use]
pub const fn cache_token(self) -> &'static str {
match self {
Self::Progressive => "progressive",
Self::Hls => "hls",
Self::Dash => "dash",
Self::Flv => "flv",
Self::MpegTs => "mpeg_ts",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlaybackMediaPipeline {
Native,
MediaSource,
ManagedMediaSource,
}
impl PlaybackMediaPipeline {
#[must_use]
pub const fn cache_token(self) -> &'static str {
match self {
Self::Native => "native",
Self::MediaSource => "media_source",
Self::ManagedMediaSource => "managed_media_source",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlaybackAudioCodec {
Aac,
Mp3,
Opus,
Vorbis,
Ac3,
Eac3,
Flac,
}
impl PlaybackAudioCodec {
#[must_use]
pub const fn cache_token(self) -> &'static str {
match self {
Self::Aac => "aac",
Self::Mp3 => "mp3",
Self::Opus => "opus",
Self::Vorbis => "vorbis",
Self::Ac3 => "ac3",
Self::Eac3 => "eac3",
Self::Flac => "flac",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlaybackMediaCapability {
pub transport: PlaybackMediaTransport,
pub container: Option<PlaybackContainer>,
pub video_codec: Option<PlaybackVideoCodec>,
pub audio_codec: Option<PlaybackAudioCodec>,
pub pipeline: PlaybackMediaPipeline,
pub codec_string: Option<String>,
}
impl PlaybackMediaCapability {
fn cache_token(&self) -> String {
format!(
"{}+{}+{}+{}+{}+{}",
self.transport.cache_token(),
self.container.map_or("any", PlaybackContainer::cache_token),
self.video_codec
.map_or("any", PlaybackVideoCodec::cache_token),
self.audio_codec
.map_or("any", PlaybackAudioCodec::cache_token),
self.pipeline.cache_token(),
self.codec_string.as_deref().unwrap_or("none"),
)
}
}
/// SyncTV-owned, request-scoped playback capability model. /// SyncTV-owned, request-scoped playback capability model.
/// ///
/// This deliberately captures only the client characteristics that materially /// This deliberately captures only the client characteristics that materially
@ -118,6 +229,8 @@ impl PlaybackAudioCapability {
/// derived from this structure at the edge. /// derived from this structure at the edge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlaybackClientProfile { pub struct PlaybackClientProfile {
pub profile_version: u32,
pub environment: PlaybackClientEnvironment,
pub stream_preference: PlaybackStreamPreference, pub stream_preference: PlaybackStreamPreference,
pub max_streaming_bitrate: Option<i64>, pub max_streaming_bitrate: Option<i64>,
pub max_audio_channels: Option<i32>, pub max_audio_channels: Option<i32>,
@ -126,11 +239,17 @@ pub struct PlaybackClientProfile {
pub audio_capability: PlaybackAudioCapability, pub audio_capability: PlaybackAudioCapability,
pub subtitle_preference: PlaybackSubtitlePreference, pub subtitle_preference: PlaybackSubtitlePreference,
pub supported_live_transports: Vec<PlaybackLiveTransport>, pub supported_live_transports: Vec<PlaybackLiveTransport>,
pub media_capabilities: Vec<PlaybackMediaCapability>,
pub supports_custom_http_headers: bool,
pub supports_provider_proxy: bool,
pub supports_insecure_http_media: bool,
} }
impl Default for PlaybackClientProfile { impl Default for PlaybackClientProfile {
fn default() -> Self { fn default() -> Self {
Self { Self {
profile_version: 0,
environment: PlaybackClientEnvironment::Native,
stream_preference: PlaybackStreamPreference::Auto, stream_preference: PlaybackStreamPreference::Auto,
max_streaming_bitrate: None, max_streaming_bitrate: None,
max_audio_channels: Some(2), max_audio_channels: Some(2),
@ -148,15 +267,128 @@ impl Default for PlaybackClientProfile {
audio_capability: PlaybackAudioCapability::LosslessSurround, audio_capability: PlaybackAudioCapability::LosslessSurround,
subtitle_preference: PlaybackSubtitlePreference::External, subtitle_preference: PlaybackSubtitlePreference::External,
supported_live_transports: vec![PlaybackLiveTransport::Hls], supported_live_transports: vec![PlaybackLiveTransport::Hls],
media_capabilities: Vec::new(),
supports_custom_http_headers: true,
supports_provider_proxy: true,
supports_insecure_http_media: true,
} }
} }
} }
impl PlaybackClientProfile { impl PlaybackClientProfile {
#[must_use]
pub const fn uses_explicit_capabilities(&self) -> bool {
self.profile_version >= CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION
}
#[must_use]
pub const fn is_web(&self) -> bool {
matches!(self.environment, PlaybackClientEnvironment::Web)
}
#[must_use]
pub fn supports_transport(&self, transport: PlaybackMediaTransport) -> bool {
if self.uses_explicit_capabilities() {
return self
.media_capabilities
.iter()
.any(|capability| capability.transport == transport);
}
match transport {
PlaybackMediaTransport::Progressive | PlaybackMediaTransport::Dash => true,
PlaybackMediaTransport::Hls => self
.supported_live_transports
.contains(&PlaybackLiveTransport::Hls),
PlaybackMediaTransport::Flv | PlaybackMediaTransport::MpegTs => self
.supported_live_transports
.contains(&PlaybackLiveTransport::Flv),
}
}
#[must_use]
pub fn supports_video_codec(&self, codec: PlaybackVideoCodec) -> bool {
if self.uses_explicit_capabilities() {
return self
.media_capabilities
.iter()
.any(|capability| capability.video_codec.is_none_or(|value| value == codec));
}
self.supported_video_codecs.contains(&codec)
}
#[must_use]
pub fn supports_container(&self, container: PlaybackContainer) -> bool {
if self.uses_explicit_capabilities() {
return self
.media_capabilities
.iter()
.any(|capability| capability.container.is_none_or(|value| value == container));
}
self.supported_containers.contains(&container)
}
#[must_use]
pub fn supports_media(
&self,
transport: PlaybackMediaTransport,
container: Option<PlaybackContainer>,
video_codec: Option<PlaybackVideoCodec>,
audio_codec: Option<PlaybackAudioCodec>,
) -> bool {
if !self.uses_explicit_capabilities() {
return self.supports_transport(transport)
&& container.is_none_or(|value| self.supports_container(value))
&& video_codec.is_none_or(|value| self.supports_video_codec(value));
}
self.media_capabilities.iter().any(|capability| {
capability.transport == transport
&& container
.is_none_or(|value| capability.container.is_none_or(|item| item == value))
&& video_codec
.is_none_or(|value| capability.video_codec.is_none_or(|item| item == value))
&& audio_codec
.is_none_or(|value| capability.audio_codec.is_none_or(|item| item == value))
})
}
#[must_use]
pub fn supports_media_with_pipeline(
&self,
transport: PlaybackMediaTransport,
container: Option<PlaybackContainer>,
video_codec: Option<PlaybackVideoCodec>,
audio_codec: Option<PlaybackAudioCodec>,
pipeline: PlaybackMediaPipeline,
) -> bool {
if !self.uses_explicit_capabilities() {
return self.supports_media(transport, container, video_codec, audio_codec);
}
self.media_capabilities.iter().any(|capability| {
capability.transport == transport
&& capability.pipeline == pipeline
&& container
.is_none_or(|value| capability.container.is_none_or(|item| item == value))
&& video_codec
.is_none_or(|value| capability.video_codec.is_none_or(|item| item == value))
&& audio_codec
.is_none_or(|value| capability.audio_codec.is_none_or(|item| item == value))
})
}
#[must_use] #[must_use]
pub fn cache_fingerprint(&self) -> String { pub fn cache_fingerprint(&self) -> String {
let mut capabilities = self
.media_capabilities
.iter()
.map(PlaybackMediaCapability::cache_token)
.collect::<Vec<_>>();
capabilities.sort_unstable();
format!( format!(
"stream={}:bitrate={}:channels={}:video_codecs={}:containers={}:audio={}:subtitle={}:live_transports={}", "v={}:environment={}:stream={}:bitrate={}:channels={}:video_codecs={}:containers={}:audio={}:subtitle={}:live_transports={}:media={}:headers={}:proxy={}:insecure_http_media={}",
self.profile_version,
self.environment.cache_token(),
self.stream_preference.cache_token(), self.stream_preference.cache_token(),
self.max_streaming_bitrate self.max_streaming_bitrate
.map_or_else(|| "none".to_string(), |value| value.to_string()), .map_or_else(|| "none".to_string(), |value| value.to_string()),
@ -179,6 +411,10 @@ impl PlaybackClientProfile {
.map(|transport| transport.cache_token()) .map(|transport| transport.cache_token())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","), .join(","),
capabilities.join(","),
self.supports_custom_http_headers,
self.supports_provider_proxy,
self.supports_insecure_http_media,
) )
} }
} }
@ -228,6 +464,8 @@ mod tests {
#[test] #[test]
fn cache_fingerprint_includes_every_playback_negotiation_field() { fn cache_fingerprint_includes_every_playback_negotiation_field() {
let profile = PlaybackClientProfile { let profile = PlaybackClientProfile {
profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: PlaybackClientEnvironment::Web,
stream_preference: PlaybackStreamPreference::Transcode, stream_preference: PlaybackStreamPreference::Transcode,
max_streaming_bitrate: Some(8_000_000), max_streaming_bitrate: Some(8_000_000),
max_audio_channels: Some(2), max_audio_channels: Some(2),
@ -236,11 +474,78 @@ mod tests {
audio_capability: PlaybackAudioCapability::Surround, audio_capability: PlaybackAudioCapability::Surround,
subtitle_preference: PlaybackSubtitlePreference::EmbeddedOrExternal, subtitle_preference: PlaybackSubtitlePreference::EmbeddedOrExternal,
supported_live_transports: vec![PlaybackLiveTransport::Hls, PlaybackLiveTransport::Flv], supported_live_transports: vec![PlaybackLiveTransport::Hls, PlaybackLiveTransport::Flv],
media_capabilities: vec![PlaybackMediaCapability {
transport: PlaybackMediaTransport::Progressive,
container: Some(PlaybackContainer::Mp4),
video_codec: Some(PlaybackVideoCodec::H264),
audio_codec: Some(PlaybackAudioCodec::Aac),
pipeline: PlaybackMediaPipeline::MediaSource,
codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()),
}],
supports_custom_http_headers: false,
supports_provider_proxy: true,
supports_insecure_http_media: false,
}; };
assert_eq!( assert_eq!(
profile.cache_fingerprint(), profile.cache_fingerprint(),
"stream=transcode:bitrate=8000000:channels=2:video_codecs=h264,av1:containers=mp4,webm:audio=surround:subtitle=embedded_or_external:live_transports=hls,flv" "v=2:environment=web:stream=transcode:bitrate=8000000:channels=2:video_codecs=h264,av1:containers=mp4,webm:audio=surround:subtitle=embedded_or_external:live_transports=hls,flv:media=progressive+mp4+h264+aac+media_source+avc1.42E01E,mp4a.40.2:headers=false:proxy=true:insecure_http_media=false"
); );
} }
#[test]
fn version_two_empty_capability_set_means_no_media_support() {
let profile = PlaybackClientProfile {
profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: PlaybackClientEnvironment::Web,
supported_video_codecs: Vec::new(),
supported_containers: Vec::new(),
media_capabilities: Vec::new(),
..PlaybackClientProfile::default()
};
assert!(!profile.supports_transport(PlaybackMediaTransport::Progressive));
assert!(!profile.supports_container(PlaybackContainer::Mp4));
assert!(!profile.supports_video_codec(PlaybackVideoCodec::H264));
}
#[test]
fn version_two_checks_codec_and_container_on_the_same_capability() {
let profile = PlaybackClientProfile {
profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: PlaybackClientEnvironment::Web,
media_capabilities: vec![
PlaybackMediaCapability {
transport: PlaybackMediaTransport::Progressive,
container: Some(PlaybackContainer::Mp4),
video_codec: Some(PlaybackVideoCodec::H264),
audio_codec: Some(PlaybackAudioCodec::Aac),
pipeline: PlaybackMediaPipeline::Native,
codec_string: None,
},
PlaybackMediaCapability {
transport: PlaybackMediaTransport::Progressive,
container: Some(PlaybackContainer::Webm),
video_codec: Some(PlaybackVideoCodec::Vp9),
audio_codec: Some(PlaybackAudioCodec::Opus),
pipeline: PlaybackMediaPipeline::Native,
codec_string: None,
},
],
..PlaybackClientProfile::default()
};
assert!(profile.supports_media(
PlaybackMediaTransport::Progressive,
Some(PlaybackContainer::Mp4),
Some(PlaybackVideoCodec::H264),
Some(PlaybackAudioCodec::Aac),
));
assert!(!profile.supports_media(
PlaybackMediaTransport::Progressive,
Some(PlaybackContainer::Mp4),
Some(PlaybackVideoCodec::Vp9),
Some(PlaybackAudioCodec::Aac),
));
}
} }

@ -985,12 +985,22 @@ impl MediaProvider for QnapProvider {
PLAYBACK_CACHE_TTL, PLAYBACK_CACHE_TTL,
ctx, ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_qnap_playback_resources(result, version, expires_at, config.proxy_mode); mark_qnap_playback_resources(
result,
version,
expires_at,
config.proxy_mode,
ctx.playback_client_profile(),
);
}, },
|| async { Ok(result) }, || async { Ok(result) },
) )
.await?; .await?;
super::require_direct_playback_route(result, config.proxy_mode) super::require_compatible_playback_route(
result,
config.proxy_mode,
ctx.playback_client_profile(),
)
} }
async fn validate_source_config( async fn validate_source_config(
@ -1431,6 +1441,7 @@ fn mark_qnap_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default = result.default_mode.clone(); let original_default = result.default_mode.clone();
let prefer_proxy = matches!( let prefer_proxy = matches!(
@ -1453,17 +1464,29 @@ fn mark_qnap_playback_resources(
.iter() .iter()
.any(|media| !media.requires_provider_url()); .any(|media| !media.requires_provider_url());
if selection.direct && direct_available { if selection.direct && direct_available {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
} }
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |media_index, media| {
.filter_map(|(media_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let PlaybackMediaProvider::Qnap(PlaybackQnapMedia::Refresh { let PlaybackMediaProvider::Qnap(PlaybackQnapMedia::Refresh {
credential_owner_id, credential_owner_id,
server_id, server_id,
@ -1483,8 +1506,10 @@ fn mark_qnap_playback_resources(
resource: resource.clone(), resource: resource.clone(),
}); });
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
if proxy_info.medias.is_empty() { if proxy_info.medias.is_empty() {
continue; continue;
} }

@ -41,11 +41,23 @@ impl RtmpProvider {
} }
} }
fn mark_rtmp_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_rtmp_playback_resources(
result: &mut PlaybackResult,
version: &str,
expires_at: i64,
client_profile: Option<&super::PlaybackClientProfile>,
) {
// RTMP playback is a SyncTV-managed live source. HLS and FLV use distinct // RTMP playback is a SyncTV-managed live source. HLS and FLV use distinct
// playback transport actions so clients can request either delivery format. // playback transport actions so clients can request either delivery format.
for (mode_name, info) in &mut result.playback_infos { let original_default = result.default_mode.clone();
let is_hls = super::playback_info_is_hls(mode_name, info); let original_modes = std::mem::take(&mut result.playback_infos);
for (mode_name, info) in original_modes {
let Some(mut info) =
super::build_proxy_playback_info_for_client(&mode_name, &info, client_profile)
else {
continue;
};
let is_hls = super::playback_info_is_hls(&mode_name, &info);
for media in &mut info.medias { for media in &mut info.medias {
let (room_id, media_id) = match &media.provider { let (room_id, media_id) = match &media.provider {
PlaybackMediaProvider::Rtmp( PlaybackMediaProvider::Rtmp(
@ -77,7 +89,9 @@ fn mark_rtmp_playback_resources(result: &mut PlaybackResult, version: &str, expi
continue; continue;
}; };
} }
result.playback_infos.insert(mode_name, info);
} }
super::select_generated_playback_default(result, &original_default, true);
} }
impl Default for RtmpProvider { impl Default for RtmpProvider {
@ -109,15 +123,23 @@ impl MediaProvider for RtmpProvider {
let cache_key = format!("playback:{room_id}:{media_id}"); let cache_key = format!("playback:{room_id}:{media_id}");
let cache_ttl = Duration::from_mins(5); // 5 minutes for live let cache_ttl = Duration::from_mins(5); // 5 minutes for live
super::cache_versioned_playback_and_build_response( let client_profile = ctx.playback_client_profile();
let result = super::cache_versioned_playback_and_build_response(
result, result,
Self::NAME, Self::NAME,
&cache_key, &cache_key,
cache_ttl, cache_ttl,
ctx, ctx,
mark_rtmp_playback_resources, |result, version, expires_at| {
mark_rtmp_playback_resources(result, version, expires_at, client_profile);
},
)
.await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
) )
.await
} }
async fn validate_source_config( async fn validate_source_config(

@ -253,12 +253,22 @@ impl MediaProvider for SeafileProvider {
PLAYBACK_CACHE_TTL, PLAYBACK_CACHE_TTL,
ctx, ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_playback_resources(result, version, expires_at, config.proxy_mode); mark_playback_resources(
result,
version,
expires_at,
config.proxy_mode,
ctx.playback_client_profile(),
);
}, },
|| async { Ok(result) }, || async { Ok(result) },
) )
.await?; .await?;
super::require_direct_playback_route(result, config.proxy_mode) super::require_compatible_playback_route(
result,
config.proxy_mode,
ctx.playback_client_profile(),
)
} }
async fn validate_source_config( async fn validate_source_config(
@ -647,6 +657,7 @@ fn mark_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default = result.default_mode.clone(); let original_default = result.default_mode.clone();
let prefer_proxy = matches!( let prefer_proxy = matches!(
@ -669,17 +680,29 @@ fn mark_playback_resources(
.iter() .iter()
.any(|media| !media.requires_provider_url()); .any(|media| !media.requires_provider_url());
if selection.direct && direct_available { if selection.direct && direct_available {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
} }
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |media_index, media| {
.filter_map(|(media_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let PlaybackMediaProvider::Seafile(PlaybackSeafileMedia::Refresh { let PlaybackMediaProvider::Seafile(PlaybackSeafileMedia::Refresh {
credential_owner_id, credential_owner_id,
server_id, server_id,
@ -703,8 +726,10 @@ fn mark_playback_resources(
object_id: object_id.clone(), object_id: object_id.clone(),
}); });
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
if proxy_info.medias.is_empty() { if proxy_info.medias.is_empty() {
continue; continue;
} }

@ -1101,12 +1101,22 @@ impl MediaProvider for SynologyProvider {
PLAYBACK_CACHE_TTL, PLAYBACK_CACHE_TTL,
ctx, ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_synology_playback_resources(result, version, expires_at, config.proxy_mode); mark_synology_playback_resources(
result,
version,
expires_at,
config.proxy_mode,
ctx.playback_client_profile(),
);
}, },
|| async { Ok(result) }, || async { Ok(result) },
) )
.await?; .await?;
let result = super::require_direct_playback_route(result, config.proxy_mode)?; let result = super::require_compatible_playback_route(
result,
config.proxy_mode,
ctx.playback_client_profile(),
)?;
if let SynologyMediaSource::LibraryItem { file_id, .. } = &config.source { if let SynologyMediaSource::LibraryItem { file_id, .. } = &config.source {
let resource_version = result.playback_infos.values().find_map(|info| { let resource_version = result.playback_infos.values().find_map(|info| {
@ -2126,7 +2136,7 @@ async fn generate_video_playback(
}, },
format: format.to_string(), format: format.to_string(),
expire_at: None, expire_at: None,
metadata: Some(video_file_metadata(file)), metadata: Some(video_file_metadata(file, profile)),
p2p_swarm_id: Some(synology_swarm_id( p2p_swarm_id: Some(synology_swarm_id(
auth.instance_name.as_deref(), auth.instance_name.as_deref(),
"media", "media",
@ -2261,6 +2271,7 @@ fn mark_synology_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default = result.default_mode.clone(); let original_default = result.default_mode.clone();
let prefer_proxy = matches!( let prefer_proxy = matches!(
@ -2283,17 +2294,29 @@ fn mark_synology_playback_resources(
.iter() .iter()
.any(|media| !media.requires_provider_url()); .any(|media| !media.requires_provider_url());
if selection.direct && direct_available { if selection.direct && direct_available {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
} }
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |media_index, media| {
.filter_map(|(media_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let PlaybackMediaProvider::Synology( let PlaybackMediaProvider::Synology(
crate::models::PlaybackSynologyMedia::Refresh { crate::models::PlaybackSynologyMedia::Refresh {
credential_owner_id, credential_owner_id,
@ -2316,8 +2339,10 @@ fn mark_synology_playback_resources(
resource: resource.clone(), resource: resource.clone(),
}); });
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
if proxy_info.medias.is_empty() { if proxy_info.medias.is_empty() {
continue; continue;
} }
@ -2425,17 +2450,32 @@ fn file_metadata(file: &SynologyFile) -> PlaybackMediaMetadata {
fn video_file_metadata( fn video_file_metadata(
file: &synctv_media_providers::synology::SynologyVideoFile, file: &synctv_media_providers::synology::SynologyVideoFile,
profile: SynologyPlaybackProfile,
) -> PlaybackMediaMetadata { ) -> PlaybackMediaMetadata {
PlaybackMediaMetadata { PlaybackMediaMetadata {
resolution: Some(format!("{}x{}", file.resolutionx, file.resolutiony)), resolution: Some(format!("{}x{}", file.resolutionx, file.resolutiony)),
bitrate: i64::try_from(file.frame_bitrate).ok(), bitrate: i64::try_from(file.frame_bitrate).ok(),
codec: Some(file.video_codec.clone()), codec: video_station_profile_codec(&file.video_codec, profile),
fps: (file.frame_rate_den > 0) fps: (file.frame_rate_den > 0)
.then(|| i32::try_from(file.frame_rate_num / file.frame_rate_den).ok()) .then(|| i32::try_from(file.frame_rate_num / file.frame_rate_den).ok())
.flatten(), .flatten(),
} }
} }
fn video_station_profile_codec(
source_codec: &str,
profile: SynologyPlaybackProfile,
) -> Option<String> {
match profile {
SynologyPlaybackProfile::HlsMedium | SynologyPlaybackProfile::HlsLow => {
Some("h264".to_string())
}
SynologyPlaybackProfile::Raw | SynologyPlaybackProfile::HlsRemux => {
(!source_codec.is_empty()).then(|| source_codec.to_string())
}
}
}
fn store_apis(apis: &SynologyApiMap) -> HashMap<String, SynologyApiBinding> { fn store_apis(apis: &SynologyApiMap) -> HashMap<String, SynologyApiBinding> {
apis.iter() apis.iter()
.map(|(name, api)| { .map(|(name, api)| {
@ -2647,6 +2687,7 @@ mod tests {
"version", "version",
1_900_000_000, 1_900_000_000,
crate::models::PlaybackProxyMode::Prefer, crate::models::PlaybackProxyMode::Prefer,
None,
); );
assert_eq!(result.default_mode, "proxy_original"); assert_eq!(result.default_mode, "proxy_original");
@ -2654,6 +2695,26 @@ mod tests {
assert!(result.playback_infos.contains_key("proxy_original")); assert!(result.playback_infos.contains_key("proxy_original"));
} }
#[test]
fn video_station_transcode_profiles_advertise_h264() {
assert_eq!(
video_station_profile_codec("hevc", SynologyPlaybackProfile::Raw).as_deref(),
Some("hevc")
);
assert_eq!(
video_station_profile_codec("hevc", SynologyPlaybackProfile::HlsRemux).as_deref(),
Some("hevc")
);
assert_eq!(
video_station_profile_codec("hevc", SynologyPlaybackProfile::HlsMedium).as_deref(),
Some("h264")
);
assert_eq!(
video_station_profile_codec("hevc", SynologyPlaybackProfile::HlsLow).as_deref(),
Some("h264")
);
}
#[test] #[test]
fn library_policy_excludes_direct_only_modes() { fn library_policy_excludes_direct_only_modes() {
let provider = SynologyProvider::with_http_client(reqwest::Client::new()); let provider = SynologyProvider::with_http_client(reqwest::Client::new());

@ -849,21 +849,47 @@ impl TikTokProvider {
} }
} }
fn mark_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_playback_resources(
for (mode_name, info) in &mut result.playback_infos { result: &mut PlaybackResult,
for (media_index, media) in info.medias.iter_mut().enumerate() { version: &str,
if matches!( expires_at: i64,
media.provider, client_profile: Option<&super::PlaybackClientProfile>,
PlaybackMediaProvider::TikTok(PlaybackTikTokMedia::Refresh { .. }) ) {
) { let original_default = result.default_mode.clone();
media.provider = PlaybackMediaProvider::TikTok(PlaybackTikTokMedia::Proxy { let original_modes = std::mem::take(&mut result.playback_infos);
version: version.to_string(), for (mode_name, mut info) in original_modes {
expires_at, let source_medias = std::mem::take(&mut info.medias);
mode_name: mode_name.clone(), let (medias, default_media_index) = super::map_playback_resources(
media_index, &source_medias,
}); info.default_media_index,
} |media_index, media| {
if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::TikTok(PlaybackTikTokMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::TikTok(PlaybackTikTokMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
if medias.is_empty() {
continue;
} }
info.medias = medias;
info.default_media_index = default_media_index;
for (subtitle_index, subtitle) in info.subtitles.iter_mut().enumerate() { for (subtitle_index, subtitle) in info.subtitles.iter_mut().enumerate() {
if matches!( if matches!(
subtitle.provider, subtitle.provider,
@ -878,7 +904,9 @@ fn mark_playback_resources(result: &mut PlaybackResult, version: &str, expires_a
}); });
} }
} }
result.playback_infos.insert(mode_name, info);
} }
super::select_generated_playback_default(result, &original_default, true);
} }
fn stream_format(format: TikTokStreamFormat) -> &'static str { fn stream_format(format: TikTokStreamFormat) -> &'static str {
@ -983,12 +1011,15 @@ impl MediaProvider for TikTokProvider {
credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()), credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()),
Self::credential_server_id_for_instance(provider_instance_name.as_deref()) Self::credential_server_id_for_instance(provider_instance_name.as_deref())
); );
super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&cache_key, &cache_key,
Duration::from_mins(30), Duration::from_mins(30),
ctx, ctx,
mark_playback_resources, |result, version, expires_at| {
mark_playback_resources(result, version, expires_at, client_profile);
},
|| async { || async {
let media = self.resolve_media(config, &session).await?; let media = self.resolve_media(config, &session).await?;
Self::playback_result( Self::playback_result(
@ -999,7 +1030,12 @@ impl MediaProvider for TikTokProvider {
) )
}, },
) )
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn media_metadata( async fn media_metadata(

@ -680,12 +680,22 @@ impl MediaProvider for TrueNasProvider {
PLAYBACK_CACHE_TTL, PLAYBACK_CACHE_TTL,
ctx, ctx,
|result, version, expires_at| { |result, version, expires_at| {
mark_playback_resources(result, version, expires_at, config.proxy_mode); mark_playback_resources(
result,
version,
expires_at,
config.proxy_mode,
ctx.playback_client_profile(),
);
}, },
|| async { Ok(result) }, || async { Ok(result) },
) )
.await?; .await?;
super::require_direct_playback_route(result, config.proxy_mode) super::require_compatible_playback_route(
result,
config.proxy_mode,
ctx.playback_client_profile(),
)
} }
async fn validate_source_config( async fn validate_source_config(
@ -966,6 +976,7 @@ fn mark_playback_resources(
version: &str, version: &str,
expires_at: i64, expires_at: i64,
proxy_mode: crate::models::PlaybackProxyMode, proxy_mode: crate::models::PlaybackProxyMode,
client_profile: Option<&super::PlaybackClientProfile>,
) { ) {
let original_default = result.default_mode.clone(); let original_default = result.default_mode.clone();
let prefer_proxy = matches!( let prefer_proxy = matches!(
@ -988,17 +999,29 @@ fn mark_playback_resources(
.iter() .iter()
.any(|media| !media.requires_provider_url()); .any(|media| !media.requires_provider_url());
if selection.direct && direct_available { if selection.direct && direct_available {
generated.insert(mode_name.clone(), original_info.clone()); if let Some(direct_info) = super::build_direct_playback_info_for_client(
&mode_name,
&original_info,
client_profile,
) {
generated.insert(mode_name.clone(), direct_info);
}
} }
if !selection.proxy { if !selection.proxy {
continue; continue;
} }
let mut proxy_info = original_info.clone(); let mut proxy_info = original_info.clone();
proxy_info.medias = original_info let (proxy_medias, proxy_default_media_index) = super::map_playback_resources(
.medias &original_info.medias,
.iter() original_info.default_media_index,
.enumerate() |media_index, media| {
.filter_map(|(media_index, media)| { if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let PlaybackMediaProvider::TrueNas(PlaybackTrueNasMedia::Refresh { let PlaybackMediaProvider::TrueNas(PlaybackTrueNasMedia::Refresh {
credential_owner_id, credential_owner_id,
server_id, server_id,
@ -1018,8 +1041,10 @@ fn mark_playback_resources(
path: path.clone(), path: path.clone(),
}); });
Some(proxy) Some(proxy)
}) },
.collect(); );
proxy_info.medias = proxy_medias;
proxy_info.default_media_index = proxy_default_media_index;
if proxy_info.medias.is_empty() { if proxy_info.medias.is_empty() {
continue; continue;
} }

@ -941,21 +941,65 @@ const fn twitch_resource_kind_name(kind: TwitchPlaybackResourceKind) -> &'static
} }
} }
fn mark_twitch_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_twitch_playback_resources(
for (mode_name, info) in &mut result.playback_infos { result: &mut PlaybackResult,
for (media_index, media) in info.medias.iter_mut().enumerate() { version: &str,
if matches!( expires_at: i64,
media.provider, client_profile: Option<&super::PlaybackClientProfile>,
PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Refresh { .. }) ) {
) { let original_default = result.default_mode.clone();
media.provider = PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Proxy { let original_modes = std::mem::take(&mut result.playback_infos);
version: version.to_string(), for (mode_name, mut info) in original_modes {
expires_at, let source_medias = std::mem::take(&mut info.medias);
mode_name: mode_name.clone(), let supported_indices = source_medias
media_index, .iter()
}); .enumerate()
} .filter_map(|(media_index, media)| {
super::proxy_playback_media_supported_by_client(client_profile, &mode_name, media)
.then_some(media_index)
})
.collect::<std::collections::HashSet<_>>();
let (medias, default_media_index) = super::map_playback_resources(
&source_medias,
info.default_media_index,
|media_index, media| {
if !supported_indices.contains(&media_index) {
return None;
}
let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
if medias.is_empty() {
continue;
} }
info.medias = medias;
info.default_media_index = default_media_index;
let (danmakus, default_danmaku_index) = super::map_playback_resources(
&info.danmakus,
info.default_danmaku_index,
|_, danmaku| {
(!matches!(
&danmaku.provider,
PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { media_index })
if !supported_indices.contains(media_index)
))
.then(|| danmaku.clone())
},
);
info.danmakus = danmakus;
info.default_danmaku_index = default_danmaku_index;
for danmaku in &mut info.danmakus { for danmaku in &mut info.danmakus {
let PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { media_index }) = let PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { media_index }) =
&danmaku.provider &danmaku.provider
@ -969,7 +1013,9 @@ fn mark_twitch_playback_resources(result: &mut PlaybackResult, version: &str, ex
media_index: *media_index, media_index: *media_index,
}); });
} }
result.playback_infos.insert(mode_name, info);
} }
super::select_generated_playback_default(result, &original_default, true);
} }
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_possible_truncation)]
@ -1054,12 +1100,15 @@ impl MediaProvider for TwitchProvider {
credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()), credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()),
credential_server_id, credential_server_id,
); );
Box::pin(super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = Box::pin(super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&cache_key, &cache_key,
Duration::from_hours(6), Duration::from_hours(6),
ctx, ctx,
mark_twitch_playback_resources, |result, version, expires_at| {
mark_twitch_playback_resources(result, version, expires_at, client_profile);
},
|| async { || async {
let (playback, metadata) = tokio::try_join!( let (playback, metadata) = tokio::try_join!(
self.client.playback(&resource, Some(&session)), self.client.playback(&resource, Some(&session)),
@ -1073,7 +1122,12 @@ impl MediaProvider for TwitchProvider {
) )
}, },
)) ))
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn validate_source_config( async fn validate_source_config(
@ -1744,7 +1798,7 @@ mod tests {
PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { .. }) PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { .. })
)); ));
mark_twitch_playback_resources(&mut result, "version-1", 12345); mark_twitch_playback_resources(&mut result, "version-1", 12345, None);
let source = result let source = result
.playback_infos .playback_infos
.get("hls") .get("hls")

@ -1019,21 +1019,47 @@ fn format_metadata(format: &YoutubeFormat) -> Option<PlaybackMediaMetadata> {
}) })
} }
fn mark_youtube_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { fn mark_youtube_playback_resources(
for (mode_name, info) in &mut result.playback_infos { result: &mut PlaybackResult,
for (media_index, media) in info.medias.iter_mut().enumerate() { version: &str,
if matches!( expires_at: i64,
media.provider, client_profile: Option<&super::PlaybackClientProfile>,
PlaybackMediaProvider::Youtube(PlaybackYoutubeMedia::Refresh { .. }) ) {
) { let original_default = result.default_mode.clone();
media.provider = PlaybackMediaProvider::Youtube(PlaybackYoutubeMedia::Proxy { let original_modes = std::mem::take(&mut result.playback_infos);
version: version.to_string(), for (mode_name, mut info) in original_modes {
expires_at, let source_medias = std::mem::take(&mut info.medias);
mode_name: mode_name.clone(), let (medias, default_media_index) = super::map_playback_resources(
media_index, &source_medias,
}); info.default_media_index,
} |media_index, media| {
if !super::proxy_playback_media_supported_by_client(
client_profile,
&mode_name,
media,
) {
return None;
}
let mut media = media.clone();
if matches!(
media.provider,
PlaybackMediaProvider::Youtube(PlaybackYoutubeMedia::Refresh { .. })
) {
media.provider = PlaybackMediaProvider::Youtube(PlaybackYoutubeMedia::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index,
});
}
Some(media)
},
);
if medias.is_empty() {
continue;
} }
info.medias = medias;
info.default_media_index = default_media_index;
for (subtitle_index, subtitle) in info.subtitles.iter_mut().enumerate() { for (subtitle_index, subtitle) in info.subtitles.iter_mut().enumerate() {
if matches!( if matches!(
subtitle.provider, subtitle.provider,
@ -1048,7 +1074,9 @@ fn mark_youtube_playback_resources(result: &mut PlaybackResult, version: &str, e
}); });
} }
} }
result.playback_infos.insert(mode_name, info);
} }
super::select_generated_playback_default(result, &original_default, true);
} }
#[async_trait] #[async_trait]
@ -1120,12 +1148,15 @@ impl MediaProvider for YoutubeProvider {
let credential_partition = let credential_partition =
owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()); owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string());
let cache_key = format!("playback:{video_id}:{credential_partition}:{server_id}"); let cache_key = format!("playback:{video_id}:{credential_partition}:{server_id}");
Box::pin(super::cached_versioned_playback_or_fill( let client_profile = ctx.playback_client_profile();
let result = Box::pin(super::cached_versioned_playback_or_fill(
Self::NAME, Self::NAME,
&cache_key, &cache_key,
Duration::from_hours(5), Duration::from_hours(5),
ctx, ctx,
mark_youtube_playback_resources, |result, version, expires_at| {
mark_youtube_playback_resources(result, version, expires_at, client_profile);
},
|| async { || async {
let player = self let player = self
.client .client
@ -1139,7 +1170,12 @@ impl MediaProvider for YoutubeProvider {
Self::playback_result(&player, owner_id, instance_name.as_deref()) Self::playback_result(&player, owner_id, instance_name.as_deref())
}, },
)) ))
.await .await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
} }
async fn validate_source_config( async fn validate_source_config(
@ -1589,7 +1625,7 @@ mod tests {
.iter() .iter()
.all(|subtitle| subtitle.p2p_swarm_id.is_none()) .all(|subtitle| subtitle.p2p_swarm_id.is_none())
})); }));
mark_youtube_playback_resources(&mut result, "version-1", 1234); mark_youtube_playback_resources(&mut result, "version-1", 1234, None);
for (mode_name, info) in &result.playback_infos { for (mode_name, info) in &result.playback_infos {
assert!(info.medias.iter().all(|media| media.p2p_swarm_id.is_none())); assert!(info.medias.iter().all(|media| media.p2p_swarm_id.is_none()));

@ -137,9 +137,9 @@ pub use notification_partition_manager::{
NotificationPartitionManager, NotificationPartitionManager,
}; };
pub use oauth2::{ pub use oauth2::{
local_oauth_state_store, state_store_from_shared_state_profile, OAuth2LinkResult, local_oauth_state_store, state_store_from_shared_state_profile, OAuth2AuthorizationRequest,
OAuth2Operation, OAuth2PendingRegistration, OAuth2Service, OAuth2ServiceRuntime, OAuth2State, OAuth2LinkResult, OAuth2Operation, OAuth2PendingRegistration, OAuth2Service,
OAuth2UserInfo, OAuthStateStore, RedisOAuthStateStore, OAuth2ServiceRuntime, OAuth2State, OAuth2UserInfo, OAuthStateStore, RedisOAuthStateStore,
}; };
pub use optimistic_retry::retry_with_optimistic_lock; pub use optimistic_retry::retry_with_optimistic_lock;
pub use partitioning::acquire_unbounded_ddl_connection; pub use partitioning::acquire_unbounded_ddl_connection;

@ -102,6 +102,15 @@ pub struct PreparedOAuth2Authorization {
pub oauth_state: OAuth2State, pub oauth_state: OAuth2State,
} }
pub struct OAuth2AuthorizationRequest<'a> {
pub instance_name: &'a str,
pub redirect_url: Option<String>,
pub request_allowed_redirect_url: Option<String>,
pub operation: OAuth2Operation,
pub target_user_id: Option<UserId>,
pub mode: crate::oauth2::OAuth2AuthorizationMode,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum OAuth2LinkResult { pub enum OAuth2LinkResult {
Linked { user_id: UserId, is_new: bool }, Linked { user_id: UserId, is_new: bool },

@ -1,11 +1,15 @@
#[cfg(test)]
use crate::oauth2::OAuth2AuthorizationMode;
use synctv_common::ExecutionControl; use synctv_common::ExecutionControl;
#[cfg(test)] #[cfg(test)]
use tracing::debug; use tracing::debug;
use crate::{ use crate::{
models::UserId, models::UserId,
oauth2::OAuth2AuthorizationMode, service::oauth2::{
service::oauth2::{OAuth2Operation, OAuth2Service, OAuth2State, PreparedOAuth2Authorization}, OAuth2AuthorizationRequest, OAuth2Operation, OAuth2Service, OAuth2State,
PreparedOAuth2Authorization,
},
Error, InternalExt, Result, Error, InternalExt, Result,
}; };
@ -45,20 +49,28 @@ impl OAuth2Service {
pub async fn prepare_authorization_url_with_control( pub async fn prepare_authorization_url_with_control(
&self, &self,
instance_name: &str, request: OAuth2AuthorizationRequest<'_>,
redirect_url: Option<String>,
operation: OAuth2Operation,
target_user_id: Option<UserId>,
mode: OAuth2AuthorizationMode,
control: Option<&ExecutionControl>, control: Option<&ExecutionControl>,
) -> Result<PreparedOAuth2Authorization> { ) -> Result<PreparedOAuth2Authorization> {
let OAuth2AuthorizationRequest {
instance_name,
redirect_url,
request_allowed_redirect_url,
operation,
target_user_id,
mode,
} = request;
Self::validate_operation_target(operation, target_user_id)?; Self::validate_operation_target(operation, target_user_id)?;
if let Some(ref url) = redirect_url { if let Some(ref url) = redirect_url {
let allowed_urls = match &self.runtime_settings_store { let allowed_urls = match &self.runtime_settings_store {
Some(settings) => settings.oauth2.allowed_redirect_urls.get()?.0, Some(settings) => settings.oauth2.allowed_redirect_urls.get()?.0,
None => Vec::new(), None => Vec::new(),
}; };
Self::validate_redirect_url_with_allowlist(url, &allowed_urls)?; if request_allowed_redirect_url.as_deref() == Some(url.as_str()) {
Self::validate_redirect_url_with_allowlist(url, std::slice::from_ref(url))?;
} else {
Self::validate_redirect_url_with_allowlist(url, &allowed_urls)?;
}
} }
let provider = self.provider_entry(instance_name).await?.provider; let provider = self.provider_entry(instance_name).await?.provider;
@ -117,11 +129,14 @@ impl OAuth2Service {
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let prepared = self let prepared = self
.prepare_authorization_url_with_control( .prepare_authorization_url_with_control(
instance_name, OAuth2AuthorizationRequest {
redirect_url, instance_name,
operation, redirect_url,
target_user_id, request_allowed_redirect_url: None,
OAuth2AuthorizationMode::Browser, operation,
target_user_id,
mode: OAuth2AuthorizationMode::Browser,
},
control, control,
) )
.await?; .await?;

@ -752,11 +752,14 @@ async fn test_authorization_rejects_mode_not_advertised_by_provider() {
let error = err( let error = err(
service service
.prepare_authorization_url_with_control( .prepare_authorization_url_with_control(
"github", OAuth2AuthorizationRequest {
None, instance_name: "github",
OAuth2Operation::Login, redirect_url: None,
None, request_allowed_redirect_url: None,
OAuth2AuthorizationMode::Native, operation: OAuth2Operation::Login,
target_user_id: None,
mode: OAuth2AuthorizationMode::Native,
},
None, None,
) )
.await, .await,
@ -787,6 +790,60 @@ async fn test_get_authorization_url_with_redirect() {
assert!(matches!(err, Error::InvalidInput(_))); assert!(matches!(err, Error::InvalidInput(_)));
} }
#[tokio::test]
async fn test_request_allowed_redirect_requires_an_exact_match() {
let service = create_test_service();
service
.register_provider(
"github".to_string(),
OAuth2Provider::GitHub,
Box::new(TestOAuth2Provider::new()),
)
.await;
let callback = "https://app.example.test/oauth2/callback".to_string();
let prepared = ok(
service
.prepare_authorization_url_with_control(
OAuth2AuthorizationRequest {
instance_name: "github",
redirect_url: Some(callback.clone()),
request_allowed_redirect_url: Some(callback.clone()),
operation: OAuth2Operation::Login,
target_user_id: None,
mode: OAuth2AuthorizationMode::Browser,
},
None,
)
.await,
"request-allowed callback should generate",
);
assert_eq!(
prepared.oauth_state.redirect_url.as_deref(),
Some(callback.as_str())
);
let error = err(
service
.prepare_authorization_url_with_control(
OAuth2AuthorizationRequest {
instance_name: "github",
redirect_url: Some(callback),
request_allowed_redirect_url: Some(
"https://app.example.test/other.html".to_string(),
),
operation: OAuth2Operation::Login,
target_user_id: None,
mode: OAuth2AuthorizationMode::Browser,
},
None,
)
.await,
"mismatched request callback must still require the configured allowlist",
);
assert!(error.to_string().contains("allowed redirect URLs"));
}
#[tokio::test] #[tokio::test]
async fn test_get_authorization_url_rejects_invalid_redirect() { async fn test_get_authorization_url_rejects_invalid_redirect() {
let service = create_test_service(); let service = create_test_service();

@ -8,6 +8,13 @@ impl RemoteProviderManager {
pub(super) fn map_remote_resolution_error(err: crate::Error) -> ProviderError { pub(super) fn map_remote_resolution_error(err: crate::Error) -> ProviderError {
match err { match err {
crate::Error::InvalidInput(msg) => ProviderError::InvalidConfig(msg), crate::Error::InvalidInput(msg) => ProviderError::InvalidConfig(msg),
crate::Error::ClientIncompatible {
reason,
required_capability,
} => ProviderError::ClientIncompatible {
reason,
required_capability,
},
crate::Error::RangeNotSatisfiable { total_size } => ProviderError::InvalidConfig( crate::Error::RangeNotSatisfiable { total_size } => ProviderError::InvalidConfig(
format!("Range not satisfiable: total size {total_size}"), format!("Range not satisfiable: total size {total_size}"),
), ),
@ -83,6 +90,13 @@ impl RemoteProviderManager {
ProviderError::UnsupportedFormat(format) => { ProviderError::UnsupportedFormat(format) => {
ProviderError::UnsupportedFormat(format.clone()) ProviderError::UnsupportedFormat(format.clone())
} }
ProviderError::ClientIncompatible {
reason,
required_capability,
} => ProviderError::ClientIncompatible {
reason: reason.clone(),
required_capability: required_capability.clone(),
},
ProviderError::ParseError(message) => ProviderError::ParseError(message.clone()), ProviderError::ParseError(message) => ProviderError::ParseError(message.clone()),
ProviderError::MissingInstance => ProviderError::MissingInstance, ProviderError::MissingInstance => ProviderError::MissingInstance,
ProviderError::InstanceNotFound(name) => ProviderError::InstanceNotFound(name.clone()), ProviderError::InstanceNotFound(name) => ProviderError::InstanceNotFound(name.clone()),

@ -881,6 +881,13 @@ fn configure_well_known_types(config: &mut tonic_prost_build::Config) {
fn build_main_protos(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> { fn build_main_protos(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let mut prost_config = tonic_prost_build::Config::new(); let mut prost_config = tonic_prost_build::Config::new();
configure_well_known_types(&mut prost_config); configure_well_known_types(&mut prost_config);
// prost does not auto-derive Eq/Hash for a message that contains another
// message. Management GET request types use PlaybackClientProfile in
// hashable request keys, and every profile field is itself Eq/Hash.
prost_config.type_attribute(
".synctv.client.PlaybackClientProfile",
"#[derive(Eq, Hash)]",
);
prost_reflect_build::Builder::new() prost_reflect_build::Builder::new()
.descriptor_pool("crate::DESCRIPTOR_POOL") .descriptor_pool("crate::DESCRIPTOR_POOL")
.file_descriptor_set_path(out_dir.join("descriptor.bin")) .file_descriptor_set_path(out_dir.join("descriptor.bin"))

@ -2621,6 +2621,56 @@ enum PlaybackAudioCapability {
PLAYBACK_AUDIO_CAPABILITY_LOSSLESS_SURROUND = 3; PLAYBACK_AUDIO_CAPABILITY_LOSSLESS_SURROUND = 3;
} }
// Runtime environment is declared by the client. Servers must not infer it
// from User-Agent because embedded browsers and privacy modes make that signal
// unreliable.
enum PlaybackClientEnvironment {
PLAYBACK_CLIENT_ENVIRONMENT_UNSPECIFIED = 0;
PLAYBACK_CLIENT_ENVIRONMENT_NATIVE = 1;
PLAYBACK_CLIENT_ENVIRONMENT_WEB = 2;
}
// Delivery protocol consumed by a player pipeline. This is independent from
// the byte container because HLS and DASH can carry several container types.
enum PlaybackMediaTransport {
PLAYBACK_MEDIA_TRANSPORT_UNSPECIFIED = 0;
PLAYBACK_MEDIA_TRANSPORT_PROGRESSIVE = 1;
PLAYBACK_MEDIA_TRANSPORT_HLS = 2;
PLAYBACK_MEDIA_TRANSPORT_DASH = 3;
PLAYBACK_MEDIA_TRANSPORT_FLV = 4;
PLAYBACK_MEDIA_TRANSPORT_MPEG_TS = 5;
}
enum PlaybackMediaPipeline {
PLAYBACK_MEDIA_PIPELINE_UNSPECIFIED = 0;
PLAYBACK_MEDIA_PIPELINE_NATIVE = 1;
PLAYBACK_MEDIA_PIPELINE_MEDIA_SOURCE = 2;
PLAYBACK_MEDIA_PIPELINE_MANAGED_MEDIA_SOURCE = 3;
}
enum PlaybackAudioCodec {
PLAYBACK_AUDIO_CODEC_UNSPECIFIED = 0;
PLAYBACK_AUDIO_CODEC_AAC = 1;
PLAYBACK_AUDIO_CODEC_MP3 = 2;
PLAYBACK_AUDIO_CODEC_OPUS = 3;
PLAYBACK_AUDIO_CODEC_VORBIS = 4;
PLAYBACK_AUDIO_CODEC_AC3 = 5;
PLAYBACK_AUDIO_CODEC_EAC3 = 6;
PLAYBACK_AUDIO_CODEC_FLAC = 7;
}
// One browser-tested media type. Optional codec/container fields mean that the
// client tested the protocol or pipeline without narrowing that dimension.
// codec_string carries the RFC 6381 value used for the runtime probe.
message PlaybackMediaCapability {
PlaybackMediaTransport transport = 1 [(buf.validate.field).enum.defined_only = true];
optional PlaybackContainer container = 2 [(buf.validate.field).enum.defined_only = true];
optional PlaybackVideoCodec video_codec = 3 [(buf.validate.field).enum.defined_only = true];
optional PlaybackAudioCodec audio_codec = 4 [(buf.validate.field).enum.defined_only = true];
PlaybackMediaPipeline pipeline = 5 [(buf.validate.field).enum.defined_only = true];
optional string codec_string = 6 [(buf.validate.field).string.max_len = 128];
}
message PlaybackClientProfile { message PlaybackClientProfile {
PlaybackStreamPreference stream_preference = 1 [(buf.validate.field).enum.defined_only = true]; PlaybackStreamPreference stream_preference = 1 [(buf.validate.field).enum.defined_only = true];
optional int64 max_streaming_bitrate = 2 [(buf.validate.field).int64.gt = 0]; optional int64 max_streaming_bitrate = 2 [(buf.validate.field).int64.gt = 0];
@ -2630,6 +2680,16 @@ message PlaybackClientProfile {
PlaybackAudioCapability audio_capability = 6 [(buf.validate.field).enum.defined_only = true]; PlaybackAudioCapability audio_capability = 6 [(buf.validate.field).enum.defined_only = true];
PlaybackSubtitlePreference subtitle_preference = 7 [(buf.validate.field).enum.defined_only = true]; PlaybackSubtitlePreference subtitle_preference = 7 [(buf.validate.field).enum.defined_only = true];
repeated PlaybackLiveTransport supported_live_transports = 8 [(buf.validate.field).repeated.items.enum.defined_only = true]; repeated PlaybackLiveTransport supported_live_transports = 8 [(buf.validate.field).repeated.items.enum.defined_only = true];
// Version 0 preserves the legacy defaults of fields 1-8. Version 2 gives
// repeated capability fields explicit empty-set semantics.
uint32 profile_version = 9;
PlaybackClientEnvironment environment = 10 [(buf.validate.field).enum.defined_only = true];
repeated PlaybackMediaCapability media_capabilities = 11 [(buf.validate.field).repeated.max_items = 64];
bool supports_custom_http_headers = 12;
bool supports_provider_proxy = 13;
reserved 14 to 19;
// True when the page/runtime may load http:// media without mixed-content blocking.
bool supports_insecure_http_media = 20;
} }
message GetPlaybackRequest { message GetPlaybackRequest {

@ -0,0 +1,29 @@
[package]
name = "synctv-web-ui"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
build = "build.rs"
[features]
default = []
embed = []
[build-dependencies]
brotli.workspace = true
flate2.workspace = true
hex.workspace = true
serde.workspace = true
sha2.workspace = true
toml.workspace = true
[dev-dependencies]
hex.workspace = true
serde.workspace = true
sha2.workspace = true
tempfile.workspace = true
toml.workspace = true
[lints]
workspace = true

@ -0,0 +1,97 @@
# SyncTV Web UI assets
This crate owns the acquisition, optional Flutter build, compression, manifest,
and compile-time embedding of the SyncTV browser client. `synctv-api-http`
only serves the generated asset table.
## Sources
`web-ui.toml` reads prebuilt files from `dist/`. The directory is empty in Git
apart from `.gitkeep`; place a Web distribution there before enabling the
server's `web-ui` feature.
`web-ui.production.toml` is the versioned production source used by CI. Its Git
source pins both the requested revision and its expected full lowercase commit
SHA. The build fails when the revision resolves to another commit.
For local development, create the ignored `web-ui.local.toml` beside the
default file. It takes precedence unless `SYNCTV_WEB_CONFIG` names another
configuration. Relative paths resolve from the selected configuration file.
Prebuilt distribution:
```toml
schema-version = 1
[source]
kind = "dist"
path = "../synctv-app/build/web"
```
Local Flutter project:
```toml
schema-version = 1
[source]
kind = "local-project"
path = "../../flutter/synctv-app"
allow-dirty = true
```
Immutable Git checkout:
```toml
schema-version = 1
[source]
kind = "git"
repository = "https://github.com/synctv-org/synctv-app.git"
revision = "refs/tags/v1.2.3"
commit = "0123456789abcdef0123456789abcdef01234567"
```
The optional `[build]` table accepts `flutter`, `arguments`, and a
`dart-defines` mapping. Arguments and defines participate in the build
fingerprint.
## Commands
Build and export the Web distribution:
```bash
SYNCTV_WEB_CONFIG=synctv-web-ui/web-ui.production.toml \
make web-ui-build WEB_UI_EXPORT_DIR=synctv-web-ui/dist
```
Build the release server with the assets embedded:
```bash
make web-release-build
```
The Web-only command exports to `target/web-ui-dist` by default. CI uploads the
exported distribution once, then passes its authenticated artifact URL and
SHA-256 digest to the existing multi-platform Docker build. Docker verifies and
embeds the archive; it never installs Flutter or builds the frontend.
## Build controls
| Variable | Behavior |
| --- | --- |
| `SYNCTV_WEB_CONFIG` | Select a configuration file explicitly. |
| `SYNCTV_WEB_DIST` | Use a prebuilt directory. This compatibility override takes precedence over configured sources. |
| `SYNCTV_WEB_CACHE_DIR` | Select the Git, Flutter output, and compression cache root. |
| `SYNCTV_WEB_EXPORT_DIR` | Copy the final uncompressed distribution to a disjoint directory. |
| `SYNCTV_WEB_OFFLINE` | Disable Git fetches and use `flutter pub get --offline`. Missing cache entries fail. |
| `SYNCTV_WEB_FORCE_REBUILD` | Fetch the pinned revision again and rebuild Flutter output. |
Relative paths in these controls resolve from the workspace root containing the
`synctv-web-ui` crate. Paths inside a selected configuration resolve from that
configuration file.
The fingerprint includes the source file hash or pinned commit, Flutter version,
build arguments, dart-defines, builder version, and final distribution hash.
Git checkout, Flutter output, and compression data use separate cache layers.
Ordinary workspace builds do not enable the `embed` feature and require no
Flutter installation, Git access, or network access.

@ -0,0 +1,560 @@
#[path = "src/build_support.rs"]
mod build_support;
use build_support::{
build_fingerprint, collect_files, ensure_disjoint_directories, hash_files, load_config,
prepare_git_source, project_watch_directories, resolve_path, FlutterBuild, WebUiSource,
};
use flate2::write::GzEncoder;
use flate2::Compression;
use sha2::{Digest, Sha256};
use std::env;
use std::ffi::{OsStr, OsString};
use std::fmt::Write as _;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const CONFIG_ENV: &str = "SYNCTV_WEB_CONFIG";
const LEGACY_DIST_ENV: &str = "SYNCTV_WEB_DIST";
const CACHE_ENV: &str = "SYNCTV_WEB_CACHE_DIR";
const OFFLINE_ENV: &str = "SYNCTV_WEB_OFFLINE";
const FORCE_ENV: &str = "SYNCTV_WEB_FORCE_REBUILD";
const EXPORT_ENV: &str = "SYNCTV_WEB_EXPORT_DIR";
const COMPRESSION_CACHE_VERSION: &str = "br-q9-w22-gzip-best-v1";
fn main() {
if let Err(error) = build() {
panic!("Web UI build failed: {error}");
}
}
fn build() -> Result<(), String> {
let out_dir = required_path("OUT_DIR")?;
if env::var_os("CARGO_FEATURE_EMBED").is_none() {
fs::write(
out_dir.join("web_assets.rs"),
"pub const WEB_UI_AVAILABLE: bool = false;\n\
pub const BUILD_FINGERPRINT: &str = \"disabled\";\n\
pub static ASSETS: &[Asset] = &[];\n",
)
.map_err(|error| format!("failed to write disabled Web UI manifest: {error}"))?;
return Ok(());
}
for name in [
CONFIG_ENV,
LEGACY_DIST_ENV,
CACHE_ENV,
OFFLINE_ENV,
FORCE_ENV,
EXPORT_ENV,
] {
println!("cargo:rerun-if-env-changed={name}");
}
let manifest_dir = required_path("CARGO_MANIFEST_DIR")?;
let control_base = manifest_dir.parent().unwrap_or(&manifest_dir);
for config_name in [build_support::DEFAULT_CONFIG, build_support::LOCAL_CONFIG] {
println!(
"cargo:rerun-if-changed={}",
manifest_dir.join(config_name).display()
);
}
let explicit_config = env::var_os(CONFIG_ENV)
.map(PathBuf::from)
.map(|path| absolute_control_path(control_base, path));
let legacy_dist = env::var_os(LEGACY_DIST_ENV)
.map(PathBuf::from)
.map(|path| absolute_control_path(control_base, path));
let loaded = load_config(
&manifest_dir,
explicit_config.as_deref(),
legacy_dist.as_deref(),
)?;
println!("cargo:rerun-if-changed={}", loaded.path.display());
let cache_dir = env::var_os(CACHE_ENV).map_or_else(
|| {
manifest_dir
.parent()
.unwrap_or(&manifest_dir)
.join("target/web-ui-cache")
},
|value| absolute_control_path(control_base, PathBuf::from(value)),
);
fs::create_dir_all(&cache_dir).map_err(|error| {
format!(
"failed to create Web UI cache {}: {error}",
cache_dir.display()
)
})?;
let offline = env_flag(OFFLINE_ENV)?;
let force = env_flag(FORCE_ENV)?;
let (dist, source_identity, flutter_version) = match &loaded.config.source {
WebUiSource::Dist { path } => {
let dist = resolve_path(&loaded.path, path);
watch_tree(&dist, false)?;
let files = collect_files(&dist, false)?;
let identity = format!("dist:{}", hash_files(&dist, &files)?);
(dist, identity, "prebuilt".to_owned())
}
WebUiSource::LocalProject { path, allow_dirty } => {
let project = resolve_path(&loaded.path, path);
if !allow_dirty {
ensure_clean_checkout(&project)?;
}
let files = watch_tree(&project, true)?;
let identity = format!("local:{}", hash_files(&project, &files)?);
build_flutter_project(
&project,
&identity,
&loaded.config.build,
&cache_dir,
offline,
force,
)?
}
WebUiSource::Git {
repository,
revision,
commit,
} => {
let project =
prepare_git_source(repository, revision, commit, &cache_dir, offline, force)?;
let identity = format!("git:{repository}@{commit}");
build_flutter_project(
&project,
&identity,
&loaded.config.build,
&cache_dir,
offline,
force,
)?
}
};
if !dist.join("index.html").is_file() {
return Err(format!(
"Web UI output {} does not contain index.html",
dist.display()
));
}
if let Some(export_dir) = env::var_os(EXPORT_ENV)
.map(PathBuf::from)
.map(|path| absolute_control_path(control_base, path))
{
ensure_disjoint_directories(&dist, &export_dir)?;
replace_directory(&dist, &export_dir)?;
}
let files = collect_files(&dist, false)?;
let fingerprint = build_fingerprint(
&format!("{source_identity}:{}", hash_files(&dist, &files)?),
&flutter_version,
&loaded.config.build,
);
generate_assets(&dist, &files, &out_dir, &cache_dir, &fingerprint)
}
fn replace_directory(source: &Path, destination: &Path) -> Result<(), String> {
if destination.exists() {
fs::remove_dir_all(destination).map_err(|error| {
format!(
"failed to clear Web UI export {}: {error}",
destination.display()
)
})?;
}
copy_directory(source, destination)
}
fn copy_directory(source: &Path, destination: &Path) -> Result<(), String> {
fs::create_dir_all(destination).map_err(|error| {
format!(
"failed to create Web UI export {}: {error}",
destination.display()
)
})?;
for entry in fs::read_dir(source)
.map_err(|error| format!("failed to read {}: {error}", source.display()))?
{
let entry =
entry.map_err(|error| format!("failed to inspect {}: {error}", source.display()))?;
let destination_path = destination.join(entry.file_name());
if entry.path().is_dir() {
copy_directory(&entry.path(), &destination_path)?;
} else if entry.path().is_file() {
fs::copy(entry.path(), &destination_path).map_err(|error| {
format!(
"failed to export Web asset {}: {error}",
entry.path().display()
)
})?;
}
}
Ok(())
}
fn required_path(name: &str) -> Result<PathBuf, String> {
env::var_os(name)
.map(PathBuf::from)
.ok_or_else(|| format!("{name} is not set"))
}
fn absolute_control_path(base: &Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
base.join(path)
}
}
fn env_flag(name: &str) -> Result<bool, String> {
let Some(value) = env::var_os(name) else {
return Ok(false);
};
match value.to_string_lossy().trim().to_ascii_lowercase().as_str() {
"" | "0" | "false" | "no" | "off" => Ok(false),
"1" | "true" | "yes" | "on" => Ok(true),
_ => Err(format!("{name} must be a boolean value")),
}
}
fn watch_tree(root: &Path, project: bool) -> Result<Vec<PathBuf>, String> {
if !root.is_dir() {
return Err(format!(
"Web UI source directory {} is missing",
root.display()
));
}
if !project {
println!("cargo:rerun-if-changed={}", root.display());
}
let files = collect_files(root, project)?;
if project {
for directory in project_watch_directories(root)? {
println!("cargo:rerun-if-changed={}", directory.display());
}
let git_index = root.join(".git/index");
if git_index.is_file() {
println!("cargo:rerun-if-changed={}", git_index.display());
}
}
for path in &files {
println!("cargo:rerun-if-changed={}", path.display());
}
Ok(files)
}
fn ensure_clean_checkout(project: &Path) -> Result<(), String> {
let output = run_output(
Command::new("git").arg("-C").arg(project).args([
"status",
"--porcelain",
"--untracked-files=normal",
]),
"inspect local Web UI checkout",
)?;
if output.stdout.is_empty() {
Ok(())
} else {
Err(format!(
"local Web UI checkout {} has uncommitted changes; set source.allow-dirty=true for development",
project.display()
))
}
}
fn build_flutter_project(
project: &Path,
source_identity: &str,
build: &FlutterBuild,
cache_dir: &Path,
offline: bool,
force: bool,
) -> Result<(PathBuf, String, String), String> {
if !project.join("pubspec.yaml").is_file() {
return Err(format!(
"Flutter Web project {} does not contain pubspec.yaml",
project.display()
));
}
let flutter_version = command_stdout(
Command::new(&build.flutter).args(["--version", "--machine"]),
"read Flutter version",
)?;
let fingerprint = build_fingerprint(source_identity, &flutter_version, build);
let output = cache_dir.join("builds").join(&fingerprint).join("web");
if output.join("index.html").is_file() && !force {
return Ok((output, source_identity.to_owned(), flutter_version));
}
let temporary = cache_dir.join("builds").join(".tmp").join(format!(
"{}-{}",
&fingerprint[..20],
std::process::id()
));
if temporary.exists() {
fs::remove_dir_all(&temporary).map_err(|error| {
format!(
"failed to clear temporary Web UI output {}: {error}",
temporary.display()
)
})?;
}
fs::create_dir_all(&temporary).map_err(|error| {
format!(
"failed to create temporary Web UI output {}: {error}",
temporary.display()
)
})?;
let mut pub_get = Command::new(&build.flutter);
pub_get.current_dir(project).args(["pub", "get"]);
if offline {
pub_get.arg("--offline");
}
run_visible(&mut pub_get, "resolve Flutter Web dependencies")?;
let temporary_dist = temporary.join("web");
let mut flutter_build = Command::new(&build.flutter);
flutter_build
.current_dir(project)
.args(["build", "web", "--no-pub", "--output"])
.arg(&temporary_dist)
.args(&build.arguments);
for (key, value) in &build.dart_defines {
flutter_build
.arg("--dart-define")
.arg(format!("{key}={value}"));
}
run_visible(&mut flutter_build, "build Flutter Web client")?;
if !temporary_dist.join("index.html").is_file() {
return Err("Flutter Web build completed without index.html".to_owned());
}
let parent = output
.parent()
.ok_or_else(|| "invalid Web UI build cache path".to_owned())?;
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to create Web UI build cache {}: {error}",
parent.display()
)
})?;
if output.exists() {
fs::remove_dir_all(&output).map_err(|error| {
format!(
"failed to replace Web UI cache {}: {error}",
output.display()
)
})?;
}
fs::rename(&temporary_dist, &output).map_err(|error| {
format!(
"failed to publish Web UI cache {}: {error}",
output.display()
)
})?;
let _ = fs::remove_dir_all(&temporary);
Ok((output, source_identity.to_owned(), flutter_version))
}
fn generate_assets(
dist: &Path,
files: &[PathBuf],
out_dir: &Path,
cache_dir: &Path,
fingerprint: &str,
) -> Result<(), String> {
let compression_cache = cache_dir.join("compressed");
fs::create_dir_all(&compression_cache).map_err(|error| {
format!(
"failed to create compression cache {}: {error}",
compression_cache.display()
)
})?;
let mut source = format!(
"pub const WEB_UI_AVAILABLE: bool = {};\npub const BUILD_FINGERPRINT: &str = {fingerprint:?};\npub static ASSETS: &[Asset] = &[\n",
!files.is_empty()
);
for path in files {
let relative = path
.strip_prefix(dist)
.map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
let route = relative
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if route.is_empty() {
continue;
}
let bytes = fs::read(path)
.map_err(|error| format!("failed to read Web asset {}: {error}", path.display()))?;
let content_type = content_type(&route);
let etag = bytes_etag(&bytes);
let path_literal = rust_string_literal(&path.to_string_lossy());
let (brotli, gzip) = if compressible_content_type(content_type) {
let digest = hex::encode(Sha256::digest(&bytes));
let brotli_path =
compression_cache.join(format!("{COMPRESSION_CACHE_VERSION}-{digest}.br"));
let gzip_path =
compression_cache.join(format!("{COMPRESSION_CACHE_VERSION}-{digest}.gz"));
ensure_compressed(&bytes, &brotli_path, &gzip_path)?;
(
encoded_asset_source(&brotli_path)?,
encoded_asset_source(&gzip_path)?,
)
} else {
("None".to_owned(), "None".to_owned())
};
writeln!(
source,
" Asset {{ path: {route:?}, content_type: {content_type:?}, etag: {etag:?}, bytes: include_bytes!({path_literal}), brotli: {brotli}, gzip: {gzip} }},"
)
.map_err(|error| format!("failed to render Web asset table: {error}"))?;
}
source.push_str("];\n");
fs::write(out_dir.join("web_assets.rs"), source)
.map_err(|error| format!("failed to write generated Web asset table: {error}"))
}
fn ensure_compressed(bytes: &[u8], brotli_path: &Path, gzip_path: &Path) -> Result<(), String> {
if !brotli_path.is_file() {
let mut encoded = Vec::new();
{
let mut encoder = brotli::CompressorWriter::new(&mut encoded, 64 * 1024, 9, 22);
encoder
.write_all(bytes)
.map_err(|error| format!("failed to Brotli-compress Web asset: {error}"))?;
}
fs::write(brotli_path, encoded).map_err(|error| {
format!(
"failed to write Brotli cache {}: {error}",
brotli_path.display()
)
})?;
}
if !gzip_path.is_file() {
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(bytes)
.map_err(|error| format!("failed to gzip Web asset: {error}"))?;
let encoded = encoder
.finish()
.map_err(|error| format!("failed to finish gzip Web asset: {error}"))?;
fs::write(gzip_path, encoded).map_err(|error| {
format!(
"failed to write gzip cache {}: {error}",
gzip_path.display()
)
})?;
}
Ok(())
}
fn encoded_asset_source(path: &Path) -> Result<String, String> {
let bytes = fs::read(path)
.map_err(|error| format!("failed to read encoded asset {}: {error}", path.display()))?;
let path_literal = rust_string_literal(&path.to_string_lossy());
Ok(format!(
"Some(EncodedAsset {{ etag: {:?}, bytes: include_bytes!({path_literal}) }})",
bytes_etag(&bytes),
))
}
fn rust_string_literal(value: &str) -> String {
let escaped = value
.chars()
.flat_map(char::escape_default)
.collect::<String>();
format!("\"{escaped}\"")
}
fn bytes_etag(bytes: &[u8]) -> String {
format!("\"{}-{}\"", hex::encode(Sha256::digest(bytes)), bytes.len())
}
fn content_type(path: &str) -> &'static str {
match Path::new(path)
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"html" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "text/javascript; charset=utf-8",
"json" | "map" => "application/json; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"ico" => "image/x-icon",
"wasm" => "application/wasm",
"woff" => "font/woff",
"woff2" => "font/woff2",
_ => "application/octet-stream",
}
}
fn compressible_content_type(content_type: &str) -> bool {
content_type.starts_with("text/")
|| matches!(
content_type.split(';').next().unwrap_or_default(),
"application/json"
| "application/javascript"
| "application/wasm"
| "application/xml"
| "image/svg+xml"
)
}
fn run_visible(command: &mut Command, description: &str) -> Result<(), String> {
let status = command
.status()
.map_err(|error| format!("failed to {description}: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!("failed to {description} ({status})"))
}
}
fn run_output(command: &mut Command, description: &str) -> Result<Output, String> {
command
.output()
.map_err(|error| format!("failed to {description}: {error}"))
}
fn command_stdout(command: &mut Command, description: &str) -> Result<String, String> {
let output = run_output(command, description)?;
if !output.status.success() {
return Err(command_failure(description, &output));
}
String::from_utf8(output.stdout)
.map(|value| value.trim().to_owned())
.map_err(|error| format!("{description} returned non-UTF-8 output: {error}"))
}
fn command_failure(description: &str, output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let details = if stderr.is_empty() { stdout } else { stderr };
format!("failed to {description} ({}): {details}", output.status)
}
#[allow(dead_code)]
fn _display_command(program: &OsStr, arguments: &[OsString]) -> String {
std::iter::once(program.to_string_lossy().into_owned())
.chain(
arguments
.iter()
.map(|argument| argument.to_string_lossy().into_owned()),
)
.collect::<Vec<_>>()
.join(" ")
}

@ -0,0 +1,848 @@
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
pub const BUILDER_VERSION: &str = "synctv-web-ui-v3";
pub const DEFAULT_CONFIG: &str = "web-ui.toml";
pub const LOCAL_CONFIG: &str = "web-ui.local.toml";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct WebUiConfig {
pub schema_version: u32,
pub source: WebUiSource,
#[serde(default)]
pub build: FlutterBuild,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(
tag = "kind",
rename_all = "kebab-case",
rename_all_fields = "kebab-case",
deny_unknown_fields
)]
pub enum WebUiSource {
Dist {
path: PathBuf,
},
LocalProject {
path: PathBuf,
#[serde(default = "default_allow_dirty")]
allow_dirty: bool,
},
Git {
repository: String,
revision: String,
commit: String,
},
}
const fn default_allow_dirty() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct FlutterBuild {
pub flutter: String,
pub arguments: Vec<String>,
pub dart_defines: BTreeMap<String, String>,
}
impl Default for FlutterBuild {
fn default() -> Self {
Self {
flutter: "flutter".to_owned(),
arguments: vec![
"--release".to_owned(),
"--no-web-resources-cdn".to_owned(),
"--no-wasm-dry-run".to_owned(),
],
dart_defines: BTreeMap::new(),
}
}
}
#[derive(Debug)]
pub struct LoadedConfig {
pub config: WebUiConfig,
pub path: PathBuf,
}
pub fn load_config(
manifest_dir: &Path,
explicit: Option<&Path>,
legacy_dist: Option<&Path>,
) -> Result<LoadedConfig, String> {
if let Some(path) = legacy_dist {
return Ok(LoadedConfig {
config: WebUiConfig {
schema_version: 1,
source: WebUiSource::Dist {
path: path.to_path_buf(),
},
build: FlutterBuild::default(),
},
path: manifest_dir.join(LOCAL_CONFIG),
});
}
let path = explicit.map_or_else(
|| {
let local = manifest_dir.join(LOCAL_CONFIG);
if local.is_file() {
local
} else {
manifest_dir.join(DEFAULT_CONFIG)
}
},
Path::to_path_buf,
);
let text = fs::read_to_string(&path)
.map_err(|error| format!("failed to read Web UI config {}: {error}", path.display()))?;
let config: WebUiConfig = toml::from_str(&text)
.map_err(|error| format!("invalid Web UI config {}: {error}", path.display()))?;
if config.schema_version != 1 {
return Err(format!(
"unsupported Web UI config schema {} in {}",
config.schema_version,
path.display()
));
}
validate_config(&config)?;
Ok(LoadedConfig { config, path })
}
fn validate_config(config: &WebUiConfig) -> Result<(), String> {
if config.build.flutter.trim().is_empty() {
return Err("build.flutter must not be empty".to_owned());
}
if config
.build
.arguments
.iter()
.any(|argument| argument.contains('\n') || argument.contains('\r'))
{
return Err("build.arguments must not contain line breaks".to_owned());
}
if let WebUiSource::Git {
repository,
revision,
commit,
} = &config.source
{
if repository.trim().is_empty() || revision.trim().is_empty() {
return Err("Git repository and revision must not be empty".to_owned());
}
if repository.contains(['\n', '\r']) || revision.contains(['\n', '\r']) {
return Err("Git source values must not contain line breaks".to_owned());
}
if repository
.split_once("://")
.and_then(|(_, remainder)| remainder.split('/').next())
.is_some_and(|authority| authority.contains('@'))
{
return Err("Git repository URLs must not contain credentials".to_owned());
}
if !is_full_commit(commit) {
return Err("Git commit must be a full lowercase SHA-1".to_owned());
}
}
Ok(())
}
fn is_full_commit(value: &str) -> bool {
value.len() == 40
&& value
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
pub fn resolve_path(config_path: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
config_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(path)
}
}
pub fn collect_files(root: &Path, project: bool) -> Result<Vec<PathBuf>, String> {
if project {
if let Some(files) = git_project_files(root)? {
return Ok(files);
}
}
let mut files = Vec::new();
collect_files_inner(root, root, project, &mut files)?;
files.sort();
Ok(files)
}
pub fn project_watch_directories(root: &Path) -> Result<Vec<PathBuf>, String> {
let mut directories = BTreeSet::new();
collect_project_directories(root, root, &mut directories)?;
directories.remove(root);
Ok(directories.into_iter().collect())
}
fn collect_project_directories(
root: &Path,
directory: &Path,
directories: &mut BTreeSet<PathBuf>,
) -> Result<(), String> {
directories.insert(directory.to_path_buf());
let entries = fs::read_dir(directory)
.map_err(|error| format!("failed to read {}: {error}", directory.display()))?;
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"failed to inspect an entry in {}: {error}",
directory.display()
)
})?;
let path = entry.path();
if path.is_dir() && !excluded_project_directory(&path) && path.starts_with(root) {
collect_project_directories(root, &path, directories)?;
}
}
Ok(())
}
pub fn ensure_disjoint_directories(source: &Path, destination: &Path) -> Result<(), String> {
let source = source.canonicalize().map_err(|error| {
format!(
"failed to resolve Web UI source directory {}: {error}",
source.display()
)
})?;
let destination = resolve_existing_ancestor(destination)?;
if source == destination || source.starts_with(&destination) || destination.starts_with(&source)
{
return Err(format!(
"Web UI export {} must not equal, contain, or be contained by source {}",
destination.display(),
source.display()
));
}
Ok(())
}
pub fn prepare_git_source(
repository: &str,
revision: &str,
commit: &str,
cache_dir: &Path,
offline: bool,
force: bool,
) -> Result<PathBuf, String> {
let repository_key = hex::encode(Sha256::digest(repository.as_bytes()));
let project = cache_dir
.join("git")
.join(&repository_key[..24])
.join(commit);
let git_dir = project.join(".git");
if !git_dir.is_dir() {
if offline {
return Err(format!(
"offline Web UI build requires cached Git commit {commit}"
));
}
fs::create_dir_all(&project).map_err(|error| {
format!("failed to create Git cache {}: {error}", project.display())
})?;
run(
Command::new("git").arg("init").arg(&project),
"initialize Web UI Git cache",
)?;
run(
Command::new("git")
.arg("-C")
.arg(&project)
.args(["remote", "add", "origin", repository]),
"configure Web UI Git remote",
)?;
}
let has_commit = command_succeeds(Command::new("git").arg("-C").arg(&project).args([
"cat-file",
"-e",
&format!("{commit}^{{commit}}"),
]));
let source_marker = project.join(".synctv-source");
let expected_marker =
format!("repository={repository}\nrevision={revision}\ncommit={commit}\n");
let source_is_validated =
fs::read_to_string(&source_marker).is_ok_and(|value| value == expected_marker);
if !has_commit || !source_is_validated || force {
if offline {
return Err(format!(
"offline Web UI build cannot validate uncached Git source {repository}@{revision} ({commit})"
));
}
run(
Command::new("git").arg("-C").arg(&project).args([
"fetch",
"--depth=1",
"--no-tags",
"origin",
revision,
]),
"fetch Web UI Git revision",
)?;
let resolved = command_stdout(
Command::new("git")
.arg("-C")
.arg(&project)
.args(["rev-parse", "FETCH_HEAD^{commit}"]),
"resolve Web UI Git revision",
)?;
if resolved != commit {
return Err(format!(
"Web UI revision {revision} resolved to {resolved}, expected pinned commit {commit}"
));
}
fs::write(&source_marker, expected_marker).map_err(|error| {
format!(
"failed to record validated Web UI Git source {}: {error}",
source_marker.display()
)
})?;
}
run(
Command::new("git")
.arg("-C")
.arg(&project)
.args(["checkout", "--force", "--detach", commit]),
"check out Web UI Git commit",
)?;
Ok(project)
}
fn run(command: &mut Command, description: &str) -> Result<(), String> {
let output = run_output(command, description)?;
if output.status.success() {
Ok(())
} else {
Err(command_failure(description, &output))
}
}
fn run_output(command: &mut Command, description: &str) -> Result<Output, String> {
command
.output()
.map_err(|error| format!("failed to {description}: {error}"))
}
fn command_stdout(command: &mut Command, description: &str) -> Result<String, String> {
let output = run_output(command, description)?;
if !output.status.success() {
return Err(command_failure(description, &output));
}
String::from_utf8(output.stdout)
.map(|value| value.trim().to_owned())
.map_err(|error| format!("{description} returned non-UTF-8 output: {error}"))
}
fn command_succeeds(command: &mut Command) -> bool {
command.output().is_ok_and(|output| output.status.success())
}
fn command_failure(description: &str, output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let details = if stderr.is_empty() { stdout } else { stderr };
format!("failed to {description} ({}): {details}", output.status)
}
fn resolve_existing_ancestor(path: &Path) -> Result<PathBuf, String> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| format!("failed to resolve current directory: {error}"))?
.join(path)
};
let absolute = normalize_path(&absolute);
let mut existing = absolute.as_path();
let mut missing = Vec::new();
while !existing.exists() {
let name = existing.file_name().ok_or_else(|| {
format!(
"Web UI export path {} has no existing ancestor",
path.display()
)
})?;
missing.push(name.to_os_string());
existing = existing.parent().ok_or_else(|| {
format!(
"Web UI export path {} has no existing ancestor",
path.display()
)
})?;
}
let mut resolved = existing.canonicalize().map_err(|error| {
format!(
"failed to resolve Web UI export ancestor {}: {error}",
existing.display()
)
})?;
for name in missing.iter().rev() {
resolved.push(name);
}
Ok(resolved)
}
fn normalize_path(path: &Path) -> PathBuf {
use std::path::Component;
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
fn git_project_files(root: &Path) -> Result<Option<Vec<PathBuf>>, String> {
let output = match Command::new("git")
.arg("-C")
.arg(root)
.args([
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
])
.output()
{
Ok(output) if output.status.success() => output.stdout,
Ok(_) => return Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"failed to enumerate Git files in {}: {error}",
root.display()
));
}
};
let mut files = Vec::new();
for encoded in output
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
{
let relative = std::str::from_utf8(encoded).map_err(|error| {
format!(
"Git returned a non-UTF-8 Web UI path in {}: {error}",
root.display()
)
})?;
let path = root.join(relative);
if path.is_file() {
files.push(path);
}
}
files.sort();
files.dedup();
Ok(Some(files))
}
fn collect_files_inner(
root: &Path,
directory: &Path,
project: bool,
files: &mut Vec<PathBuf>,
) -> Result<(), String> {
let entries = fs::read_dir(directory)
.map_err(|error| format!("failed to read {}: {error}", directory.display()))?;
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"failed to inspect an entry in {}: {error}",
directory.display()
)
})?;
let path = entry.path();
if path.is_dir() {
if project && excluded_project_directory(&path) {
continue;
}
collect_files_inner(root, &path, project, files)?;
} else if path.is_file()
&& path.strip_prefix(root).is_ok()
&& (!project || !excluded_project_file(&path))
{
files.push(path);
}
}
Ok(())
}
fn excluded_project_directory(path: &Path) -> bool {
matches!(
path.file_name().and_then(OsStr::to_str),
Some(".git" | ".dart_tool" | ".idea" | ".vscode" | "build" | "target")
)
}
fn excluded_project_file(path: &Path) -> bool {
matches!(
path.file_name().and_then(OsStr::to_str),
Some(".DS_Store" | ".flutter-plugins" | ".flutter-plugins-dependencies" | ".packages")
)
}
pub fn hash_files(root: &Path, files: &[PathBuf]) -> Result<String, String> {
let mut hasher = Sha256::new();
for path in files {
let relative = path
.strip_prefix(root)
.map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
hasher.update(relative.to_string_lossy().as_bytes());
hasher.update([0]);
let bytes = fs::read(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
hasher.update(bytes.len().to_le_bytes());
hasher.update(bytes);
}
Ok(hex::encode(hasher.finalize()))
}
pub fn build_fingerprint(
source_identity: &str,
flutter_version: &str,
build: &FlutterBuild,
) -> String {
let mut hasher = Sha256::new();
hasher.update(BUILDER_VERSION.as_bytes());
hasher.update([0]);
hasher.update(source_identity.as_bytes());
hasher.update([0]);
hasher.update(flutter_version.as_bytes());
for argument in &build.arguments {
hasher.update([0]);
hasher.update(argument.as_bytes());
}
for (key, value) in &build.dart_defines {
hasher.update([0]);
hasher.update(key.as_bytes());
hasher.update(b"=");
hasher.update(value.as_bytes());
}
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn local_config_takes_precedence() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
fs::write(
directory.path().join(DEFAULT_CONFIG),
"schema-version=1\n[source]\nkind='dist'\npath='default'\n",
)?;
fs::write(
directory.path().join(LOCAL_CONFIG),
"schema-version=1\n[source]\nkind='dist'\npath='local'\n",
)?;
let loaded = load_config(directory.path(), None, None)?;
assert_eq!(loaded.path, directory.path().join(LOCAL_CONFIG));
assert!(matches!(
&loaded.config.source,
WebUiSource::Dist { path }
if resolve_path(&loaded.path, path) == directory.path().join("local")
));
Ok(())
}
#[test]
fn fingerprint_is_stable_for_ordered_defines() {
let mut first = FlutterBuild::default();
first.dart_defines.insert("B".to_owned(), "2".to_owned());
first.dart_defines.insert("A".to_owned(), "1".to_owned());
let mut second = FlutterBuild::default();
second.dart_defines.insert("A".to_owned(), "1".to_owned());
second.dart_defines.insert("B".to_owned(), "2".to_owned());
assert_eq!(
build_fingerprint("source", "flutter", &first),
build_fingerprint("source", "flutter", &second)
);
}
#[test]
fn project_hash_ignores_generated_build_output() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
fs::create_dir(directory.path().join("lib"))?;
fs::create_dir(directory.path().join("build"))?;
fs::write(directory.path().join("lib/main.dart"), "void main() {}")?;
fs::write(directory.path().join("build/output.js"), "generated")?;
fs::write(
directory.path().join(".flutter-plugins-dependencies"),
"generated",
)?;
let files = collect_files(directory.path(), true)?;
let initial_hash = hash_files(directory.path(), &files)?;
fs::write(directory.path().join("build/output.js"), "changed")?;
fs::write(
directory.path().join(".flutter-plugins-dependencies"),
"changed",
)?;
let next_files = collect_files(directory.path(), true)?;
assert_eq!(files, vec![directory.path().join("lib/main.dart")]);
assert_eq!(initial_hash, hash_files(directory.path(), &next_files)?);
Ok(())
}
#[test]
fn git_project_files_exclude_ignored_generated_inputs() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
fs::create_dir(directory.path().join("lib"))?;
fs::create_dir(directory.path().join("generated"))?;
fs::write(directory.path().join("lib/main.dart"), "void main() {}")?;
fs::write(directory.path().join("lib/pending.dart"), "pending")?;
fs::write(directory.path().join("generated/output"), "generated")?;
fs::write(directory.path().join(".gitignore"), "generated/\n")?;
assert!(Command::new("git")
.arg("init")
.arg(directory.path())
.status()?
.success());
assert!(Command::new("git")
.arg("-C")
.arg(directory.path())
.args(["add", ".gitignore", "lib/main.dart"])
.status()?
.success());
let files = collect_files(directory.path(), true)?;
let relative = files
.iter()
.map(|path| path.strip_prefix(directory.path()))
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
relative,
vec![
Path::new(".gitignore"),
Path::new("lib/main.dart"),
Path::new("lib/pending.dart")
]
);
Ok(())
}
#[test]
fn git_source_rejects_credentials_and_partial_commits() {
let config = WebUiConfig {
schema_version: 1,
source: WebUiSource::Git {
repository: "https://user:secret@example.com/repo".to_owned(),
revision: "main".to_owned(),
commit: "abc".to_owned(),
},
build: FlutterBuild::default(),
};
assert!(validate_config(&config).is_err());
}
#[test]
fn project_watch_directories_cover_new_files_without_generated_output(
) -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let root = directory.path();
fs::create_dir_all(root.join("lib/empty"))?;
fs::create_dir_all(root.join("packages/player/lib"))?;
fs::create_dir_all(root.join("build/web"))?;
assert_eq!(
project_watch_directories(root)?,
vec![
root.join("lib"),
root.join("lib/empty"),
root.join("packages"),
root.join("packages/player"),
root.join("packages/player/lib")
]
);
Ok(())
}
#[test]
fn export_must_be_disjoint_from_source() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let source = directory.path().join("source");
let sibling = directory.path().join("export");
fs::create_dir_all(source.join("nested"))?;
assert!(ensure_disjoint_directories(&source, &source).is_err());
assert!(ensure_disjoint_directories(&source, &source.join("export")).is_err());
assert!(ensure_disjoint_directories(&source.join("nested"), &source).is_err());
ensure_disjoint_directories(&source, &sibling)?;
Ok(())
}
#[test]
fn offline_git_source_requires_cached_commit() -> Result<(), Box<dyn std::error::Error>> {
let cache = tempfile::tempdir()?;
let error = prepare_git_source(
"https://example.invalid/app.git",
"main",
"a234567890abcdef0123456789abcdef01234567",
cache.path(),
true,
false,
)
.expect_err("uncached offline source must fail");
assert!(error.contains("requires cached Git commit"));
Ok(())
}
#[test]
fn git_source_rejects_revision_at_another_commit() -> Result<(), Box<dyn std::error::Error>> {
let repository = create_test_repository()?;
let revision = git_stdout(repository.path(), &["rev-parse", "HEAD"])?;
let cache = tempfile::tempdir()?;
let expected = if revision.starts_with('a') {
"b".repeat(40)
} else {
"a".repeat(40)
};
let error = prepare_git_source(
&repository.path().to_string_lossy(),
&revision,
&expected,
cache.path(),
false,
false,
)
.expect_err("mismatched revision must fail");
assert!(error.contains("resolved to"));
assert!(error.contains("expected pinned commit"));
Ok(())
}
#[test]
fn cached_git_source_revalidates_a_changed_revision() -> Result<(), Box<dyn std::error::Error>>
{
let repository = create_test_repository()?;
let first = git_stdout(repository.path(), &["rev-parse", "HEAD"])?;
let cache = tempfile::tempdir()?;
prepare_git_source(
&repository.path().to_string_lossy(),
&first,
&first,
cache.path(),
false,
false,
)?;
fs::write(repository.path().join("pubspec.yaml"), "name: changed\n")?;
assert!(Command::new("git")
.arg("-C")
.arg(repository.path())
.args(["add", "pubspec.yaml"])
.status()?
.success());
assert!(Command::new("git")
.arg("-C")
.arg(repository.path())
.args([
"-c",
"user.name=SyncTV Test",
"-c",
"user.email=test@example.invalid",
"commit",
"-m",
"changed fixture",
])
.status()?
.success());
let second = git_stdout(repository.path(), &["rev-parse", "HEAD"])?;
let error = prepare_git_source(
&repository.path().to_string_lossy(),
&second,
&first,
cache.path(),
false,
false,
)
.expect_err("a changed revision must be resolved again");
assert!(error.contains("resolved to"));
assert!(error.contains("expected pinned commit"));
Ok(())
}
fn create_test_repository() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
let repository = tempfile::tempdir()?;
assert!(Command::new("git")
.arg("init")
.arg(repository.path())
.status()?
.success());
fs::write(repository.path().join("pubspec.yaml"), "name: app\n")?;
assert!(Command::new("git")
.arg("-C")
.arg(repository.path())
.args(["add", "pubspec.yaml"])
.status()?
.success());
assert!(Command::new("git")
.arg("-C")
.arg(repository.path())
.args([
"-c",
"user.name=SyncTV Test",
"-c",
"user.email=test@example.invalid",
"commit",
"-m",
"fixture",
])
.status()?
.success());
Ok(repository)
}
fn git_stdout(
repository: &Path,
arguments: &[&str],
) -> Result<String, Box<dyn std::error::Error>> {
let output = Command::new("git")
.arg("-C")
.arg(repository)
.args(arguments)
.output()?;
assert!(output.status.success());
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
}
}

@ -0,0 +1,21 @@
#[derive(Clone, Copy, Debug)]
pub struct Asset {
pub path: &'static str,
pub content_type: &'static str,
pub etag: &'static str,
pub bytes: &'static [u8],
pub brotli: Option<EncodedAsset>,
pub gzip: Option<EncodedAsset>,
}
#[derive(Clone, Copy, Debug)]
pub struct EncodedAsset {
pub etag: &'static str,
pub bytes: &'static [u8],
}
include!(concat!(env!("OUT_DIR"), "/web_assets.rs"));
#[cfg(test)]
#[path = "build_support.rs"]
mod build_support;

@ -0,0 +1,11 @@
schema-version = 1
[source]
kind = "git"
repository = "https://github.com/synctv-org/synctv-app.git"
revision = "64235a3584e4fa97560629e34f0160cc44672898"
commit = "64235a3584e4fa97560629e34f0160cc44672898"
[build]
flutter = "flutter"
arguments = ["--release", "--no-web-resources-cdn", "--no-wasm-dry-run"]

@ -0,0 +1,5 @@
schema-version = 1
[source]
kind = "dist"
path = "dist"

@ -12,6 +12,7 @@ path = "src/main.rs"
[features] [features]
# OpenAPI is opt-in through the `openapi` feature below. # OpenAPI is opt-in through the `openapi` feature below.
default = ["tls-aws-lc", "tls-webpki-roots"] default = ["tls-aws-lc", "tls-webpki-roots"]
web-ui = ["synctv-api/web-ui"]
# Enable Kubernetes support (K8s DNS discovery, K8s Lease leader election). # Enable Kubernetes support (K8s DNS discovery, K8s Lease leader election).
# Disable to reduce binary size for non-K8s deployments: # Disable to reduce binary size for non-K8s deployments:
# cargo build --no-default-features # cargo build --no-default-features

@ -409,6 +409,7 @@ impl PlaybackClientProfileArgs {
.subtitle_preference .subtitle_preference
.map_or(0, CliPlaybackSubtitlePreference::to_proto), .map_or(0, CliPlaybackSubtitlePreference::to_proto),
supported_live_transports: Vec::new(), supported_live_transports: Vec::new(),
..Default::default()
}) })
} }
} }

@ -2896,11 +2896,11 @@ impl SyncTvServer {
} }
}; };
let router = http_router.merge(grpc_router);
let server = axum::serve( let server = axum::serve(
listener, listener,
http_router router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
.merge(grpc_router)
.into_make_service_with_connect_info::<std::net::SocketAddr>(),
) )
.with_graceful_shutdown(graceful); .with_graceful_shutdown(graceful);

Loading…
Cancel
Save