diff --git a/.dockerignore b/.dockerignore index d8781d8d..fc0aab59 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,4 +18,6 @@ helm/ scripts/ .env .env.* +**/*.local +**/*.local.* synctv.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c77290b..f8d8289e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master with: + toolchain: nightly-2026-08-21 components: rustfmt - name: Check formatting @@ -46,8 +47,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master with: + toolchain: nightly-2026-08-21 components: clippy - name: Install build dependencies @@ -69,7 +71,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev @@ -88,7 +92,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev @@ -108,7 +114,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev @@ -134,7 +142,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install cargo-nextest uses: taiki-e/install-action@nextest @@ -181,7 +191,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install cargo-nextest uses: taiki-e/install-action@nextest @@ -205,7 +217,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install cargo-audit run: make install-cargo-audit @@ -226,7 +240,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install cargo-deny run: make install-cargo-deny @@ -258,7 +274,9 @@ jobs: - uses: actions/checkout@v5 - name: Install Rust nightly - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d383efa4..e7f34e03 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -57,10 +57,73 @@ jobs: echo 'EOF' } >> "$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: name: Docker Build - needs: docker-prepare + needs: + - docker-prepare + - web-ui permissions: + actions: read contents: read packages: write id-token: write @@ -81,6 +144,9 @@ jobs: cache-scope: synctv-image build-args: | 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_TERM_COLOR=always set-meta-annotations: true diff --git a/.github/workflows/helm-ci.yml b/.github/workflows/helm-ci.yml index 0885905d..41c1b7b7 100644 --- a/.github/workflows/helm-ci.yml +++ b/.github/workflows/helm-ci.yml @@ -49,7 +49,9 @@ jobs: uses: azure/setup-helm@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index c8193c19..d79e8b32 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -39,7 +39,9 @@ jobs: uses: azure/setup-helm@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Install build dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a09e9487..cbe346fe 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -28,7 +28,9 @@ jobs: uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-08-21 - name: Normalize release version id: version diff --git a/.gitignore b/.gitignore index 69b5e73e..a29a0fd2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,9 @@ logs/ .env.synctv 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 diff --git a/Cargo.lock b/Cargo.lock index 5ae8e49c..ad91b208 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7858,6 +7858,7 @@ dependencies = [ "synctv-proto", "synctv-proxy", "synctv-realtime", + "synctv-web-ui", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -8325,6 +8326,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "synctv-web-ui" +version = "1.0.3" +dependencies = [ + "brotli", + "flate2", + "hex", + "serde", + "sha2 0.11.0", + "tempfile", + "toml", +] + [[package]] name = "synctv-xiu" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index f89e7c1c..821338d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "synctv-api", "synctv-api-common", "synctv-api-http", + "synctv-web-ui", "synctv-api-grpc", "synctv-livestream", "synctv-cluster", @@ -379,6 +380,7 @@ synctv-adapter = { path = "synctv-adapter", default-features = false } synctv-api = { path = "synctv-api", default-features = false } synctv-api-common = { path = "synctv-api-common", 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-cluster = { path = "synctv-cluster", default-features = false } synctv-realtime = { path = "synctv-realtime", default-features = false } diff --git a/Dockerfile b/Dockerfile index 66a002b7..0907c021 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Stage 1: Build +# Stage 1: Build the backend for the target platform. FROM rust:slim-trixie AS builder # Install build dependencies @@ -9,10 +9,12 @@ RUN apt-get update && apt-get install -y \ protobuf-compiler \ pkg-config \ build-essential \ + lld \ libclang-dev \ nasm \ cmake \ curl \ + unzip \ perl \ 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_CARGO_BUILD_ARGS="" ARG SYNCTV_CARGO_BUILD_PROFILE=release +ARG SYNCTV_WEB_ARTIFACT_URL="" +ARG SYNCTV_WEB_ARTIFACT_DIGEST="" ARG CARGO_INCREMENTAL=0 ARG CARGO_TERM_COLOR="auto" ARG TARGETARCH @@ -43,10 +47,39 @@ ENV CARGO_TERM_COLOR=$CARGO_TERM_COLOR # Copy entire source tree 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 # Copy binary out of cache mount before RUN completes RUN --mount=type=cache,target=/usr/local/cargo/registry \ --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 \ case "$SYNCTV_CARGO_BUILD_PROFILE" in \ dev) target_profile_dir=debug ;; \ @@ -63,8 +96,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ if [ -n "$SYNCTV_BUILD_FEATURES" ]; then \ build_flags="$build_flags --features $SYNCTV_BUILD_FEATURES"; \ fi; \ - cargo +nightly \ - --config 'build.rustflags=["-Clink-arg=-Wl,-z,pack-relative-relocs"]' \ + RUSTFLAGS="-Clink-arg=-fuse-ld=lld -Clink-arg=-Wl,-z,pack-relative-relocs" \ + cargo \ build $build_flags \ --bin synctv && \ cp "target/$target_profile_dir/synctv" /synctv diff --git a/Makefile b/Makefile index fb849fa2..07e66485 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,8 @@ COMPOSE ?= docker compose PROD_COMPOSE_FILE ?= docker-compose.yml COMPOSE_PROD := $(COMPOSE) -f $(PROD_COMPOSE_FILE) COMPOSE_ENV_FILES := .env.postgres .env.redis .env.synctv -RUST_TOOLCHAIN ?= nightly -CARGO ?= cargo +$(RUST_TOOLCHAIN) -CROSS ?= cargo cross +$(RUST_TOOLCHAIN) +CARGO ?= cargo +CROSS ?= cargo cross CARGO_LOCKED ?= --locked CARGO_WORKSPACE_ARGS ?= --workspace CARGO_ALL_TARGETS_ARGS ?= --all-targets @@ -118,7 +117,7 @@ export SYNCTV_MANAGEMENT_TRANSPORT=unix; \ export SYNCTV_MANAGEMENT_UNIX_SOCKET_PATH="$(DEV_SOCKET)" 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. @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 @command -v rustup >/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. @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. 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. mkdir -p "$(DEV_DATA_DIR)/run" @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. @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. - RUSTUP_TOOLCHAIN="$(RUST_TOOLCHAIN)" scripts/validate-helm.sh + scripts/validate-helm.sh require-cross: @command -v cargo-cross >/dev/null || { printf "cargo-cross is required; run 'make install-cross'.\n" >&2; exit 1; } diff --git a/README.md b/README.md index 59195a39..d4d72030 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,28 @@ Start locally: 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 Read the complete documentation at [docs.syncs.tv](https://docs.syncs.tv). diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4faab903..16045efa 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "nightly" +channel = "nightly-2026-08-21" profile = "minimal" components = ["clippy", "rustfmt"] diff --git a/synctv-adapter/src/error.rs b/synctv-adapter/src/error.rs index b9bd07a8..5752e22a 100644 --- a/synctv-adapter/src/error.rs +++ b/synctv-adapter/src/error.rs @@ -17,6 +17,7 @@ pub mod error_codes { pub const CONFLICT: i32 = 2003; pub const INVALID_ARGUMENT: i32 = 3000; + pub const FAILED_PRECONDITION: i32 = 3001; pub const PERMISSION_DENIED: i32 = 4000; pub const FORBIDDEN: i32 = 4001; @@ -35,6 +36,7 @@ pub enum ErrorKind { AlreadyExists, Conflict, InvalidArgument, + FailedPrecondition, RateLimited, ServiceUnavailable, Timeout, @@ -51,6 +53,7 @@ impl ErrorKind { Self::AlreadyExists => error_codes::ALREADY_EXISTS, Self::Conflict => error_codes::CONFLICT, Self::InvalidArgument => error_codes::INVALID_ARGUMENT, + Self::FailedPrecondition => error_codes::FAILED_PRECONDITION, Self::RateLimited => error_codes::RESOURCE_EXHAUSTED, Self::ServiceUnavailable => error_codes::SERVICE_UNAVAILABLE, 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::Conflict => tonic::Status::aborted(message), ErrorKind::InvalidArgument => tonic::Status::invalid_argument(message), + ErrorKind::FailedPrecondition => tonic::Status::failed_precondition(message), ErrorKind::RateLimited => tonic::Status::resource_exhausted(message), ErrorKind::ServiceUnavailable => tonic::Status::unavailable(message), ErrorKind::Timeout => tonic::Status::deadline_exceeded(message), @@ -107,6 +111,7 @@ impl ClassifiedError for ProviderError { | Self::InvalidCredentialType | Self::EncryptionRequired(_) | Self::JsonError(_) => ErrorKind::InvalidArgument, + Self::ClientIncompatible { .. } => ErrorKind::FailedPrecondition, Self::NotFound | Self::InstanceNotFound(_) | Self::CredentialNotFound(_) => { ErrorKind::NotFound } @@ -145,6 +150,7 @@ impl ClassifiedError for ProviderError { | Self::CredentialExpired(message) | Self::RouteRegistrationFailed(message) | Self::Internal(message) => Cow::Borrowed(message), + Self::ClientIncompatible { reason, .. } => Cow::Borrowed(reason), Self::NotFound => Cow::Borrowed(synctv_common::messages::RESOURCE_NOT_FOUND), Self::MissingInstance => Cow::Borrowed("Provider instance not configured"), Self::AuthRequired => Cow::Borrowed(synctv_common::messages::AUTHENTICATION_REQUIRED), diff --git a/synctv-api-common/src/api_error_model.rs b/synctv-api-common/src/api_error_model.rs index 649d9e10..ac5a0c64 100644 --- a/synctv-api-common/src/api_error_model.rs +++ b/synctv-api-common/src/api_error_model.rs @@ -47,6 +47,16 @@ impl GoogleApiError { 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(); details.set_error_info(classification.reason, ERROR_DOMAIN, metadata); @@ -195,6 +205,11 @@ impl ErrorClassification { http_status: StatusCode::BAD_REQUEST, 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 { grpc_code: Code::ResourceExhausted, http_status: StatusCode::TOO_MANY_REQUESTS, @@ -356,4 +371,29 @@ mod tests { 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(()) + } } diff --git a/synctv-api-common/src/http_error.rs b/synctv-api-common/src/http_error.rs index c94cc118..d6fc459e 100644 --- a/synctv-api-common/src/http_error.rs +++ b/synctv-api-common/src/http_error.rs @@ -726,6 +726,19 @@ mod tests { 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] fn test_from_provider_upstream_http_404_maps_to_not_found() { let app_err = AppError::from(synctv_core::provider::ProviderError::UpstreamHttp { diff --git a/synctv-api-common/src/impls/client/convert.rs b/synctv-api-common/src/impls/client/convert.rs index 81ea2080..4dc43e01 100644 --- a/synctv-api-common/src/impls/client/convert.rs +++ b/synctv-api-common/src/impls/client/convert.rs @@ -2251,6 +2251,38 @@ pub fn playback_client_profile_from_proto( }; 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 = match synctv_proto::client::PlaybackStreamPreference::try_from(profile.stream_preference) .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() } else { profile @@ -2301,7 +2335,7 @@ pub fn playback_client_profile_from_proto( .collect::, _>>()? }; - 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() } else { profile @@ -2336,9 +2370,14 @@ pub fn playback_client_profile_from_proto( "Unsupported playback audio capability".to_string(), ) })? { - synctv_proto::client::PlaybackAudioCapability::Unspecified => { + synctv_proto::client::PlaybackAudioCapability::Unspecified if uses_legacy_defaults => { 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_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() { - default_profile.supported_live_transports.clone() - } else { - profile - .supported_live_transports - .iter() - .filter_map(|transport| { - Some( - match synctv_proto::client::PlaybackLiveTransport::try_from(*transport) { - 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::Flv) => { - Ok(synctv_core::provider::PlaybackLiveTransport::Flv) - } - Err(_) => Err(crate::impls::ApiError::InvalidInput( - "Unsupported playback live transport".to_string(), - )), - }, - ) + let supported_live_transports = + if uses_legacy_defaults && profile.supported_live_transports.is_empty() { + default_profile.supported_live_transports.clone() + } else { + profile + .supported_live_transports + .iter() + .filter_map(|transport| { + Some( + match synctv_proto::client::PlaybackLiveTransport::try_from(*transport) { + 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::Flv) => { + Ok(synctv_core::provider::PlaybackLiveTransport::Flv) + } + Err(_) => Err(crate::impls::ApiError::InvalidInput( + "Unsupported playback live transport".to_string(), + )), + }, + ) + }) + .collect::, _>>()? + }; + + 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::, _>>()? - }; + }) + .collect::, crate::impls::ApiError>>()?; Ok(Some(synctv_core::provider::PlaybackClientProfile { + profile_version: profile.profile_version, + environment, stream_preference, max_streaming_bitrate: profile.max_streaming_bitrate, - max_audio_channels: profile - .max_audio_channels - .or(default_profile.max_audio_channels), + max_audio_channels: if uses_legacy_defaults { + profile + .max_audio_channels + .or(default_profile.max_audio_channels) + } else { + profile.max_audio_channels + }, supported_video_codecs, supported_containers, audio_capability, subtitle_preference, 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( role_i32: i32, ) -> Result { @@ -5849,12 +6183,9 @@ mod playback_conversion_tests { let key = signing_key(); let signing = signing_context(&key); - let proto = try_playback_to_proto( - &playback_result_with_mode("direct", info), - &codec(), - Some(&signing), - ) - .expect("playback should convert"); + let result = playback_result_with_mode("direct", info); + let proto = try_playback_to_proto(&result, &codec(), Some(&signing)) + .expect("playback should convert"); let delivery = proto.playback_infos["direct"].medias[0] .p2p_delivery .as_ref() @@ -5937,12 +6268,9 @@ mod playback_conversion_tests { .build(); let key = signing_key(); let signing = signing_context(&key); - let proto = try_playback_to_proto( - &playback_result_with_mode("direct", info), - &codec(), - Some(&signing), - ) - .expect("playback should convert"); + let result = playback_result_with_mode("direct", info); + let proto = try_playback_to_proto(&result, &codec(), Some(&signing)) + .expect("playback should convert"); let info = &proto.playback_infos["direct"]; let media = info.medias[0] .p2p_delivery diff --git a/synctv-api-common/src/impls/messaging/tests.rs b/synctv-api-common/src/impls/messaging/tests.rs index c21feed0..d8afa828 100644 --- a/synctv-api-common/src/impls/messaging/tests.rs +++ b/synctv-api-common/src/impls/messaging/tests.rs @@ -613,6 +613,7 @@ fn watch_playback_observe_builds_playback_resource_only() { audio_capability: synctv_proto::client::PlaybackAudioCapability::Unspecified as i32, subtitle_preference: synctv_proto::client::PlaybackSubtitlePreference::Unspecified as i32, + ..Default::default() }), }), }) diff --git a/synctv-api-common/src/impls/mod.rs b/synctv-api-common/src/impls/mod.rs index 230765e0..93817409 100644 --- a/synctv-api-common/src/impls/mod.rs +++ b/synctv-api-common/src/impls/mod.rs @@ -427,6 +427,10 @@ pub enum ApiError { message: String, violations: Vec, }, + ClientIncompatible { + message: String, + required_capability: Option, + }, PayloadTooLarge(String), RangeNotSatisfiable { total_size: u64, @@ -508,6 +512,13 @@ impl From for ApiError { Self::Conflict("Resource modified concurrently".to_string()) } 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 } => { Self::RangeNotSatisfiable { total_size } } @@ -577,6 +588,13 @@ impl From for ApiError { | ProviderError::InvalidUrl(msg) | ProviderError::MissingField(msg) | ProviderError::UnsupportedFormat(msg) => Self::InvalidInput(msg), + ProviderError::ClientIncompatible { + reason, + required_capability, + } => Self::ClientIncompatible { + message: reason, + required_capability, + }, ProviderError::NotFound => { Self::NotFound(synctv_common::messages::RESOURCE_NOT_FOUND.to_string()) } @@ -627,6 +645,7 @@ impl ApiError { Self::InvalidInput(_) | Self::InvalidRequest { .. } | Self::RangeNotSatisfiable { .. } => ErrorKind::InvalidArgument, + Self::ClientIncompatible { .. } => ErrorKind::FailedPrecondition, Self::BadGateway(_) => ErrorKind::ServiceUnavailable, Self::RequestTimeout(_) | Self::Timeout(_) => ErrorKind::Timeout, Self::PayloadTooLarge(_) | Self::RateLimited(_) | Self::RateLimitedWithRetry { .. } => { @@ -668,7 +687,8 @@ impl ApiError { | Self::ServiceUnavailable(msg) | Self::Timeout(msg) | Self::Internal(msg) => msg, - Self::RateLimitedWithRetry { message, .. } + Self::ClientIncompatible { message, .. } + | Self::RateLimitedWithRetry { message, .. } | Self::OAuth2InvalidState { message } | Self::OAuth2ProviderExchangeFailed { message, .. } | Self::OAuth2MissingTargetUser { message, .. } @@ -852,6 +872,10 @@ impl From for ApiError { ErrorKind::AlreadyExists => Self::AlreadyExists(msg), ErrorKind::Conflict => Self::Conflict(msg), ErrorKind::InvalidArgument => Self::InvalidInput(msg), + ErrorKind::FailedPrecondition => Self::ClientIncompatible { + message: msg, + required_capability: None, + }, ErrorKind::RateLimited => Self::RateLimited(msg), ErrorKind::ServiceUnavailable => Self::ServiceUnavailable(msg), ErrorKind::Timeout => Self::Timeout(msg), diff --git a/synctv-api-common/src/impls/oauth2.rs b/synctv-api-common/src/impls/oauth2.rs index 80b634e7..e241af93 100644 --- a/synctv-api-common/src/impls/oauth2.rs +++ b/synctv-api-common/src/impls/oauth2.rs @@ -23,7 +23,9 @@ use std::{collections::HashSet, sync::Arc}; use synctv_core::models::{OAuth2Provider, User, UserId, UserRole, UserStatus}; use synctv_core::oauth2::OAuth2AuthorizationMode; 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::{ ExchangeAuthorizationCodeRequest, ExchangeAuthorizationCodeResponse, GetAuthorizationUrlForBindRequest, GetAuthorizationUrlForBindResponse, @@ -383,6 +385,7 @@ impl OAuth2ApiImpl { pub async fn get_authorization_url_response_with_control( &self, req: GetAuthorizationUrlRequest, + request_allowed_redirect_url: Option, control: Option<&ExecutionControl>, ) -> Result { crate::impls::validate_proto_request(&req)?; @@ -395,11 +398,14 @@ impl OAuth2ApiImpl { let prepared = self .oauth2_service .prepare_authorization_url_with_control( - &req.provider, - redirect_url, - OAuth2Operation::Login, - None, - mode, + OAuth2AuthorizationRequest { + instance_name: &req.provider, + redirect_url, + request_allowed_redirect_url, + operation: OAuth2Operation::Login, + target_user_id: None, + mode, + }, control, ) .await?; @@ -420,6 +426,7 @@ impl OAuth2ApiImpl { &self, user_id: &UserId, req: GetAuthorizationUrlForBindRequest, + request_allowed_redirect_url: Option, control: Option<&ExecutionControl>, ) -> Result { crate::impls::validate_proto_request(&req)?; @@ -445,11 +452,14 @@ impl OAuth2ApiImpl { let prepared = self .oauth2_service .prepare_authorization_url_with_control( - &req.provider, - redirect_url, - OAuth2Operation::Bind, - Some(*user_id), - mode, + OAuth2AuthorizationRequest { + instance_name: &req.provider, + redirect_url, + request_allowed_redirect_url, + operation: OAuth2Operation::Bind, + target_user_id: Some(*user_id), + mode, + }, control, ) .await diff --git a/synctv-api-grpc/src/grpc/oauth2_service.rs b/synctv-api-grpc/src/grpc/oauth2_service.rs index 5635e51b..cd801251 100644 --- a/synctv-api-grpc/src/grpc/oauth2_service.rs +++ b/synctv-api-grpc/src/grpc/oauth2_service.rs @@ -101,7 +101,11 @@ impl OAuth2Service for OAuth2GrpcService { EndpointRateLimitCategory::Read, move |request_control| async move { oauth2_api - .get_authorization_url_response_with_control(req, Some(&request_control)) + .get_authorization_url_response_with_control( + req, + None, + Some(&request_control), + ) .await }, ) @@ -144,6 +148,7 @@ impl OAuth2Service for OAuth2GrpcService { .get_authorization_url_for_bind_response_with_control( &authenticated.user_id(), req, + None, Some(&request_control), ) .await diff --git a/synctv-api-http/Cargo.toml b/synctv-api-http/Cargo.toml index 9274746b..ee5f8db0 100644 --- a/synctv-api-http/Cargo.toml +++ b/synctv-api-http/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [features] default = ["tls-aws-lc", "tls-webpki-roots"] +web-ui = ["dep:synctv-web-ui", "synctv-web-ui/embed"] openapi = [ "synctv-api-common/openapi", "synctv-proto/openapi", @@ -89,6 +90,7 @@ synctv-media-providers = { 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-common = { workspace = true, default-features = false } +synctv-web-ui = { workspace = true, optional = true } webauthn-rs.workspace = true webauthn-rs-proto.workspace = true diff --git a/synctv-api-http/src/http/middleware.rs b/synctv-api-http/src/http/middleware.rs index 1b60dd9a..9da1bd70 100644 --- a/synctv-api-http/src/http/middleware.rs +++ b/synctv-api-http/src/http/middleware.rs @@ -172,23 +172,24 @@ pub async fn security_headers_middleware(request: Request, next: Next) -> Respon } // Content Security Policy - // Default API responses should not grant broad media or framing privileges. - // Routes that intentionally serve embeddable frontend/media content can set - // their own CSP; this middleware preserves existing endpoint-specific - // headers. + // Keep the default restrictive while allowing the same-origin Web client to + // render HTML media, MSE blobs, Flutter's generated styles, and its worker. + // Endpoint-specific headers still take precedence over this default. if !headers.contains_key("Content-Security-Policy") { headers.insert( CONTENT_SECURITY_POLICY.clone(), axum::http::HeaderValue::from_static( "default-src 'self'; \ - media-src 'none'; \ - frame-src 'none'; \ - connect-src 'self' wss: ws:; \ - img-src 'self' data: https:; \ - style-src 'self'; \ - script-src 'self'; \ + media-src 'self' blob: https: http:; \ + frame-src 'self'; \ + connect-src 'self' blob: https: http: wss: ws:; \ + img-src 'self' data: blob: https:; \ + font-src 'self' data:; \ + style-src 'self' 'unsafe-inline'; \ + script-src 'self' 'wasm-unsafe-eval'; \ + worker-src 'self' blob:; \ 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(), axum::http::HeaderValue::from_static( "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")?; assert!(csp.contains("default-src 'self'")); - assert!(csp.contains("media-src 'none'")); - assert!(csp.contains("frame-src 'none'")); - assert!(csp.contains("style-src 'self'")); + assert!(csp.contains("media-src 'self' blob: https: http:")); + assert!(csp.contains("frame-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("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(()) } diff --git a/synctv-api-http/src/http/mod.rs b/synctv-api-http/src/http/mod.rs index a8c61125..729d6750 100644 --- a/synctv-api-http/src/http/mod.rs +++ b/synctv-api-http/src/http/mod.rs @@ -25,19 +25,22 @@ pub(crate) mod room_extra; pub(crate) mod ticket; pub(crate) mod user; pub(crate) mod validation; +#[cfg(feature = "web-ui")] +pub(crate) mod web_ui; pub(crate) mod webrtc; pub(crate) mod websocket; use crate::providers; use axum::{ body::Body, - extract::State, http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, middleware as axum_middleware, - response::{IntoResponse, Redirect}, + response::IntoResponse, routing::{get, post}, Router, }; +#[cfg(not(feature = "web-ui"))] +use axum::{extract::State, response::Redirect}; use futures::StreamExt; use std::sync::{Arc, LazyLock}; use tower_http::compression::{ @@ -893,8 +896,18 @@ fn register_websocket_routes() -> Router { } fn register_all_routes() -> Router { - let mut router = Router::new() - .route("/", get(redirect_to_project)) + let mut router = Router::new(); + #[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( "/.well-known/apple-app-site-association", get(native_app_association::apple_app_site_association), @@ -1543,6 +1556,7 @@ fn register_all_routes() -> Router { router } +#[cfg(not(feature = "web-ui"))] async fn redirect_to_project(State(state): State) -> Redirect { Redirect::temporary(&state.runtime_settings.server.project_url) } @@ -1754,7 +1768,7 @@ fn forwarded_proto_is_https( Ok(value.eq_ignore_ascii_case("https")) } -fn should_compress_json_response( +fn should_compress_application_response( _status: StatusCode, _version: axum::http::Version, headers: &HeaderMap, @@ -1766,7 +1780,14 @@ fn should_compress_json_response( .and_then(|value| value.split(';').next()) .map(str::trim) .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) .gzip(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_middleware::from_fn(middleware::request_id_middleware)) diff --git a/synctv-api-http/src/http/oauth2.rs b/synctv-api-http/src/http/oauth2.rs index 1680b5ab..8aab06a6 100644 --- a/synctv-api-http/src/http/oauth2.rs +++ b/synctv-api-http/src/http/oauth2.rs @@ -23,6 +23,7 @@ use axum::{ extract::{Path, Query, State}, + http::HeaderMap, Json, }; use std::sync::Arc; @@ -135,6 +136,53 @@ fn map_oauth2_exchange_error(error: synctv_api_common::impls::ApiError) -> AppEr map_api_error(error) } +fn request_allowed_web_callback( + redirect_url: Option<&str>, + native: Option, + headers: &HeaderMap, + direct_peer_ip: Option, + server: &synctv_api_common::ApiServerSettings, +) -> AppResult> { + 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 /`api/oauth2/:provider/authorize?redirectUrl`= @@ -159,9 +207,17 @@ pub async fn get_authorize_url( State(state): State, Path(path): Path, Query(query): Query, + headers: HeaderMap, ) -> AppResult> { let oauth2_api = require_oauth2_api(&state)?; 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 request_meta = request_meta.0; @@ -173,7 +229,11 @@ pub async fn get_authorize_url( EndpointRateLimitCategory::Read, move |request_control| async move { 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 }, ) @@ -294,9 +354,17 @@ pub async fn get_bind_authorize_url( State(state): State, Path(path): Path, Query(query): Query, + headers: HeaderMap, ) -> AppResult> { let oauth2_api = require_oauth2_api(&state)?; 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 request_meta = request_meta.0; @@ -311,6 +379,7 @@ pub async fn get_bind_authorize_url( .get_authorization_url_for_bind_response_with_control( &authenticated.user_id(), req, + request_allowed_redirect_url, Some(&request_control), ) .await @@ -480,7 +549,7 @@ pub async fn get_linked_providers( #[cfg(test)] mod tests { use super::*; - use axum::http::StatusCode; + use axum::http::{header, HeaderMap, StatusCode}; type TestResult = anyhow::Result; @@ -539,4 +608,106 @@ mod tests { assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(err.message(), "OAuth2 is not available on this server."); } + + fn callback_headers(host: &str) -> TestResult { + 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(()) + } } diff --git a/synctv-api-http/src/http/room/query.rs b/synctv-api-http/src/http/room/query.rs index 9705d6da..196a5bc0 100644 --- a/synctv-api-http/src/http/room/query.rs +++ b/synctv-api-http/src/http/room/query.rs @@ -1,4 +1,6 @@ use axum::http::HeaderMap; +use base64::Engine as _; +use prost::Message as _; use super::AppResult; use synctv_proto::client::{ChatMessageType, GetPlaybackRequest, ResourceDeliveryMode}; @@ -23,6 +25,9 @@ pub(super) fn validate_include_message_types(values: Vec) -> AppResult, pub stream_preference: Option, pub max_streaming_bitrate: Option, pub max_audio_channels: Option, @@ -75,6 +80,7 @@ pub struct WatchPlaylistItemsQuery { pub struct WatchPlaybackQuery { pub delivery_mode: Option, pub format: Option, + pub client_profile: Option, pub stream_preference: Option, pub max_streaming_bitrate: Option, pub max_audio_channels: Option, @@ -225,10 +231,27 @@ fn parse_audio_capability( .map_err(|_| super::super::AppError::bad_request("Invalid audioCapability enum integer")) } +fn decode_client_profile( + encoded: &str, +) -> Result { + 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( query: &GetPlaybackQuery, ) -> AppResult { - 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_audio_channels.is_some() || query.video_codecs.is_some() @@ -237,7 +260,15 @@ pub(crate) fn build_get_playback_request( || query.audio_capability.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 { stream_preference: parse_stream_preference(query.stream_preference)? as i32, 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())?, audio_capability: parse_audio_capability(query.audio_capability)? as i32, subtitle_preference: parse_subtitle_preference(query.subtitle_preference)? as i32, + ..Default::default() }) } else { None @@ -262,6 +294,7 @@ pub(crate) fn build_playback_client_profile_from_watch_query( query: &WatchPlaybackQuery, ) -> AppResult> { build_get_playback_request(&GetPlaybackQuery { + client_profile: query.client_profile.clone(), stream_preference: query.stream_preference, max_streaming_bitrate: query.max_streaming_bitrate, max_audio_channels: query.max_audio_channels, diff --git a/synctv-api-http/src/http/room/tests.rs b/synctv-api-http/src/http/room/tests.rs index 7a9b23ec..690832aa 100644 --- a/synctv-api-http/src/http/room/tests.rs +++ b/synctv-api-http/src/http/room/tests.rs @@ -99,6 +99,7 @@ fn test_watch_after_event_sequence_rejects_non_utf8_last_event_id() -> TestResul #[test] fn test_build_get_playback_request_parses_generic_profile_query() -> TestResult { let request = app_ok(build_get_playback_request(&GetPlaybackQuery { + client_profile: None, stream_preference: Some(synctv_proto::client::PlaybackStreamPreference::Transcode as i32), max_streaming_bitrate: Some(8_000_000), max_audio_channels: Some(2), @@ -172,6 +173,47 @@ fn test_build_get_playback_request_omits_profile_when_query_is_empty() -> TestRe 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] fn test_handwritten_room_queries_ignore_unknown_fields() { let playback_query = @@ -240,6 +282,7 @@ fn test_handwritten_room_queries_ignore_unknown_fields() { #[test] fn test_build_get_playback_request_rejects_invalid_video_codec() { let error = build_get_playback_request(&GetPlaybackQuery { + client_profile: None, stream_preference: None, max_streaming_bitrate: None, max_audio_channels: None, @@ -257,6 +300,7 @@ fn test_build_get_playback_request_rejects_invalid_video_codec() { #[test] fn test_build_get_playback_request_rejects_invalid_stream_preference() { let error = build_get_playback_request(&GetPlaybackQuery { + client_profile: None, stream_preference: Some(999), max_streaming_bitrate: None, max_audio_channels: None, @@ -274,6 +318,7 @@ fn test_build_get_playback_request_rejects_invalid_stream_preference() { #[test] fn test_build_get_playback_request_rejects_invalid_container() { let error = build_get_playback_request(&GetPlaybackQuery { + client_profile: None, stream_preference: None, max_streaming_bitrate: None, max_audio_channels: None, @@ -291,6 +336,7 @@ fn test_build_get_playback_request_rejects_invalid_container() { #[test] fn test_build_get_playback_request_rejects_invalid_audio_capability() { let error = build_get_playback_request(&GetPlaybackQuery { + client_profile: None, stream_preference: None, max_streaming_bitrate: None, max_audio_channels: None, @@ -308,6 +354,7 @@ fn test_build_get_playback_request_rejects_invalid_audio_capability() { #[test] fn test_build_get_playback_request_rejects_invalid_subtitle_preference() -> TestResult { let error = app_err(build_get_playback_request(&GetPlaybackQuery { + client_profile: None, stream_preference: None, max_streaming_bitrate: None, max_audio_channels: None, diff --git a/synctv-api-http/src/http/tests.rs b/synctv-api-http/src/http/tests.rs index 440d8ef2..6e23103d 100644 --- a/synctv-api-http/src/http/tests.rs +++ b/synctv-api-http/src/http/tests.rs @@ -22,6 +22,14 @@ use tower::ServiceExt; type TestResult = anyhow::Result; +#[cfg(feature = "web-ui")] +#[test] +fn web_ui_routes_can_merge_with_a_grpc_style_fallback() { + let grpc_router = Router::::new().fallback(StatusCode::NOT_FOUND); + + let _combined = register_all_routes().merge(grpc_router); +} + fn test_error(message: impl Into) -> anyhow::Error { anyhow::anyhow!(message.into()) } @@ -1694,6 +1702,7 @@ async fn test_playback_patch_route_is_reachable_via_project_router() -> TestResu #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] +#[cfg(not(feature = "web-ui"))] async fn test_api_root_redirects_to_configured_project_url() -> TestResult { let mut state = test_app_state(); 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] #[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 app = apply_global_layers( Router::new() @@ -2888,6 +2897,24 @@ async fn json_compression_is_enabled_without_compressing_media_responses() -> Te .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( "/flv", get(|| async { @@ -2918,6 +2945,27 @@ async fn json_compression_is_enabled_without_compressing_media_responses() -> Te 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( app.oneshot(test_request( Request::builder() diff --git a/synctv-api-http/src/http/web_ui.rs b/synctv-api-http/src/http/web_ui.rs new file mode 100644 index 00000000..35caea9b --- /dev/null +++ b/synctv-api-http/src/http/web_ui.rs @@ -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 { + 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 { + 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::().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::() + .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::().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); + } +} diff --git a/synctv-api-http/src/http/websocket.rs b/synctv-api-http/src/http/websocket.rs index 47cca557..c409d6c1 100644 --- a/synctv-api-http/src/http/websocket.rs +++ b/synctv-api-http/src/http/websocket.rs @@ -511,7 +511,7 @@ fn validate_websocket_origin( )) } -fn same_origin_as_host( +pub(crate) fn same_origin_as_host( origin: &url::Url, host_header: &str, forwarded_proto: Option<&str>, diff --git a/synctv-api/Cargo.toml b/synctv-api/Cargo.toml index 8d4c0df3..af3b5ada 100644 --- a/synctv-api/Cargo.toml +++ b/synctv-api/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [features] default = ["tls-aws-lc", "tls-webpki-roots"] +web-ui = ["synctv-api-http/web-ui"] openapi = ["synctv-api-common/openapi", "synctv-api-http/openapi"] k8s = [ "synctv-api-common/k8s", diff --git a/synctv-api/tests/http_integration_test.rs b/synctv-api/tests/http_integration_test.rs index b55c92ec..58c85917 100644 --- a/synctv-api/tests/http_integration_test.rs +++ b/synctv-api/tests/http_integration_test.rs @@ -395,9 +395,10 @@ mod security_headers { .unwrap(); assert!(csp.contains("default-src 'self'")); assert!(csp.contains("frame-ancestors 'none'")); - assert!(csp.contains("media-src 'none'")); - assert!(csp.contains("frame-src 'none'")); - assert!(csp.contains("connect-src 'self' wss: ws:")); + assert!(csp.contains("media-src 'self' blob: https: http:")); + assert!(csp.contains("frame-src 'self'")); + assert!(csp.contains("connect-src 'self' blob: https: http: wss: ws:")); + assert!(csp.contains("worker-src 'self' blob:")); let pp = resp .headers() @@ -406,7 +407,8 @@ mod security_headers { .to_str() .unwrap(); assert!(pp.contains("camera=()")); - assert!(pp.contains("microphone=()")); + assert!(pp.contains("microphone=(self)")); + assert!(pp.contains("picture-in-picture=(self)")); assert!(pp.contains("geolocation=()")); let cc = resp diff --git a/synctv-core/src/cache/singleflight.rs b/synctv-core/src/cache/singleflight.rs index afd41c74..ad157996 100644 --- a/synctv-core/src/cache/singleflight.rs +++ b/synctv-core/src/cache/singleflight.rs @@ -62,6 +62,11 @@ pub enum CloneableError { Conflict(String), #[error("Invalid input: {0}")] InvalidInput(String), + #[error("Client cannot play this resource: {reason}")] + ClientIncompatible { + reason: String, + required_capability: Option, + }, #[error("Rate limited: {0}")] RateLimited(String), #[error("Service unavailable: {0}")] @@ -105,6 +110,13 @@ impl From for CloneableError { Error::AlreadyExists(message) => Self::AlreadyExists(message), Error::Conflict(message) => Self::Conflict(message), Error::InvalidInput(message) => Self::InvalidInput(message), + Error::ClientIncompatible { + reason, + required_capability, + } => Self::ClientIncompatible { + reason, + required_capability, + }, Error::RangeNotSatisfiable { total_size } => { Self::InvalidInput(format!("Range not satisfiable: total size {total_size}")) } @@ -128,6 +140,13 @@ impl From for Error { CloneableError::AlreadyExists(message) => Self::AlreadyExists(message), CloneableError::Conflict(message) => Self::Conflict(message), CloneableError::InvalidInput(message) => Self::InvalidInput(message), + CloneableError::ClientIncompatible { + reason, + required_capability, + } => Self::ClientIncompatible { + reason, + required_capability, + }, CloneableError::RateLimited(message) => Self::RateLimited(message), CloneableError::ServiceUnavailable(message) => Self::ServiceUnavailable(message), CloneableError::Internal(message) => Self::Internal(message), diff --git a/synctv-core/src/error.rs b/synctv-core/src/error.rs index f07341fb..39fee93a 100644 --- a/synctv-core/src/error.rs +++ b/synctv-core/src/error.rs @@ -41,6 +41,12 @@ pub enum Error { #[error("Invalid input: {0}")] InvalidInput(String), + #[error("Client cannot play this resource: {reason}")] + ClientIncompatible { + reason: String, + required_capability: Option, + }, + #[error("Range not satisfiable: total size {total_size}")] RangeNotSatisfiable { total_size: u64 }, @@ -230,6 +236,13 @@ impl From for Error { ProviderError::UnsupportedFormat(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}")), // Route registration ProviderError::RouteRegistrationFailed(msg) => { diff --git a/synctv-core/src/models/media.rs b/synctv-core/src/models/media.rs index 820383af..9b090ef0 100644 --- a/synctv-core/src/models/media.rs +++ b/synctv-core/src/models/media.rs @@ -3120,6 +3120,21 @@ impl PlaybackSubtitle { | 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 { @@ -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] pub fn format(&self) -> Option<&str> { self.format.as_deref() diff --git a/synctv-core/src/provider/acfun.rs b/synctv-core/src/provider/acfun.rs index 9b4af32b..525100ba 100644 --- a/synctv-core/src/provider/acfun.rs +++ b/synctv-core/src/provider/acfun.rs @@ -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 (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } - } + let source_medias = std::mem::take(&mut info.medias); + let supported_indices = source_medias + .iter() + .enumerate() + .filter_map(|(media_index, media)| { + super::proxy_playback_media_supported_by_client(client_profile, mode_name, media) + .then_some(media_index) + }) + .collect::>(); + 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 { danmaku.provider = match &danmaku.provider { 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] @@ -684,15 +727,23 @@ impl MediaProvider for AcFunProvider { } else { 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::cache_key(&resource), cache_ttl, 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?) }, ) - .await + .await?; + super::require_compatible_playback_route( + result, + crate::models::PlaybackProxyMode::Only, + client_profile, + ) } async fn validate_source_config( diff --git a/synctv-core/src/provider/alist.rs b/synctv-core/src/provider/alist.rs index ba808d84..1d49d7f6 100644 --- a/synctv-core/src/provider/alist.rs +++ b/synctv-core/src/provider/alist.rs @@ -570,6 +570,7 @@ fn mark_alist_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&PlaybackClientProfile>, ) { // Alist returns upstream playback modes and SyncTV proxy siblings in the // same result. The proxy default keeps clients independent from upstream @@ -592,7 +593,13 @@ fn mark_alist_playback_resources( default_selection = selection; } 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 { continue; @@ -601,11 +608,17 @@ fn mark_alist_playback_resources( let proxy_mode_name = format!("proxy_{mode_name}"); let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(url_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |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 mut proxy = playback_media( media.name.clone(), @@ -635,8 +648,10 @@ fn mark_alist_playback_resources( ); proxy.p2p_swarm_id.clone_from(&media.p2p_swarm_id); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; proxy_info.subtitles = original_info .subtitles .iter() @@ -2559,13 +2574,19 @@ impl MediaProvider for AlistProvider { let cache_ttl = Duration::from_mins(15); 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, &cache_key, cache_ttl, _ctx, |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 { let resolved = self.resolve_config(_ctx, config.clone()).await?; @@ -2573,7 +2594,8 @@ impl MediaProvider for AlistProvider { .await }, )) - .await + .await?; + super::require_compatible_playback_route(result, proxy_mode, playback_client_profile) } fn as_dynamic_playlist_provider(&self) -> Option<&dyn DynamicPlaylistProvider> { @@ -3878,6 +3900,7 @@ mod tests { "version", transport_expires_at, crate::models::PlaybackProxyMode::Auto, + None, ); let proxy = &result.playback_infos["proxy_direct"]; assert_eq!( diff --git a/synctv-core/src/provider/bilibili.rs b/synctv-core/src/provider/bilibili.rs index e36c7cd7..1ddb8f64 100644 --- a/synctv-core/src/provider/bilibili.rs +++ b/synctv-core/src/provider/bilibili.rs @@ -2368,6 +2368,7 @@ fn mark_bilibili_playback_resources( // while proxy siblings remain as a server-mediated fallback. attach_bilibili_live_danmaku(result, context); 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_modes = result .playback_infos @@ -2388,7 +2389,13 @@ fn mark_bilibili_playback_resources( ) { 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( PlaybackBilibiliMedia::DirectDurlManifest { version: resource_version, @@ -2402,13 +2409,25 @@ fn mark_bilibili_playback_resources( *resource_expires_at = expires_at; 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 // segment URLs. The proxy sibling preserves server forwarding and // 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(); if let Some(media) = proxy_info.medias.first_mut() { if let PlaybackMediaProvider::Bilibili( @@ -2427,14 +2446,16 @@ fn mark_bilibili_playback_resources( }); } } - populate_bilibili_proxy_attachments( - &original_info, - &mut proxy_info, - version, - expires_at, - &mode_name, - ); - generated.insert(format!("proxy_{mode_name}"), proxy_info); + if !proxy_info.medias.is_empty() { + populate_bilibili_proxy_attachments( + &original_info, + &mut proxy_info, + version, + expires_at, + &mode_name, + ); + generated.insert(format!("proxy_{mode_name}"), proxy_info); + } } 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(); - direct_info.default_media_index = Some(0); - generated.insert(mode_name.clone(), direct_info); + if !direct_info.medias.is_empty() { + 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 { - 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 { @@ -2526,14 +2568,27 @@ fn mark_bilibili_playback_resources( ), ) }) + .filter(|media| { + super::proxy_playback_media_supported_by_client( + client_profile, + &mode_name, + media, + ) + }) .collect(); - proxy_info.default_media_index = Some(0); + proxy_info.default_media_index = (!proxy_info.medias.is_empty()).then_some(0); } else { - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(url_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |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(); Some(playback_media( 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; - let proxy_default_mode = format!("proxy_{original_default_mode}"); - let direct_default_available = result.playback_infos.contains_key(&original_default_mode); - let proxy_default_available = result.playback_infos.contains_key(&proxy_default_mode); - result.default_mode = if direct_default_available && !selection.prefer_proxy { - original_default_mode - } else if proxy_default_available { - proxy_default_mode - } else { - original_default_mode - }; + super::select_generated_playback_default( + result, + &original_default_mode, + selection.prefer_proxy, + ); } fn bilibili_credential_server_id() -> String { @@ -3499,7 +3553,7 @@ impl MediaProvider for BilibiliProvider { )?; 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, &cache_key, cache_ttl, @@ -3524,7 +3578,8 @@ impl MediaProvider for BilibiliProvider { .await }, )) - .await + .await?; + super::require_compatible_playback_route(result, proxy_mode, _ctx.playback_client_profile()) } async fn validate_source_config( @@ -4450,9 +4505,11 @@ mod tests { }; use crate::models::{BilibiliTarget, PlaylistId, ProviderTarget, RoomId}; use crate::provider::{ - InMemoryProviderStore, PlaybackInfo, PlaybackResult, PlaybackTransportAction, + InMemoryProviderStore, PlaybackAudioCodec, PlaybackClientEnvironment, + PlaybackClientProfile, PlaybackInfo, PlaybackMediaCapability, PlaybackMediaPipeline, + PlaybackMediaTransport, PlaybackResult, PlaybackTransportAction, PlaybackVideoCodec, ProviderActor, ProviderContext, ProviderStore, ProviderStoreExt, SourceConfig, - VersionedPlayback, + VersionedPlayback, CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, }; use std::collections::HashMap; use std::sync::Arc; @@ -4463,6 +4520,57 @@ mod tests { 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 { + 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] fn playback_cache_key_uses_the_codec_route_schema_version() -> TestResult { let config = super::BilibiliSourceConfig::Video(crate::models::BilibiliVideoSourceConfig { @@ -4848,6 +4956,22 @@ mod tests { }; 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 { room_id: 42, shared: false, @@ -4863,8 +4987,14 @@ mod tests { "anonymous", 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!(flv_key.ends_with(":flv")); + assert!(web_flv_key.ends_with(":flv")); assert_ne!(hls_key, flv_key); Ok(()) } @@ -5056,6 +5186,57 @@ mod tests { 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] async fn bilibili_subtitle_uses_full_response_cache_transport() -> TestResult { let store: Arc = Arc::new(InMemoryProviderStore::new(8)); @@ -6213,9 +6394,8 @@ fn bilibili_live_streams_request( fn bilibili_live_uses_hls(profile: Option<&super::PlaybackClientProfile>) -> bool { !profile.is_some_and(|profile| { - profile - .supported_live_transports - .contains(&super::PlaybackLiveTransport::Flv) + profile.supports_transport(super::PlaybackMediaTransport::Flv) + && !profile.supports_transport(super::PlaybackMediaTransport::Hls) }) } diff --git a/synctv-core/src/provider/cctv.rs b/synctv-core/src/provider/cctv.rs index efb22b56..dcf32d27 100644 --- a/synctv-core/src/provider/cctv.rs +++ b/synctv-core/src/provider/cctv.rs @@ -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 (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } - } + let source_medias = std::mem::take(&mut info.medias); + let (medias, default_media_index) = super::map_playback_resources( + &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::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] @@ -287,17 +313,25 @@ impl MediaProvider for CctvProvider { .map_err(|error| ProviderError::NetworkError(error.to_string()))?; let config = Self::config(source_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, &format!("playback:{}", config.resource), Duration::from_hours(2), ctx, - mark_cctv_playback_resources, + |result, version, expires_at| { + mark_cctv_playback_resources(result, version, expires_at, client_profile); + }, || async { 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( diff --git a/synctv-core/src/provider/cloudreve.rs b/synctv-core/src/provider/cloudreve.rs index 55f67275..84e00cf8 100644 --- a/synctv-core/src/provider/cloudreve.rs +++ b/synctv-core/src/provider/cloudreve.rs @@ -854,6 +854,7 @@ fn mark_cloudreve_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default_mode = result.default_mode.clone(); let mut default_selection = super::PlaybackRouteSelection::DIRECT_ONLY; @@ -873,7 +874,13 @@ fn mark_cloudreve_playback_resources( default_selection = selection; } 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 { continue; @@ -881,17 +888,20 @@ fn mark_cloudreve_playback_resources( let proxy_mode_name = format!("proxy_{mode_name}"); let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter(|(_, media)| { - matches!( + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |media_index, media| { + if !super::proxy_playback_media_supported_by_client( + client_profile, + &mode_name, + media, + ) || !matches!( media.provider, PlaybackMediaProvider::Cloudreve(PlaybackCloudreveMedia::Direct { .. }) - ) - }) - .map(|(media_index, media)| { + ) { + return None; + } let mut proxy = media.clone(); proxy.expire_at = chrono::DateTime::from_timestamp(expires_at, 0); proxy.provider = PlaybackMediaProvider::Cloudreve( @@ -911,9 +921,11 @@ fn mark_cloudreve_playback_resources( } }, ); - proxy - }) - .collect(); + Some(proxy) + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; proxy_info.subtitles = original_info .subtitles .iter() @@ -1094,7 +1106,7 @@ impl MediaProvider for CloudreveProvider { metadata: None, }; let proxy_mode = config.proxy_mode; - super::cached_versioned_playback_or_fill( + let result = super::cached_versioned_playback_or_fill( Self::NAME, &format!( "playback:{user_id}:{}:room:{}:{}", @@ -1106,11 +1118,18 @@ impl MediaProvider for CloudreveProvider { PLAYBACK_CACHE_TTL, ctx, |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) }, ) - .await + .await?; + super::require_compatible_playback_route(result, proxy_mode, ctx.playback_client_profile()) } async fn validate_source_config( @@ -1830,6 +1849,7 @@ mod tests { "version-1", 1_900_000_000, crate::models::PlaybackProxyMode::Prefer, + None, ); let direct = &result.playback_infos["direct"]; @@ -1864,6 +1884,7 @@ mod tests { "version-1", 1_900_000_000, crate::models::PlaybackProxyMode::Auto, + None, ); assert!(matches!( @@ -1880,6 +1901,7 @@ mod tests { "version-1", 1_900_000_000, crate::models::PlaybackProxyMode::Prefer, + None, ); assert_eq!(preferred.default_mode, "proxy_direct"); assert!(preferred.playback_infos.contains_key("direct")); @@ -1890,6 +1912,7 @@ mod tests { "version-1", 1_900_000_000, crate::models::PlaybackProxyMode::Only, + None, ); assert_eq!(proxy_only.default_mode, "proxy_direct"); assert!(!proxy_only.playback_infos.contains_key("direct")); diff --git a/synctv-core/src/provider/direct_url.rs b/synctv-core/src/provider/direct_url.rs index 914dcf12..b4e9e4e4 100644 --- a/synctv-core/src/provider/direct_url.rs +++ b/synctv-core/src/provider/direct_url.rs @@ -452,6 +452,7 @@ fn mark_direct_url_playback_resources( expires_at: i64, selection: super::PlaybackRouteSelection, auto_mode: bool, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default_mode = result.default_mode.clone(); let auto_default_prefers_proxy = auto_mode @@ -559,7 +560,11 @@ fn mark_direct_url_playback_resources( .map(|(_, danmaku)| danmaku.clone()) .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); } } @@ -575,6 +580,13 @@ fn mark_direct_url_playback_resources( .iter() .enumerate() .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 headers = media.upstream_headers(); Some(( @@ -925,6 +937,7 @@ impl MediaProvider for DirectUrlProvider { }; let route_selection = direct_url_route_selection(config); let auto_mode = config.proxy_mode == crate::models::PlaybackProxyMode::Auto; + let client_profile = _ctx.playback_client_profile(); Self::validate_config_shape(config)?; for media in &config.medias { Self::validate_source_url(&media.url, &self.ssrf_guard)?; @@ -990,6 +1003,7 @@ impl MediaProvider for DirectUrlProvider { expires_at, route_selection, auto_mode, + client_profile, ); }, ) @@ -1196,7 +1210,7 @@ impl MediaProvider for DirectUrlProvider { metadata: Some(metadata), }; - super::cache_versioned_playback_and_build_response( + let result = super::cache_versioned_playback_and_build_response( result, Self::NAME, &cache_key, @@ -1209,10 +1223,12 @@ impl MediaProvider for DirectUrlProvider { expires_at, route_selection, 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] async fn generate_playback_marks_plain_file_video_probeable() { let provider = DirectUrlProvider::new(); diff --git a/synctv-core/src/provider/douyin.rs b/synctv-core/src/provider/douyin.rs index ababa969..6c004e5f 100644 --- a/synctv-core/src/provider/douyin.rs +++ b/synctv-core/src/provider/douyin.rs @@ -835,21 +835,65 @@ impl DouyinProvider { } } -fn mark_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { - for (mode_name, info) in &mut result.playback_infos { - for (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } +fn mark_playback_resources( + result: &mut PlaybackResult, + version: &str, + expires_at: i64, + client_profile: Option<&super::PlaybackClientProfile>, +) { + let original_default = result.default_mode.clone(); + let original_modes = std::mem::take(&mut result.playback_infos); + for (mode_name, mut info) in original_modes { + let source_medias = std::mem::take(&mut info.medias); + let supported_indices = source_medias + .iter() + .enumerate() + .filter_map(|(media_index, media)| { + super::proxy_playback_media_supported_by_client(client_profile, &mode_name, media) + .then_some(media_index) + }) + .collect::>(); + 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 { let PlaybackDanmakuProvider::Douyin(PlaybackDouyinDanmaku::Refresh { media_index }) = &danmaku.provider @@ -863,7 +907,9 @@ fn mark_playback_resources(result: &mut PlaybackResult, version: &str, expires_a 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 { @@ -964,12 +1010,15 @@ impl MediaProvider for DouyinProvider { credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()), 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, &cache_key, Duration::from_mins(30), ctx, - mark_playback_resources, + |result, version, expires_at| { + mark_playback_resources(result, version, expires_at, client_profile); + }, || async { let media = self.resolve_media(config, &session).await?; 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( diff --git a/synctv-core/src/provider/douyu.rs b/synctv-core/src/provider/douyu.rs index 0cf14a26..540f0768 100644 --- a/synctv-core/src/provider/douyu.rs +++ b/synctv-core/src/provider/douyu.rs @@ -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 (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } - } + let source_medias = std::mem::take(&mut info.medias); + let supported_indices = source_medias + .iter() + .enumerate() + .filter_map(|(media_index, media)| { + super::proxy_playback_media_supported_by_client(client_profile, mode_name, media) + .then_some(media_index) + }) + .collect::>(); + 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 { let PlaybackDanmakuProvider::Douyu(PlaybackDouyuDanmaku::Refresh { media_index }) = &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] @@ -419,15 +463,23 @@ impl MediaProvider for DouyuProvider { ctx.check_active() .map_err(|error| ProviderError::NetworkError(error.to_string()))?; 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::cache_key(&resource), Duration::from_mins(2), 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?) }, ) - .await + .await?; + super::require_compatible_playback_route( + result, + crate::models::PlaybackProxyMode::Only, + client_profile, + ) } async fn validate_source_config( diff --git a/synctv-core/src/provider/emby.rs b/synctv-core/src/provider/emby.rs index 61564a06..dfe5abf7 100644 --- a/synctv-core/src/provider/emby.rs +++ b/synctv-core/src/provider/emby.rs @@ -156,6 +156,7 @@ fn mark_emby_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&PlaybackClientProfile>, ) { // Emby exposes upstream modes and SyncTV proxy siblings together. // Upstream token headers remain visible by product policy; administrators @@ -178,7 +179,13 @@ fn mark_emby_playback_resources( default_selection = selection; } 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 { continue; @@ -187,11 +194,17 @@ fn mark_emby_playback_resources( let proxy_mode_name = format!("proxy_{mode_name}"); let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(url_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |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 mut proxy = playback_media( media.name.clone(), @@ -221,8 +234,10 @@ fn mark_emby_playback_resources( ); proxy.p2p_swarm_id.clone_from(&media.p2p_swarm_id); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; proxy_info.subtitles = original_info .subtitles .iter() @@ -1953,7 +1968,13 @@ impl MediaProvider for EmbyProvider { cache_ttl, _ctx, |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 { self.resolve_from_api(&resolved, _ctx.request_context(), playback_client_profile) @@ -1962,6 +1983,9 @@ impl MediaProvider for EmbyProvider { )) .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 { return Ok(result); }; @@ -3148,6 +3172,7 @@ mod tests { "version-1", 1234, crate::models::PlaybackProxyMode::Prefer, + None, ); assert!(matches!( diff --git a/synctv-core/src/provider/error.rs b/synctv-core/src/provider/error.rs index 32792435..716c643e 100644 --- a/synctv-core/src/provider/error.rs +++ b/synctv-core/src/provider/error.rs @@ -39,6 +39,12 @@ pub enum ProviderError { #[error("Unsupported format: {0}")] UnsupportedFormat(String), + #[error("Client cannot play this resource: {reason}")] + ClientIncompatible { + reason: String, + required_capability: Option, + }, + #[error("Parse error: {0}")] ParseError(String), diff --git a/synctv-core/src/provider/fnos.rs b/synctv-core/src/provider/fnos.rs index c01f5e91..cf39e4c3 100644 --- a/synctv-core/src/provider/fnos.rs +++ b/synctv-core/src/provider/fnos.rs @@ -1626,6 +1626,7 @@ fn mark_fnos_playback_resources( version: &str, expires_at: i64, selection: super::PlaybackRouteSelection, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default_mode = result.default_mode.clone(); let original_modes = result @@ -1640,7 +1641,13 @@ fn mark_fnos_playback_resources( } if mode_name.starts_with("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; } @@ -1648,11 +1655,17 @@ fn mark_fnos_playback_resources( continue; } let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(media_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |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 { PlaybackMediaProvider::Fnos(PlaybackFnosMedia::FileRefresh { credential_owner_id, @@ -1711,8 +1724,10 @@ fn mark_fnos_playback_resources( resource, }); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; if proxy_info.medias.is_empty() { continue; } @@ -2425,18 +2440,22 @@ impl MediaProvider for FnosProvider { Duration::from_hours(2), ctx, |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) }, ) .await?; - if config.proxy_mode == crate::models::PlaybackProxyMode::DirectOnly - && result.playback_infos.is_empty() - { - return Err(ProviderError::UnsupportedFormat( - "This FNOS media source cannot provide a direct playback route".to_string(), - )); - } + let result = super::require_compatible_playback_route( + result, + config.proxy_mode, + ctx.playback_client_profile(), + )?; let media = result .metadata @@ -3450,6 +3469,7 @@ mod tests { "version", 123, fnos_route_selection(crate::models::PlaybackProxyMode::Auto), + None, ); assert!(matches!( @@ -3520,6 +3540,7 @@ mod tests { "version", 123, fnos_route_selection(crate::models::PlaybackProxyMode::Auto), + None, ); let info = &result.playback_infos["proxy_direct"]; diff --git a/synctv-core/src/provider/huya.rs b/synctv-core/src/provider/huya.rs index 3f09e7cc..de6a5cbb 100644 --- a/synctv-core/src/provider/huya.rs +++ b/synctv-core/src/provider/huya.rs @@ -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 (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } - } + let source_medias = std::mem::take(&mut info.medias); + let supported_indices = source_medias + .iter() + .enumerate() + .filter_map(|(media_index, media)| { + super::proxy_playback_media_supported_by_client(client_profile, mode_name, media) + .then_some(media_index) + }) + .collect::>(); + 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 { let PlaybackDanmakuProvider::Huya(PlaybackHuyaDanmaku::Refresh { media_index }) = &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] @@ -485,15 +529,23 @@ impl MediaProvider for HuyaProvider { HuyaResourceKind::Live => Duration::from_mins(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::cache_key(&resource), cache_ttl, 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?) }, ) - .await + .await?; + super::require_compatible_playback_route( + result, + crate::models::PlaybackProxyMode::Only, + client_profile, + ) } async fn validate_source_config( diff --git a/synctv-core/src/provider/live_proxy.rs b/synctv-core/src/provider/live_proxy.rs index bc1d51b4..37e1e5c6 100644 --- a/synctv-core/src/provider/live_proxy.rs +++ b/synctv-core/src/provider/live_proxy.rs @@ -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 // provider-playback transport actions directly. let default_mode = result.default_mode.clone(); - for (mode_name, info) in &mut result.playback_infos { - 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); let media_count = info.medias.len(); for media in &mut info.medias { 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, 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 { version: version.to_string(), expires_at, @@ -273,7 +284,9 @@ fn mark_live_proxy_playback_resources(result: &mut PlaybackResult, version: &str 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 { @@ -329,15 +342,23 @@ impl MediaProvider for LiveProxyProvider { let cache_key = format!("playback:{room_id}:{media_id}"); 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, Self::NAME, &cache_key, cache_ttl, 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( diff --git a/synctv-core/src/provider/mod.rs b/synctv-core/src/provider/mod.rs index 84ab1f31..79e7e15e 100644 --- a/synctv-core/src/provider/mod.rs +++ b/synctv-core/src/provider/mod.rs @@ -65,8 +65,10 @@ pub use p2p_media::{ P2pResourceDelivery, }; pub use playback_profile::{ - PlaybackAudioCapability, PlaybackClientProfile, PlaybackContainer, PlaybackLiveTransport, - PlaybackStreamPreference, PlaybackSubtitlePreference, PlaybackVideoCodec, + PlaybackAudioCapability, PlaybackAudioCodec, PlaybackClientEnvironment, PlaybackClientProfile, + PlaybackContainer, PlaybackLiveTransport, PlaybackMediaCapability, PlaybackMediaPipeline, + PlaybackMediaTransport, PlaybackStreamPreference, PlaybackSubtitlePreference, + PlaybackVideoCodec, CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, }; pub use playback_transport::{ 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, + pub video_codec: Option, + pub audio_codec: Option, +} + +fn playback_video_codec(value: &str) -> Option { + 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 { + 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 { + 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( + resources: &[T], + default_index: Option, + mut map: impl FnMut(usize, &T) -> Option, +) -> (Vec, Option) { + 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 { + 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 { + 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::{ PlaybackDanmaku, PlaybackMedia, PlaybackMediaProvider, PlaybackRtmpMedia, PlaybackSubtitle, }; @@ -530,6 +838,11 @@ enum ProviderPlaybackFillError { UpstreamHttp { status: u16, url: String }, #[error("Unsupported format: {0}")] UnsupportedFormat(String), + #[error("Client cannot play this resource: {reason}")] + ClientIncompatible { + reason: String, + required_capability: Option, + }, #[error("Parse error: {0}")] ParseError(String), #[error("Missing provider instance")] @@ -567,6 +880,13 @@ impl From for ProviderPlaybackFillError { ProviderError::ApiError(message) => Self::ApiError(message), ProviderError::UpstreamHttp { status, url } => Self::UpstreamHttp { status, url }, ProviderError::UnsupportedFormat(message) => Self::UnsupportedFormat(message), + ProviderError::ClientIncompatible { + reason, + required_capability, + } => Self::ClientIncompatible { + reason, + required_capability, + }, ProviderError::ParseError(message) => Self::ParseError(message), ProviderError::MissingInstance => Self::MissingInstance, ProviderError::InstanceNotFound(message) => Self::InstanceNotFound(message), @@ -602,6 +922,13 @@ impl From for ProviderError { ProviderPlaybackFillError::UnsupportedFormat(message) => { Self::UnsupportedFormat(message) } + ProviderPlaybackFillError::ClientIncompatible { + reason, + required_capability, + } => Self::ClientIncompatible { + reason, + required_capability, + }, ProviderPlaybackFillError::ParseError(message) => Self::ParseError(message), ProviderPlaybackFillError::MissingInstance => Self::MissingInstance, ProviderPlaybackFillError::InstanceNotFound(message) => Self::InstanceNotFound(message), @@ -1458,6 +1785,487 @@ where .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) -> 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 { + 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)] mod playback_transport_expiry_tests { use super::*; diff --git a/synctv-core/src/provider/nextcloud.rs b/synctv-core/src/provider/nextcloud.rs index 5bdf8eb8..54305e6f 100644 --- a/synctv-core/src/provider/nextcloud.rs +++ b/synctv-core/src/provider/nextcloud.rs @@ -816,12 +816,22 @@ impl MediaProvider for NextcloudProvider { PLAYBACK_CACHE_TTL, ctx, |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) }, ) .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( @@ -1185,6 +1195,7 @@ fn mark_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default = result.default_mode.clone(); let prefer_proxy = matches!( @@ -1207,17 +1218,29 @@ fn mark_playback_resources( .iter() .any(|media| !media.requires_provider_url()); 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 { continue; } let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(media_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |media_index, media| { + if !super::proxy_playback_media_supported_by_client( + client_profile, + &mode_name, + media, + ) { + return None; + } let PlaybackMediaProvider::Nextcloud(PlaybackNextcloudMedia::Refresh { credential_owner_id, server_id, @@ -1239,8 +1262,10 @@ fn mark_playback_resources( file_id: *file_id, }); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; if proxy_info.medias.is_empty() { continue; } diff --git a/synctv-core/src/provider/playback_profile.rs b/synctv-core/src/provider/playback_profile.rs index d8cb4f82..a338b4c7 100644 --- a/synctv-core/src/provider/playback_profile.rs +++ b/synctv-core/src/provider/playback_profile.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +pub const CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION: u32 = 2; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum PlaybackStreamPreference { #[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, + pub video_codec: Option, + pub audio_codec: Option, + pub pipeline: PlaybackMediaPipeline, + pub codec_string: Option, +} + +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. /// /// This deliberately captures only the client characteristics that materially @@ -118,6 +229,8 @@ impl PlaybackAudioCapability { /// derived from this structure at the edge. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PlaybackClientProfile { + pub profile_version: u32, + pub environment: PlaybackClientEnvironment, pub stream_preference: PlaybackStreamPreference, pub max_streaming_bitrate: Option, pub max_audio_channels: Option, @@ -126,11 +239,17 @@ pub struct PlaybackClientProfile { pub audio_capability: PlaybackAudioCapability, pub subtitle_preference: PlaybackSubtitlePreference, pub supported_live_transports: Vec, + pub media_capabilities: Vec, + pub supports_custom_http_headers: bool, + pub supports_provider_proxy: bool, + pub supports_insecure_http_media: bool, } impl Default for PlaybackClientProfile { fn default() -> Self { Self { + profile_version: 0, + environment: PlaybackClientEnvironment::Native, stream_preference: PlaybackStreamPreference::Auto, max_streaming_bitrate: None, max_audio_channels: Some(2), @@ -148,15 +267,128 @@ impl Default for PlaybackClientProfile { audio_capability: PlaybackAudioCapability::LosslessSurround, subtitle_preference: PlaybackSubtitlePreference::External, 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 { + #[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, + video_codec: Option, + audio_codec: Option, + ) -> 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, + video_codec: Option, + audio_codec: Option, + 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] pub fn cache_fingerprint(&self) -> String { + let mut capabilities = self + .media_capabilities + .iter() + .map(PlaybackMediaCapability::cache_token) + .collect::>(); + capabilities.sort_unstable(); 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.max_streaming_bitrate .map_or_else(|| "none".to_string(), |value| value.to_string()), @@ -179,6 +411,10 @@ impl PlaybackClientProfile { .map(|transport| transport.cache_token()) .collect::>() .join(","), + capabilities.join(","), + self.supports_custom_http_headers, + self.supports_provider_proxy, + self.supports_insecure_http_media, ) } } @@ -228,6 +464,8 @@ mod tests { #[test] fn cache_fingerprint_includes_every_playback_negotiation_field() { let profile = PlaybackClientProfile { + profile_version: CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION, + environment: PlaybackClientEnvironment::Web, stream_preference: PlaybackStreamPreference::Transcode, max_streaming_bitrate: Some(8_000_000), max_audio_channels: Some(2), @@ -236,11 +474,78 @@ mod tests { audio_capability: PlaybackAudioCapability::Surround, subtitle_preference: PlaybackSubtitlePreference::EmbeddedOrExternal, 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!( 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), + )); + } } diff --git a/synctv-core/src/provider/qnap.rs b/synctv-core/src/provider/qnap.rs index a0a8a4fb..fc58c5dd 100644 --- a/synctv-core/src/provider/qnap.rs +++ b/synctv-core/src/provider/qnap.rs @@ -985,12 +985,22 @@ impl MediaProvider for QnapProvider { PLAYBACK_CACHE_TTL, ctx, |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) }, ) .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( @@ -1431,6 +1441,7 @@ fn mark_qnap_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default = result.default_mode.clone(); let prefer_proxy = matches!( @@ -1453,17 +1464,29 @@ fn mark_qnap_playback_resources( .iter() .any(|media| !media.requires_provider_url()); 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 { continue; } let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(media_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |media_index, media| { + if !super::proxy_playback_media_supported_by_client( + client_profile, + &mode_name, + media, + ) { + return None; + } let PlaybackMediaProvider::Qnap(PlaybackQnapMedia::Refresh { credential_owner_id, server_id, @@ -1483,8 +1506,10 @@ fn mark_qnap_playback_resources( resource: resource.clone(), }); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; if proxy_info.medias.is_empty() { continue; } diff --git a/synctv-core/src/provider/rtmp.rs b/synctv-core/src/provider/rtmp.rs index 8e4e4583..b7272b40 100644 --- a/synctv-core/src/provider/rtmp.rs +++ b/synctv-core/src/provider/rtmp.rs @@ -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 // playback transport actions so clients can request either delivery format. - for (mode_name, info) in &mut result.playback_infos { - let is_hls = super::playback_info_is_hls(mode_name, info); + let original_default = result.default_mode.clone(); + 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 { let (room_id, media_id) = match &media.provider { PlaybackMediaProvider::Rtmp( @@ -77,7 +89,9 @@ fn mark_rtmp_playback_resources(result: &mut PlaybackResult, version: &str, expi continue; }; } + result.playback_infos.insert(mode_name, info); } + super::select_generated_playback_default(result, &original_default, true); } impl Default for RtmpProvider { @@ -109,15 +123,23 @@ impl MediaProvider for RtmpProvider { let cache_key = format!("playback:{room_id}:{media_id}"); 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, Self::NAME, &cache_key, cache_ttl, 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( diff --git a/synctv-core/src/provider/seafile.rs b/synctv-core/src/provider/seafile.rs index 08ad040b..9432498f 100644 --- a/synctv-core/src/provider/seafile.rs +++ b/synctv-core/src/provider/seafile.rs @@ -253,12 +253,22 @@ impl MediaProvider for SeafileProvider { PLAYBACK_CACHE_TTL, ctx, |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) }, ) .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( @@ -647,6 +657,7 @@ fn mark_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default = result.default_mode.clone(); let prefer_proxy = matches!( @@ -669,17 +680,29 @@ fn mark_playback_resources( .iter() .any(|media| !media.requires_provider_url()); 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 { continue; } let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(media_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |media_index, media| { + if !super::proxy_playback_media_supported_by_client( + client_profile, + &mode_name, + media, + ) { + return None; + } let PlaybackMediaProvider::Seafile(PlaybackSeafileMedia::Refresh { credential_owner_id, server_id, @@ -703,8 +726,10 @@ fn mark_playback_resources( object_id: object_id.clone(), }); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; if proxy_info.medias.is_empty() { continue; } diff --git a/synctv-core/src/provider/synology.rs b/synctv-core/src/provider/synology.rs index 820513b2..e5585854 100644 --- a/synctv-core/src/provider/synology.rs +++ b/synctv-core/src/provider/synology.rs @@ -1101,12 +1101,22 @@ impl MediaProvider for SynologyProvider { PLAYBACK_CACHE_TTL, ctx, |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) }, ) .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 { let resource_version = result.playback_infos.values().find_map(|info| { @@ -2126,7 +2136,7 @@ async fn generate_video_playback( }, format: format.to_string(), expire_at: None, - metadata: Some(video_file_metadata(file)), + metadata: Some(video_file_metadata(file, profile)), p2p_swarm_id: Some(synology_swarm_id( auth.instance_name.as_deref(), "media", @@ -2261,6 +2271,7 @@ fn mark_synology_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default = result.default_mode.clone(); let prefer_proxy = matches!( @@ -2283,17 +2294,29 @@ fn mark_synology_playback_resources( .iter() .any(|media| !media.requires_provider_url()); 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 { continue; } let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(media_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |media_index, media| { + if !super::proxy_playback_media_supported_by_client( + client_profile, + &mode_name, + media, + ) { + return None; + } let PlaybackMediaProvider::Synology( crate::models::PlaybackSynologyMedia::Refresh { credential_owner_id, @@ -2316,8 +2339,10 @@ fn mark_synology_playback_resources( resource: resource.clone(), }); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; if proxy_info.medias.is_empty() { continue; } @@ -2425,17 +2450,32 @@ fn file_metadata(file: &SynologyFile) -> PlaybackMediaMetadata { fn video_file_metadata( file: &synctv_media_providers::synology::SynologyVideoFile, + profile: SynologyPlaybackProfile, ) -> PlaybackMediaMetadata { PlaybackMediaMetadata { resolution: Some(format!("{}x{}", file.resolutionx, file.resolutiony)), 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) .then(|| i32::try_from(file.frame_rate_num / file.frame_rate_den).ok()) .flatten(), } } +fn video_station_profile_codec( + source_codec: &str, + profile: SynologyPlaybackProfile, +) -> Option { + 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 { apis.iter() .map(|(name, api)| { @@ -2647,6 +2687,7 @@ mod tests { "version", 1_900_000_000, crate::models::PlaybackProxyMode::Prefer, + None, ); assert_eq!(result.default_mode, "proxy_original"); @@ -2654,6 +2695,26 @@ mod tests { 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] fn library_policy_excludes_direct_only_modes() { let provider = SynologyProvider::with_http_client(reqwest::Client::new()); diff --git a/synctv-core/src/provider/tiktok.rs b/synctv-core/src/provider/tiktok.rs index 1222b188..b900e3c4 100644 --- a/synctv-core/src/provider/tiktok.rs +++ b/synctv-core/src/provider/tiktok.rs @@ -849,21 +849,47 @@ impl TikTokProvider { } } -fn mark_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { - for (mode_name, info) in &mut result.playback_infos { - for (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } +fn mark_playback_resources( + result: &mut PlaybackResult, + version: &str, + expires_at: i64, + client_profile: Option<&super::PlaybackClientProfile>, +) { + let original_default = result.default_mode.clone(); + let original_modes = std::mem::take(&mut result.playback_infos); + for (mode_name, mut info) in original_modes { + let source_medias = std::mem::take(&mut info.medias); + let (medias, default_media_index) = super::map_playback_resources( + &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() { if matches!( 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 { @@ -983,12 +1011,15 @@ impl MediaProvider for TikTokProvider { credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()), 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, &cache_key, Duration::from_mins(30), ctx, - mark_playback_resources, + |result, version, expires_at| { + mark_playback_resources(result, version, expires_at, client_profile); + }, || async { let media = self.resolve_media(config, &session).await?; 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( diff --git a/synctv-core/src/provider/truenas.rs b/synctv-core/src/provider/truenas.rs index ba0cd827..eaeeb7cf 100644 --- a/synctv-core/src/provider/truenas.rs +++ b/synctv-core/src/provider/truenas.rs @@ -680,12 +680,22 @@ impl MediaProvider for TrueNasProvider { PLAYBACK_CACHE_TTL, ctx, |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) }, ) .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( @@ -966,6 +976,7 @@ fn mark_playback_resources( version: &str, expires_at: i64, proxy_mode: crate::models::PlaybackProxyMode, + client_profile: Option<&super::PlaybackClientProfile>, ) { let original_default = result.default_mode.clone(); let prefer_proxy = matches!( @@ -988,17 +999,29 @@ fn mark_playback_resources( .iter() .any(|media| !media.requires_provider_url()); 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 { continue; } let mut proxy_info = original_info.clone(); - proxy_info.medias = original_info - .medias - .iter() - .enumerate() - .filter_map(|(media_index, media)| { + let (proxy_medias, proxy_default_media_index) = super::map_playback_resources( + &original_info.medias, + original_info.default_media_index, + |media_index, media| { + if !super::proxy_playback_media_supported_by_client( + client_profile, + &mode_name, + media, + ) { + return None; + } let PlaybackMediaProvider::TrueNas(PlaybackTrueNasMedia::Refresh { credential_owner_id, server_id, @@ -1018,8 +1041,10 @@ fn mark_playback_resources( path: path.clone(), }); Some(proxy) - }) - .collect(); + }, + ); + proxy_info.medias = proxy_medias; + proxy_info.default_media_index = proxy_default_media_index; if proxy_info.medias.is_empty() { continue; } diff --git a/synctv-core/src/provider/twitch.rs b/synctv-core/src/provider/twitch.rs index aba77591..60cc298f 100644 --- a/synctv-core/src/provider/twitch.rs +++ b/synctv-core/src/provider/twitch.rs @@ -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) { - for (mode_name, info) in &mut result.playback_infos { - for (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } +fn mark_twitch_playback_resources( + result: &mut PlaybackResult, + version: &str, + expires_at: i64, + client_profile: Option<&super::PlaybackClientProfile>, +) { + let original_default = result.default_mode.clone(); + let original_modes = std::mem::take(&mut result.playback_infos); + for (mode_name, mut info) in original_modes { + let source_medias = std::mem::take(&mut info.medias); + let supported_indices = source_medias + .iter() + .enumerate() + .filter_map(|(media_index, media)| { + super::proxy_playback_media_supported_by_client(client_profile, &mode_name, media) + .then_some(media_index) + }) + .collect::>(); + 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 { let PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { media_index }) = &danmaku.provider @@ -969,7 +1013,9 @@ fn mark_twitch_playback_resources(result: &mut PlaybackResult, version: &str, ex 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)] @@ -1054,12 +1100,15 @@ impl MediaProvider for TwitchProvider { credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()), 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, &cache_key, Duration::from_hours(6), ctx, - mark_twitch_playback_resources, + |result, version, expires_at| { + mark_twitch_playback_resources(result, version, expires_at, client_profile); + }, || async { let (playback, metadata) = tokio::try_join!( 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( @@ -1744,7 +1798,7 @@ mod tests { 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 .playback_infos .get("hls") diff --git a/synctv-core/src/provider/youtube.rs b/synctv-core/src/provider/youtube.rs index 973e3b7f..e1209621 100644 --- a/synctv-core/src/provider/youtube.rs +++ b/synctv-core/src/provider/youtube.rs @@ -1019,21 +1019,47 @@ fn format_metadata(format: &YoutubeFormat) -> Option { }) } -fn mark_youtube_playback_resources(result: &mut PlaybackResult, version: &str, expires_at: i64) { - for (mode_name, info) in &mut result.playback_infos { - for (media_index, media) in info.medias.iter_mut().enumerate() { - 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, - }); - } +fn mark_youtube_playback_resources( + result: &mut PlaybackResult, + version: &str, + expires_at: i64, + client_profile: Option<&super::PlaybackClientProfile>, +) { + let original_default = result.default_mode.clone(); + let original_modes = std::mem::take(&mut result.playback_infos); + for (mode_name, mut info) in original_modes { + let source_medias = std::mem::take(&mut info.medias); + let (medias, default_media_index) = super::map_playback_resources( + &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() { if matches!( 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] @@ -1120,12 +1148,15 @@ impl MediaProvider for YoutubeProvider { let credential_partition = owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()); 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, &cache_key, Duration::from_hours(5), ctx, - mark_youtube_playback_resources, + |result, version, expires_at| { + mark_youtube_playback_resources(result, version, expires_at, client_profile); + }, || async { let player = self .client @@ -1139,7 +1170,12 @@ impl MediaProvider for YoutubeProvider { 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( @@ -1589,7 +1625,7 @@ mod tests { .iter() .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 { assert!(info.medias.iter().all(|media| media.p2p_swarm_id.is_none())); diff --git a/synctv-core/src/service/mod.rs b/synctv-core/src/service/mod.rs index c80f251c..42d9d9ef 100644 --- a/synctv-core/src/service/mod.rs +++ b/synctv-core/src/service/mod.rs @@ -137,9 +137,9 @@ pub use notification_partition_manager::{ NotificationPartitionManager, }; pub use oauth2::{ - local_oauth_state_store, state_store_from_shared_state_profile, OAuth2LinkResult, - OAuth2Operation, OAuth2PendingRegistration, OAuth2Service, OAuth2ServiceRuntime, OAuth2State, - OAuth2UserInfo, OAuthStateStore, RedisOAuthStateStore, + local_oauth_state_store, state_store_from_shared_state_profile, OAuth2AuthorizationRequest, + OAuth2LinkResult, OAuth2Operation, OAuth2PendingRegistration, OAuth2Service, + OAuth2ServiceRuntime, OAuth2State, OAuth2UserInfo, OAuthStateStore, RedisOAuthStateStore, }; pub use optimistic_retry::retry_with_optimistic_lock; pub use partitioning::acquire_unbounded_ddl_connection; diff --git a/synctv-core/src/service/oauth2.rs b/synctv-core/src/service/oauth2.rs index 7d4553ee..5e8692be 100644 --- a/synctv-core/src/service/oauth2.rs +++ b/synctv-core/src/service/oauth2.rs @@ -102,6 +102,15 @@ pub struct PreparedOAuth2Authorization { pub oauth_state: OAuth2State, } +pub struct OAuth2AuthorizationRequest<'a> { + pub instance_name: &'a str, + pub redirect_url: Option, + pub request_allowed_redirect_url: Option, + pub operation: OAuth2Operation, + pub target_user_id: Option, + pub mode: crate::oauth2::OAuth2AuthorizationMode, +} + #[derive(Debug, Clone)] pub enum OAuth2LinkResult { Linked { user_id: UserId, is_new: bool }, diff --git a/synctv-core/src/service/oauth2/authorization.rs b/synctv-core/src/service/oauth2/authorization.rs index c7ab3e8a..44f90194 100644 --- a/synctv-core/src/service/oauth2/authorization.rs +++ b/synctv-core/src/service/oauth2/authorization.rs @@ -1,11 +1,15 @@ +#[cfg(test)] +use crate::oauth2::OAuth2AuthorizationMode; use synctv_common::ExecutionControl; #[cfg(test)] use tracing::debug; use crate::{ models::UserId, - oauth2::OAuth2AuthorizationMode, - service::oauth2::{OAuth2Operation, OAuth2Service, OAuth2State, PreparedOAuth2Authorization}, + service::oauth2::{ + OAuth2AuthorizationRequest, OAuth2Operation, OAuth2Service, OAuth2State, + PreparedOAuth2Authorization, + }, Error, InternalExt, Result, }; @@ -45,20 +49,28 @@ impl OAuth2Service { pub async fn prepare_authorization_url_with_control( &self, - instance_name: &str, - redirect_url: Option, - operation: OAuth2Operation, - target_user_id: Option, - mode: OAuth2AuthorizationMode, + request: OAuth2AuthorizationRequest<'_>, control: Option<&ExecutionControl>, ) -> Result { + let OAuth2AuthorizationRequest { + instance_name, + redirect_url, + request_allowed_redirect_url, + operation, + target_user_id, + mode, + } = request; Self::validate_operation_target(operation, target_user_id)?; if let Some(ref url) = redirect_url { let allowed_urls = match &self.runtime_settings_store { Some(settings) => settings.oauth2.allowed_redirect_urls.get()?.0, 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; @@ -117,11 +129,14 @@ impl OAuth2Service { ) -> Result<(String, String)> { let prepared = self .prepare_authorization_url_with_control( - instance_name, - redirect_url, - operation, - target_user_id, - OAuth2AuthorizationMode::Browser, + OAuth2AuthorizationRequest { + instance_name, + redirect_url, + request_allowed_redirect_url: None, + operation, + target_user_id, + mode: OAuth2AuthorizationMode::Browser, + }, control, ) .await?; diff --git a/synctv-core/src/service/oauth2/tests.rs b/synctv-core/src/service/oauth2/tests.rs index cbb50cff..ebdc270f 100644 --- a/synctv-core/src/service/oauth2/tests.rs +++ b/synctv-core/src/service/oauth2/tests.rs @@ -752,11 +752,14 @@ async fn test_authorization_rejects_mode_not_advertised_by_provider() { let error = err( service .prepare_authorization_url_with_control( - "github", - None, - OAuth2Operation::Login, - None, - OAuth2AuthorizationMode::Native, + OAuth2AuthorizationRequest { + instance_name: "github", + redirect_url: None, + request_allowed_redirect_url: None, + operation: OAuth2Operation::Login, + target_user_id: None, + mode: OAuth2AuthorizationMode::Native, + }, None, ) .await, @@ -787,6 +790,60 @@ async fn test_get_authorization_url_with_redirect() { 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] async fn test_get_authorization_url_rejects_invalid_redirect() { let service = create_test_service(); diff --git a/synctv-core/src/service/remote_provider_manager/resolution.rs b/synctv-core/src/service/remote_provider_manager/resolution.rs index 21767858..88071b14 100644 --- a/synctv-core/src/service/remote_provider_manager/resolution.rs +++ b/synctv-core/src/service/remote_provider_manager/resolution.rs @@ -8,6 +8,13 @@ impl RemoteProviderManager { pub(super) fn map_remote_resolution_error(err: crate::Error) -> ProviderError { match err { 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( format!("Range not satisfiable: total size {total_size}"), ), @@ -83,6 +90,13 @@ impl RemoteProviderManager { ProviderError::UnsupportedFormat(format) => { 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::MissingInstance => ProviderError::MissingInstance, ProviderError::InstanceNotFound(name) => ProviderError::InstanceNotFound(name.clone()), diff --git a/synctv-proto-build/src/lib.rs b/synctv-proto-build/src/lib.rs index 8440bb4d..86c071fb 100644 --- a/synctv-proto-build/src/lib.rs +++ b/synctv-proto-build/src/lib.rs @@ -881,6 +881,13 @@ fn configure_well_known_types(config: &mut tonic_prost_build::Config) { fn build_main_protos(out_dir: &Path) -> Result<(), Box> { let mut prost_config = tonic_prost_build::Config::new(); 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() .descriptor_pool("crate::DESCRIPTOR_POOL") .file_descriptor_set_path(out_dir.join("descriptor.bin")) diff --git a/synctv-proto/proto/client.proto b/synctv-proto/proto/client.proto index e3478332..0a86bcd4 100644 --- a/synctv-proto/proto/client.proto +++ b/synctv-proto/proto/client.proto @@ -2621,6 +2621,56 @@ enum PlaybackAudioCapability { 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 { PlaybackStreamPreference stream_preference = 1 [(buf.validate.field).enum.defined_only = true]; 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]; 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]; + // 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 { diff --git a/synctv-web-ui/Cargo.toml b/synctv-web-ui/Cargo.toml new file mode 100644 index 00000000..e5c5f8b5 --- /dev/null +++ b/synctv-web-ui/Cargo.toml @@ -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 diff --git a/synctv-web-ui/README.md b/synctv-web-ui/README.md new file mode 100644 index 00000000..19dadbe9 --- /dev/null +++ b/synctv-web-ui/README.md @@ -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. diff --git a/synctv-web-ui/build.rs b/synctv-web-ui/build.rs new file mode 100644 index 00000000..196fca02 --- /dev/null +++ b/synctv-web-ui/build.rs @@ -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 { + 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 { + 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, 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::>() + .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 { + 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::(); + 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 { + command + .output() + .map_err(|error| format!("failed to {description}: {error}")) +} + +fn command_stdout(command: &mut Command, description: &str) -> Result { + 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::>() + .join(" ") +} diff --git a/synctv-web-ui/dist/.gitkeep b/synctv-web-ui/dist/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/synctv-web-ui/dist/.gitkeep @@ -0,0 +1 @@ + diff --git a/synctv-web-ui/src/build_support.rs b/synctv-web-ui/src/build_support.rs new file mode 100644 index 00000000..db1021a3 --- /dev/null +++ b/synctv-web-ui/src/build_support.rs @@ -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, + pub dart_defines: BTreeMap, +} + +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 { + 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, 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, 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, +) -> 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 { + 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 { + command + .output() + .map_err(|error| format!("failed to {description}: {error}")) +} + +fn command_stdout(command: &mut Command, description: &str) -> Result { + 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 { + 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>, 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, +) -> 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 { + 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> { + 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> { + 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> + { + 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::, _>>()?; + + 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> { + 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> { + 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> { + 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> { + 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> + { + 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> { + 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> { + 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()) + } +} diff --git a/synctv-web-ui/src/lib.rs b/synctv-web-ui/src/lib.rs new file mode 100644 index 00000000..755fb9e4 --- /dev/null +++ b/synctv-web-ui/src/lib.rs @@ -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, + pub gzip: Option, +} + +#[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; diff --git a/synctv-web-ui/web-ui.production.toml b/synctv-web-ui/web-ui.production.toml new file mode 100644 index 00000000..5d42a29c --- /dev/null +++ b/synctv-web-ui/web-ui.production.toml @@ -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"] diff --git a/synctv-web-ui/web-ui.toml b/synctv-web-ui/web-ui.toml new file mode 100644 index 00000000..251d507d --- /dev/null +++ b/synctv-web-ui/web-ui.toml @@ -0,0 +1,5 @@ +schema-version = 1 + +[source] +kind = "dist" +path = "dist" diff --git a/synctv/Cargo.toml b/synctv/Cargo.toml index 987963bb..3c444f82 100644 --- a/synctv/Cargo.toml +++ b/synctv/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [features] # OpenAPI is opt-in through the `openapi` feature below. default = ["tls-aws-lc", "tls-webpki-roots"] +web-ui = ["synctv-api/web-ui"] # Enable Kubernetes support (K8s DNS discovery, K8s Lease leader election). # Disable to reduce binary size for non-K8s deployments: # cargo build --no-default-features diff --git a/synctv/src/cli/args.rs b/synctv/src/cli/args.rs index 3bc7f4f7..6a411fbc 100644 --- a/synctv/src/cli/args.rs +++ b/synctv/src/cli/args.rs @@ -409,6 +409,7 @@ impl PlaybackClientProfileArgs { .subtitle_preference .map_or(0, CliPlaybackSubtitlePreference::to_proto), supported_live_transports: Vec::new(), + ..Default::default() }) } } diff --git a/synctv/src/server.rs b/synctv/src/server.rs index 81ffeadc..d5725a24 100644 --- a/synctv/src/server.rs +++ b/synctv/src/server.rs @@ -2896,11 +2896,11 @@ impl SyncTvServer { } }; + let router = http_router.merge(grpc_router); + let server = axum::serve( listener, - http_router - .merge(grpc_router) - .into_make_service_with_connect_info::(), + router.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(graceful);