chore(deps): update dependencies and nightly toolchain to 2026-10-13 (#483)

## Dependency updates (exact pins)

- tokio 1.53.2, tokio-util 0.7.20, hyper 1.12.0, serde_json 1.0.154,
uuid 1.28.0, toml 1.1.8, opendal 0.59.4, ammonia 4.2.3, config 0.15.27,
http-acl 0.13.1, tokio-rustls 0.26.6, retina 0.4.21, zstd 0.14.2,
ffmpeg-sys-next 9.0.1, oxc 0.154.0, webrtc/rtc 0.21.1
- **rusty_aac 0.5.0 → 1.1.0**: 1.x is the stabilized release with a
security-hardening pass (crafted fill-element panic on overflow-checked
builds, stale decoder state after failed blocks; output bit-identical
otherwise). Usage in this repo is test-only, no API changes needed
- **html-escape 0.2.15 → 0.3.0**: fixes multi-codepoint HTML entity
decoding and speeds up common named entities; `decode_html_entities` is
used by provider title extraction

Deliberately kept (documented/intentional pins): bincode 2.0.1 (3.0.0
placeholder), dashmap 7.0.0-rc2, opaque-ke 4.1.0-pre.2, webauthn-rs
0.6.1-dev, serde_yaml, testcontainers 0.27 (testcontainers-modules 0.15
requires ^0.27).

## Toolchain and CI

- nightly-2026-09-27 → **nightly-2026-10-13** (rust-toolchain.toml + all
workflows)
- removed the `cargo-features = ["trim-paths"]` gate (stabilized in
1.101)
- actions: checkout v7, cache v6, setup-node v7, dependency-review v5,
setup-helm v5, create-pull-request v8

## Code changes required by new clippy lints (2026-10-13)

- `try_from_instead_of_from_str`: `RoomResourceKind` now implements
`FromStr` (call site uses `.parse()`)
- `items_after_test_module`: moved 7 `#[cfg(test)]` modules to end of
file

## Dependency cleanup (new cargo `unused_dependencies` lint)

- removed 13 unused crate-level deps, including `tonic-web` which is now
unused workspace-wide
- moved test-only `tempfile` (synctv-proxy) and `anyhow`
(synctv-media-providers) to dev-dependencies
- kept `synctv-cluster` in synctv-livestream: referenced only by TLS
feature forwarding (`synctv-cluster/tls-*`), which the lint does not
account for — a known false positive

## Validation

- `make check`, `make check-all-targets`, `make clippy-check`, `make
fmt-check` all clean
- `cargo nextest run --workspace --run-ignored all`: **6969/6969
passed** (incl. docker-backed e2e)
pull/484/head
zijiren 2 weeks ago committed by GitHub
parent ca91048b9d
commit 0183a80da8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -34,12 +34,12 @@ jobs:
name: Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
components: rustfmt
- name: Check formatting
@ -50,12 +50,12 @@ jobs:
name: Clippy Lints
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
components: clippy
- name: Install build dependencies
@ -77,12 +77,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld
@ -101,12 +101,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld
@ -123,12 +123,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld
@ -154,12 +154,12 @@ jobs:
env:
SYNCTV_TEST_DOCKER_STARTUP_TIMEOUT_SECS: 600
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install cargo-nextest
uses: taiki-e/install-action@nextest
@ -206,12 +206,12 @@ jobs:
env:
SYNCTV_TEST_DOCKER_STARTUP_TIMEOUT_SECS: 600
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install cargo-nextest
uses: taiki-e/install-action@nextest
@ -235,12 +235,12 @@ jobs:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install cargo-audit
run: make install-cargo-audit
@ -258,12 +258,12 @@ jobs:
name: Dependency Policy Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install cargo-deny
run: make install-cargo-deny
@ -292,12 +292,12 @@ jobs:
name: Unused Dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev lld

@ -19,9 +19,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Dependency Review
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v5
with:
fail-on-severity: moderate

@ -77,12 +77,12 @@ jobs:
WEB_UI_EXPORT_DIR: ${{ github.workspace }}/synctv-web-ui/dist
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
@ -92,7 +92,7 @@ jobs:
cache: true
- name: Cache Web build inputs
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.pub-cache

@ -25,10 +25,10 @@ jobs:
name: Validate Docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm

@ -39,15 +39,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Set up Helm
uses: azure/setup-helm@v4
uses: azure/setup-helm@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev

@ -31,17 +31,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Helm
uses: azure/setup-helm@v4
uses: azure/setup-helm@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler nasm libclang-dev

@ -25,12 +25,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-09-27
toolchain: nightly-2026-10-13
- name: Normalize release version
id: version
@ -44,7 +44,7 @@ jobs:
run: make set-release-version VERSION="${{ steps.version.outputs.value }}"
- name: Create release preparation PR
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
commit-message: "release: prepare v${{ steps.version.outputs.value }}"
branch: "automation/release-v${{ steps.version.outputs.value }}"

@ -37,7 +37,7 @@ jobs:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Create or update GitHub release
shell: bash

File diff suppressed because it is too large Load Diff

@ -1,5 +1,3 @@
cargo-features = ["trim-paths"]
[workspace]
members = [
# SyncTV crates
@ -76,7 +74,7 @@ unnecessary_wraps = "allow"
[workspace.dependencies]
# Async Runtime
tokio = { version = "=1.53.1", features = [
tokio = { version = "=1.53.2", features = [
"rt-multi-thread",
"macros",
"net",
@ -87,7 +85,7 @@ tokio = { version = "=1.53.1", features = [
"signal",
"fs",
] }
tokio-util = { version = "=0.7.19", features = ["codec", "io-util", "rt"] }
tokio-util = { version = "=0.7.20", features = ["codec", "io-util", "rt"] }
tokio-stream = "=0.1.19"
# gRPC & Protobuf
@ -99,7 +97,6 @@ tonic = { version = "=0.14.6", default-features = false, features = [
"channel",
"gzip",
] }
tonic-web = "=0.14.6"
tonic-reflection = "=0.14.6"
tonic-health = "=0.14.6"
tonic-types = "=0.14.6"
@ -127,7 +124,7 @@ tower-http = { version = "=0.7.1", features = [
"compression-gzip",
"compression-zstd",
] }
hyper = { version = "=1.11.1", default-features = false, features = [
hyper = { version = "=1.12.0", default-features = false, features = [
"http1",
"http2",
"server",
@ -176,8 +173,8 @@ webauthn-rs-proto = "=0.6.1-dev"
aes-gcm = "=0.11.1"
aes = "=0.9.3"
cbc = { version = "=0.2.1", features = ["alloc", "block-padding"] }
ammonia = "=4.2.0"
http-acl = "=0.13.0"
ammonia = "=4.2.3"
http-acl = "=0.13.1"
hkdf = "=0.13.0"
sha1 = "=0.11.0"
sha2 = "=0.11.0"
@ -189,11 +186,11 @@ subtle = "=2.6.1"
# Serialization
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.151"
serde_json = "=1.0.154"
quick-xml = "=0.42.0"
serde_yaml = "=0.9.34"
serde_ignored = "=0.1.14"
toml = "=1.1.6"
toml = "=1.1.8"
serde_urlencoded = "=0.7.1"
serde_html_form = "=0.4.1"
serde_path_to_error = "=0.1.20"
@ -208,7 +205,7 @@ lru = "=0.18.5"
# IDs & Crypto
nanoid = "=0.5.0"
uuid = { version = "=1.26.1", features = ["v4", "v5", "serde"] }
uuid = { version = "=1.28.0", features = ["v4", "v5", "serde"] }
sqids = "=0.4.2"
rand = "=0.10.3"
rand_08 = { package = "rand", version = "=0.8.6" }
@ -222,27 +219,27 @@ iana-time-zone = "=0.1.65"
# Streaming (consolidated xiu crate)
synctv-xiu = { path = "synctv-xiu", default-features = false }
webrtc = "=0.21.0"
rtc = "=0.21.0"
webrtc = "=0.21.1"
rtc = "=0.21.1"
sdp = "=0.17.2"
opus = "=0.4.0"
fdk-aac = "=0.8.0"
retina = "=0.4.20"
retina = "=0.4.21"
rust_h265 = "=0.1.0"
rusty_aac = { version = "=0.5.0", default-features = false }
rusty_aac = { version = "=1.1.0", default-features = false }
rusty_h264 = "=0.16.0"
ffmpeg-next = { version = "=9.0.0", default-features = false, features = ["build", "format"] }
ffmpeg-sys-next = { version = "=9.0.0", features = ["build-portable"] }
ffmpeg-sys-next = { version = "=9.0.1", features = ["build-portable"] }
# Object Storage
opendal = { version = "=0.59.3", default-features = false, features = [
opendal = { version = "=0.59.4", default-features = false, features = [
"executors-tokio",
"services-s3",
"layers-retry",
"layers-timeout",
"layers-concurrent-limit",
] }
opendal-http-transport-reqwest = { version = "=0.59.3", default-features = false, features = ["rustls-no-provider"] }
opendal-http-transport-reqwest = { version = "=0.59.4", default-features = false, features = ["rustls-no-provider"] }
# Logging & Tracing
tracing = "=0.1.44"
@ -253,7 +250,7 @@ tracing-appender = "=0.2.5"
dotenvy = "=0.15.7"
# Configuration
config = { version = "=0.15.26", default-features = false, features = ["yaml", "json", "toml"] }
config = { version = "=0.15.27", default-features = false, features = ["yaml", "json", "toml"] }
clap = { version = "=4.6.7", features = ["wrap_help", "derive"] }
clap_complete = "=4.6.11"
@ -270,10 +267,10 @@ url = "=2.5.8"
percent-encoding = "=2.3.2"
urlencoding = "=2.1.3"
regex = "=1.13.1"
oxc_allocator = "=0.151.0"
oxc_ast = "=0.151.0"
oxc_parser = "=0.151.0"
oxc_span = "=0.151.0"
oxc_allocator = "=0.154.0"
oxc_ast = "=0.154.0"
oxc_parser = "=0.154.0"
oxc_span = "=0.154.0"
boa_engine = { version = "=0.22.0", default-features = false }
byteorder = "=1.5.0"
http = "=1.5.0"
@ -284,7 +281,7 @@ prometheus = { version = "=0.14.0", features = ["process"] }
# Time
chrono = { version = "=0.4.45", features = ["serde"] }
html-escape = "=0.2.15"
html-escape = "=0.3.0"
rsntp = "=4.1.2"
# Kubernetes (in-cluster client for leader election & service discovery)
@ -292,7 +289,7 @@ kube = { version = "=4.2.0", default-features = false, features = ["client", "gz
k8s-openapi = { version = "=0.28.0", features = ["latest"] }
# TLS
tokio-rustls = { version = "=0.26.5", default-features = false, features = ["tls12", "brotli", "zlib"] }
tokio-rustls = { version = "=0.26.6", default-features = false, features = ["tls12", "brotli", "zlib"] }
rustls = { version = "=0.23.45", default-features = false, features = ["std", "tls12", "brotli", "zlib"] }
rustls-pemfile = "=2.2.0"
hyper-util = { version = "=0.1.21", default-features = false, features = ["tokio"] }
@ -323,7 +320,7 @@ lz4_flex = { version = "=0.14.0", default-features = false, features = [
"safe-encode",
"safe-decode",
] }
zstd = { version = "=0.14.0", default-features = false }
zstd = { version = "=0.14.2", default-features = false }
image = { version = "=0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
symphonia = { version = "=0.6.1", default-features = false, features = ["opt-simd", "all-formats", "aac", "flac", "mp3", "vorbis"] }

@ -4,7 +4,7 @@ unsound = "all"
yanked = "warn"
ignore = [
{ id = "RUSTSEC-2023-0071", reason = "rsa is only reached transitively and the advisory has no upstream fixed version yet" },
{ id = "RUSTSEC-2024-0436", reason = "paste remains required by direct macros and transitive cel usage with no maintained drop-in replacement available" },
{ id = "RUSTSEC-2024-0449", reason = "unsafe-libyaml is only reached through the intentionally pinned serde_yaml and has no maintained drop-in replacement" },
{ id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is still required for PEM parsing and the advisory has no replacement release to upgrade to" },
{ id = "RUSTSEC-2025-0141", reason = "bincode 2 is still required by the on-disk cache format and the advisory is maintenance-only with no fixed successor release" },
]

@ -1,4 +1,4 @@
[toolchain]
channel = "nightly-2026-09-27"
channel = "nightly-2026-10-13"
profile = "minimal"
components = ["clippy", "rustfmt"]

@ -94,8 +94,6 @@ futures.workspace = true
# gRPC
tonic.workspace = true
tonic-prost.workspace = true
tonic-web.workspace = true
tonic-reflection.workspace = true
tonic-health.workspace = true
tonic-types.workspace = true
prost.workspace = true
@ -109,7 +107,6 @@ pbjson-types.workspace = true
# HTTP
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
hyper.workspace = true
reqwest.workspace = true
http-body-util.workspace = true
@ -157,7 +154,6 @@ chrono.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
regex.workspace = true
ammonia.workspace = true

@ -28,130 +28,6 @@ pub struct MediaSwarmPeer {
pub connection_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
struct HangingRedisRuntime;
#[async_trait::async_trait]
impl RedisConnectionRuntime for HangingRedisRuntime {
async fn snapshot(&self) -> redis::RedisResult<redis::aio::ConnectionManager> {
std::future::pending().await
}
fn operation_timeout(&self) -> Duration {
Duration::from_millis(10)
}
}
#[tokio::test]
async fn local_tracker_bounds_candidates_and_excludes_announcing_connection() {
let tracker = MediaSwarmTracker::new(None, "test:");
let room_id = RoomId::expect_positive(1);
for index in 0..(MAX_MEDIA_SWARM_PEERS + 5) {
tracker
.announce(
room_id,
format!("usr_{index}"),
format!("conn_{index}"),
"swarm-a",
)
.await
.expect("announce should succeed");
}
let peers = tracker
.announce(
room_id,
"usr_current".to_string(),
"conn_current".to_string(),
"swarm-a",
)
.await
.expect("discovery should succeed");
assert_eq!(peers.len(), MAX_MEDIA_SWARM_PEERS);
assert!(peers
.iter()
.all(|peer| peer.connection_id != "conn_current"));
}
#[tokio::test]
async fn local_tracker_isolates_rooms_and_swarms_and_removes_leaves() {
let tracker = MediaSwarmTracker::new(None, "test:");
let room_a = RoomId::expect_positive(1);
let room_b = RoomId::expect_positive(2);
tracker
.announce(room_a, "usr_a".into(), "conn_a".into(), "swarm-a")
.await
.expect("first announce should succeed");
assert!(tracker
.announce(room_a, "usr_b".into(), "conn_b".into(), "swarm-b")
.await
.expect("other swarm announce should succeed")
.is_empty());
assert!(tracker
.announce(room_b, "usr_c".into(), "conn_c".into(), "swarm-a")
.await
.expect("other room announce should succeed")
.is_empty());
tracker
.leave(room_a, "usr_a", "conn_a", "swarm-a")
.await
.expect("leave should succeed");
assert!(tracker
.announce(room_a, "usr_d".into(), "conn_d".into(), "swarm-a")
.await
.expect("post-leave discovery should succeed")
.is_empty());
}
#[tokio::test]
async fn local_membership_lookup_requires_exact_actor_connection_and_swarm() {
let tracker = MediaSwarmTracker::new(None, "test:");
let room_id = RoomId::expect_positive(1);
tracker
.announce(
room_id,
"usr_a".to_string(),
"conn_a".to_string(),
"swarm-a",
)
.await
.expect("membership announcement should succeed");
assert!(tracker
.contains(room_id, "usr_a", "conn_a", "swarm-a")
.await
.expect("membership lookup should succeed"));
assert!(!tracker
.contains(room_id, "usr_b", "conn_a", "swarm-a")
.await
.expect("actor mismatch lookup should succeed"));
assert!(!tracker
.contains(room_id, "usr_a", "conn_a", "swarm-b")
.await
.expect("swarm mismatch lookup should succeed"));
}
#[tokio::test]
async fn redis_snapshot_is_bounded_by_runtime_operation_timeout() {
let tracker = MediaSwarmTracker::new(Some(Arc::new(HangingRedisRuntime)), "test:");
let error = tracker
.announce(
RoomId::expect_positive(1),
"usr_a".to_string(),
"conn_a".to_string(),
"swarm-a",
)
.await
.expect_err("hanging Redis snapshot should time out");
assert!(error.contains("Redis timeout during connection snapshot"));
}
}
#[derive(Clone)]
struct LocalMembership {
peer: MediaSwarmPeer,
@ -370,3 +246,127 @@ impl MediaSwarmTracker {
)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct HangingRedisRuntime;
#[async_trait::async_trait]
impl RedisConnectionRuntime for HangingRedisRuntime {
async fn snapshot(&self) -> redis::RedisResult<redis::aio::ConnectionManager> {
std::future::pending().await
}
fn operation_timeout(&self) -> Duration {
Duration::from_millis(10)
}
}
#[tokio::test]
async fn local_tracker_bounds_candidates_and_excludes_announcing_connection() {
let tracker = MediaSwarmTracker::new(None, "test:");
let room_id = RoomId::expect_positive(1);
for index in 0..(MAX_MEDIA_SWARM_PEERS + 5) {
tracker
.announce(
room_id,
format!("usr_{index}"),
format!("conn_{index}"),
"swarm-a",
)
.await
.expect("announce should succeed");
}
let peers = tracker
.announce(
room_id,
"usr_current".to_string(),
"conn_current".to_string(),
"swarm-a",
)
.await
.expect("discovery should succeed");
assert_eq!(peers.len(), MAX_MEDIA_SWARM_PEERS);
assert!(peers
.iter()
.all(|peer| peer.connection_id != "conn_current"));
}
#[tokio::test]
async fn local_tracker_isolates_rooms_and_swarms_and_removes_leaves() {
let tracker = MediaSwarmTracker::new(None, "test:");
let room_a = RoomId::expect_positive(1);
let room_b = RoomId::expect_positive(2);
tracker
.announce(room_a, "usr_a".into(), "conn_a".into(), "swarm-a")
.await
.expect("first announce should succeed");
assert!(tracker
.announce(room_a, "usr_b".into(), "conn_b".into(), "swarm-b")
.await
.expect("other swarm announce should succeed")
.is_empty());
assert!(tracker
.announce(room_b, "usr_c".into(), "conn_c".into(), "swarm-a")
.await
.expect("other room announce should succeed")
.is_empty());
tracker
.leave(room_a, "usr_a", "conn_a", "swarm-a")
.await
.expect("leave should succeed");
assert!(tracker
.announce(room_a, "usr_d".into(), "conn_d".into(), "swarm-a")
.await
.expect("post-leave discovery should succeed")
.is_empty());
}
#[tokio::test]
async fn local_membership_lookup_requires_exact_actor_connection_and_swarm() {
let tracker = MediaSwarmTracker::new(None, "test:");
let room_id = RoomId::expect_positive(1);
tracker
.announce(
room_id,
"usr_a".to_string(),
"conn_a".to_string(),
"swarm-a",
)
.await
.expect("membership announcement should succeed");
assert!(tracker
.contains(room_id, "usr_a", "conn_a", "swarm-a")
.await
.expect("membership lookup should succeed"));
assert!(!tracker
.contains(room_id, "usr_b", "conn_a", "swarm-a")
.await
.expect("actor mismatch lookup should succeed"));
assert!(!tracker
.contains(room_id, "usr_a", "conn_a", "swarm-b")
.await
.expect("swarm mismatch lookup should succeed"));
}
#[tokio::test]
async fn redis_snapshot_is_bounded_by_runtime_operation_timeout() {
let tracker = MediaSwarmTracker::new(Some(Arc::new(HangingRedisRuntime)), "test:");
let error = tracker
.announce(
RoomId::expect_positive(1),
"usr_a".to_string(),
"conn_a".to_string(),
"swarm-a",
)
.await
.expect_err("hanging Redis snapshot should time out");
assert!(error.contains("Redis timeout during connection snapshot"));
}
}

@ -97,7 +97,6 @@ futures.workspace = true
# gRPC
tonic.workspace = true
tonic-prost.workspace = true
tonic-web.workspace = true
tonic-reflection.workspace = true
tonic-health.workspace = true
tonic-types.workspace = true
@ -112,7 +111,6 @@ pbjson-types.workspace = true
# HTTP
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
hyper.workspace = true
reqwest.workspace = true
http-body-util.workspace = true
@ -160,7 +158,6 @@ chrono.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
regex.workspace = true
ammonia.workspace = true

@ -105,8 +105,6 @@ futures.workspace = true
# gRPC
tonic.workspace = true
tonic-prost.workspace = true
tonic-web.workspace = true
tonic-reflection.workspace = true
tonic-health.workspace = true
tonic-types.workspace = true
prost.workspace = true
@ -168,7 +166,6 @@ chrono.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
regex.workspace = true
ammonia.workspace = true
serde_html_form.workspace = true

@ -54,7 +54,6 @@ futures.workspace = true
tonic.workspace = true
tonic-prost.workspace = true
prost.workspace = true
prost-types.workspace = true
# Redis
redis.workspace = true

File diff suppressed because it is too large Load Diff

@ -537,22 +537,6 @@ impl IntoIterator for DynamicListResult {
}
}
#[cfg(test)]
mod dynamic_pagination_tests {
use super::dynamic_page_has_more;
#[test]
fn uses_the_upstream_page_size_before_filtering() {
assert!(dynamic_page_has_more(100, 1, 50, 50));
assert!(!dynamic_page_has_more(100, 2, 50, 50));
}
#[test]
fn stops_on_the_last_short_upstream_page() {
assert!(!dynamic_page_has_more(75, 2, 50, 25));
}
}
/// Media provider trait.
///
/// Core interface that all providers must implement. The maintenance contract
@ -887,3 +871,19 @@ pub struct NextPlayItem {
/// Provider-facing target payload for this playable item
pub target: crate::models::ProviderTarget,
}
#[cfg(test)]
mod dynamic_pagination_tests {
use super::dynamic_page_has_more;
#[test]
fn uses_the_upstream_page_size_before_filtering() {
assert!(dynamic_page_has_more(100, 1, 50, 50));
assert!(!dynamic_page_has_more(100, 2, 50, 50));
}
#[test]
fn stops_on_the_last_short_upstream_page() {
assert!(!dynamic_page_has_more(75, 2, 50, 25));
}
}

@ -76,10 +76,10 @@ impl RoomResourceKind {
}
}
impl TryFrom<&str> for RoomResourceKind {
type Error = String;
impl core::str::FromStr for RoomResourceKind {
type Err = String;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value {
"playback_state" => Ok(Self::PlaybackState),
"playback" => Ok(Self::Playback),
@ -279,8 +279,7 @@ impl TryFrom<RoomResourceEventLogRow> for RoomResourceEventLog {
Ok(Self {
event_id: row.event_id,
sequence: row.sequence,
resource_type: RoomResourceKind::try_from(row.resource_type.as_str())
.map_err(Error::Internal)?,
resource_type: row.resource_type.parse().map_err(Error::Internal)?,
event_type: row.event_type,
payload: row.payload.map(|payload| payload.0),
})

@ -188,92 +188,6 @@ impl std::str::FromStr for PermissionSet {
}
}
#[cfg(test)]
mod tests {
use super::{
parse_rtmp_advertise_address, validate_webrtc_settings, IceServerList, PermissionSet,
WebRtcRuntimeSettings,
};
use crate::models::RoomAdminPermissionBits;
use std::str::FromStr;
#[test]
fn permission_set_accepts_exact_canonical_names() {
let permissions = PermissionSet::from_str(
r#"["manage_live_streams","view_members","view_playback_history"]"#,
)
.expect("canonical permission names should parse");
assert_eq!(
permissions.bits().0,
RoomAdminPermissionBits::MANAGE_LIVE_STREAMS
| RoomAdminPermissionBits::VIEW_MEMBERS
| RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY
);
}
#[test]
fn permission_set_preserves_playback_history_in_display_round_trip() {
let original = PermissionSet::from_bits(crate::models::RoomPermissionSet(
RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY,
));
let restored = PermissionSet::from_str(&original.to_string())
.expect("displayed permission names should parse");
assert_eq!(restored, original);
}
#[test]
fn voice_participant_limit_accepts_mesh_operating_range() {
for max_voice_participants_per_room in [2, 8, 32] {
assert!(validate_webrtc_settings(&WebRtcRuntimeSettings {
external_ice_servers: IceServerList::new(),
max_voice_participants_per_room,
})
.is_ok());
}
for max_voice_participants_per_room in [1, 33] {
assert!(validate_webrtc_settings(&WebRtcRuntimeSettings {
external_ice_servers: IceServerList::new(),
max_voice_participants_per_room,
})
.is_err());
}
}
#[test]
fn rtmp_advertise_address_accepts_origin_without_business_path() {
let plain = parse_rtmp_advertise_address("rtmp://live.example.com")
.expect("RTMP origin should be accepted");
assert_eq!(plain.scheme(), "rtmp");
assert_eq!(plain.host_str(), Some("live.example.com"));
assert_eq!(plain.port(), None);
let secure = parse_rtmp_advertise_address("rtmps://live.example.com:8443")
.expect("RTMPS origin with an explicit port should be accepted");
assert_eq!(secure.scheme(), "rtmps");
assert_eq!(secure.host_str(), Some("live.example.com"));
assert_eq!(secure.port(), Some(8443));
}
#[test]
fn rtmp_advertise_address_rejects_non_origin_components() {
for address in [
"",
"http://live.example.com",
"rtmp://user:secret@live.example.com",
"rtmp://live.example.com/app",
"rtmp://live.example.com?token=secret",
"rtmp://live.example.com#fragment",
] {
assert!(
parse_rtmp_advertise_address(address).is_err(),
"unexpectedly accepted {address}"
);
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoomPasswordPolicy {
@ -1612,3 +1526,89 @@ impl PublicSettings {
}
}
}
#[cfg(test)]
mod tests {
use super::{
parse_rtmp_advertise_address, validate_webrtc_settings, IceServerList, PermissionSet,
WebRtcRuntimeSettings,
};
use crate::models::RoomAdminPermissionBits;
use std::str::FromStr;
#[test]
fn permission_set_accepts_exact_canonical_names() {
let permissions = PermissionSet::from_str(
r#"["manage_live_streams","view_members","view_playback_history"]"#,
)
.expect("canonical permission names should parse");
assert_eq!(
permissions.bits().0,
RoomAdminPermissionBits::MANAGE_LIVE_STREAMS
| RoomAdminPermissionBits::VIEW_MEMBERS
| RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY
);
}
#[test]
fn permission_set_preserves_playback_history_in_display_round_trip() {
let original = PermissionSet::from_bits(crate::models::RoomPermissionSet(
RoomAdminPermissionBits::VIEW_PLAYBACK_HISTORY,
));
let restored = PermissionSet::from_str(&original.to_string())
.expect("displayed permission names should parse");
assert_eq!(restored, original);
}
#[test]
fn voice_participant_limit_accepts_mesh_operating_range() {
for max_voice_participants_per_room in [2, 8, 32] {
assert!(validate_webrtc_settings(&WebRtcRuntimeSettings {
external_ice_servers: IceServerList::new(),
max_voice_participants_per_room,
})
.is_ok());
}
for max_voice_participants_per_room in [1, 33] {
assert!(validate_webrtc_settings(&WebRtcRuntimeSettings {
external_ice_servers: IceServerList::new(),
max_voice_participants_per_room,
})
.is_err());
}
}
#[test]
fn rtmp_advertise_address_accepts_origin_without_business_path() {
let plain = parse_rtmp_advertise_address("rtmp://live.example.com")
.expect("RTMP origin should be accepted");
assert_eq!(plain.scheme(), "rtmp");
assert_eq!(plain.host_str(), Some("live.example.com"));
assert_eq!(plain.port(), None);
let secure = parse_rtmp_advertise_address("rtmps://live.example.com:8443")
.expect("RTMPS origin with an explicit port should be accepted");
assert_eq!(secure.scheme(), "rtmps");
assert_eq!(secure.host_str(), Some("live.example.com"));
assert_eq!(secure.port(), Some(8443));
}
#[test]
fn rtmp_advertise_address_rejects_non_origin_components() {
for address in [
"",
"http://live.example.com",
"rtmp://user:secret@live.example.com",
"rtmp://live.example.com/app",
"rtmp://live.example.com?token=secret",
"rtmp://live.example.com#fragment",
] {
assert!(
parse_rtmp_advertise_address(address).is_err(),
"unexpectedly accepted {address}"
);
}
}
}

@ -108,7 +108,6 @@ jsonwebtoken.workspace = true
# URL encoding
percent-encoding.workspace = true
clap.workspace = true
[lints]
workspace = true

@ -58,7 +58,6 @@ synctv-adapter = { workspace = true, default-features = false }
synctv-cluster = { workspace = true, default-features = false }
synctv-common = { workspace = true, default-features = false }
synctv-core = { workspace = true, default-features = false }
synctv-proxy = { workspace = true, default-features = false }
synctv-proto = { workspace = true, default-features = false, features = ["main", "providers"] }
synctv-realtime = { workspace = true, default-features = false }

@ -26,7 +26,6 @@ tower.workspace = true
tonic-prost.workspace = true
tonic-health.workspace = true
prost.workspace = true
prost-types.workspace = true
hyper-util.workspace = true
tokio-rustls.workspace = true
rustls.workspace = true
@ -40,7 +39,6 @@ chrono.workspace = true
html-escape.workspace = true
# Utilities
anyhow.workspace = true
thiserror.workspace = true
bytes.workspace = true
regex.workspace = true
@ -121,6 +119,7 @@ workspace = true
tonic-prost-build.workspace = true
[dev-dependencies]
anyhow.workspace = true
tokio = { workspace = true, features = ["test-util"] }
wiremock.workspace = true
futures.workspace = true

@ -170,34 +170,6 @@ fn parse_display_count(value: &str) -> Option<u64> {
Some(value.round() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct OptionalCount {
#[serde(default, deserialize_with = "deserialize_optional_u64")]
value: Option<u64>,
}
fn count(value: &serde_json::Value) -> Option<u64> {
serde_json::from_value::<OptionalCount>(serde_json::json!({ "value": value }))
.expect("optional display counts should deserialize")
.value
}
#[test]
fn optional_display_counts_accept_numbers_and_ignore_labels() {
assert_eq!(count(&serde_json::json!(483)), Some(483));
assert_eq!(count(&serde_json::json!("1,234")), Some(1_234));
assert_eq!(count(&serde_json::json!("1.2万")), Some(12_000));
assert_eq!(count(&serde_json::json!("3亿")), Some(300_000_000));
assert_eq!(count(&serde_json::json!("-1万")), None);
assert_eq!(count(&serde_json::json!("点赞")), None);
assert_eq!(count(&serde_json::json!("-")), None);
}
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct PageUser {
#[serde(default)]
@ -379,3 +351,31 @@ pub(crate) struct RawDanmaku {
pub mode: Option<u32>,
pub size: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct OptionalCount {
#[serde(default, deserialize_with = "deserialize_optional_u64")]
value: Option<u64>,
}
fn count(value: &serde_json::Value) -> Option<u64> {
serde_json::from_value::<OptionalCount>(serde_json::json!({ "value": value }))
.expect("optional display counts should deserialize")
.value
}
#[test]
fn optional_display_counts_accept_numbers_and_ignore_labels() {
assert_eq!(count(&serde_json::json!(483)), Some(483));
assert_eq!(count(&serde_json::json!("1,234")), Some(1_234));
assert_eq!(count(&serde_json::json!("1.2万")), Some(12_000));
assert_eq!(count(&serde_json::json!("3亿")), Some(300_000_000));
assert_eq!(count(&serde_json::json!("-1万")), None);
assert_eq!(count(&serde_json::json!("点赞")), None);
assert_eq!(count(&serde_json::json!("-")), None);
}
}

@ -120,48 +120,6 @@ impl FnosMediaItem {
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(item_type: &str, title: &str, season_number: u32) -> FnosMediaItem {
FnosMediaItem {
guid: "item".to_string(),
title: title.to_string(),
item_type: item_type.to_string(),
poster: None,
tv_title: Some("Series".to_string()),
parent_title: None,
parent_guid: None,
ancestor_guid: None,
ancestor_name: None,
ancestor_category: None,
watched: 0,
is_favorite: 0,
ts: 0,
duration: 0,
episode_number: 0,
season_number,
vote_average: None,
overview: None,
media_guid: None,
video_guid: None,
audio_guid: None,
subtitle_guid: None,
single_child_guid: None,
}
}
#[test]
fn uses_native_episode_title_and_numbered_season_title() {
assert_eq!(
item("Episode", "First Tide", 1).display_title(),
"First Tide"
);
assert_eq!(item("Season", "", 2).display_title(), "S02");
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FnosPlayInfoRequest {
pub item_guid: String,
@ -371,3 +329,45 @@ pub struct FnosMediaCommandRequest {
pub(crate) struct FnosItemGuidRequest<'a> {
pub guid: &'a str,
}
#[cfg(test)]
mod tests {
use super::*;
fn item(item_type: &str, title: &str, season_number: u32) -> FnosMediaItem {
FnosMediaItem {
guid: "item".to_string(),
title: title.to_string(),
item_type: item_type.to_string(),
poster: None,
tv_title: Some("Series".to_string()),
parent_title: None,
parent_guid: None,
ancestor_guid: None,
ancestor_name: None,
ancestor_category: None,
watched: 0,
is_favorite: 0,
ts: 0,
duration: 0,
episode_number: 0,
season_number,
vote_average: None,
overview: None,
media_guid: None,
video_guid: None,
audio_guid: None,
subtitle_guid: None,
single_child_guid: None,
}
}
#[test]
fn uses_native_episode_title_and_numbered_season_title() {
assert_eq!(
item("Episode", "First Tide", 1).display_title(),
"First Tide"
);
assert_eq!(item("Season", "", 2).display_title(), "S02");
}
}

@ -44,7 +44,6 @@ async-trait.workspace = true
bincode.workspace = true
serde.workspace = true
quick-xml.workspace = true
tempfile.workspace = true
tokio-util.workspace = true
rayon.workspace = true
tonic.workspace = true
@ -56,6 +55,7 @@ subtle.workspace = true
tonic-prost-build.workspace = true
[dev-dependencies]
tempfile.workspace = true
wiremock.workspace = true
tokio = { workspace = true, features = ["test-util", "macros", "rt-multi-thread"] }
reqwest.workspace = true

@ -389,42 +389,6 @@ pub const STREAM_HUB_EVENT_CHANNEL_CAPACITY: usize = 4096;
pub type BroadcastEventSender = broadcast::Sender<BroadcastEvent>;
pub type BroadcastEventReceiver = broadcast::Receiver<BroadcastEvent>;
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn budgeted_frame_channel_releases_bytes_after_receive() {
let (sender, mut receiver) = FrameDataSender::budgeted(4096, 8);
let first = FrameData::Video {
timestamp: 0,
data: Bytes::from_static(b"123456"),
};
sender
.try_send(first)
.expect("first frame should fit budget");
let second = FrameData::Audio {
timestamp: 1,
data: Bytes::from_static(b"123"),
};
assert!(matches!(
sender.try_send(second),
Err(FrameTrySendError::Full(_))
));
let received = receiver.recv().await.expect("first frame should arrive");
assert_eq!(frame_data_bytes(&received), 6);
sender
.try_send(FrameData::Audio {
timestamp: 2,
data: Bytes::from_static(b"123"),
})
.expect("released permits should be reusable");
}
}
/// Called when media enters a locally owned publisher transceiver.
///
/// The callback runs before subscriber fan-out, so publisher liveness remains
@ -656,3 +620,39 @@ pub enum StatisticData {
start_time: DateTime<Local>,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn budgeted_frame_channel_releases_bytes_after_receive() {
let (sender, mut receiver) = FrameDataSender::budgeted(4096, 8);
let first = FrameData::Video {
timestamp: 0,
data: Bytes::from_static(b"123456"),
};
sender
.try_send(first)
.expect("first frame should fit budget");
let second = FrameData::Audio {
timestamp: 1,
data: Bytes::from_static(b"123"),
};
assert!(matches!(
sender.try_send(second),
Err(FrameTrySendError::Full(_))
));
let received = receiver.recv().await.expect("first frame should arrive");
assert_eq!(frame_data_bytes(&received), 6);
sender
.try_send(FrameData::Audio {
timestamp: 2,
data: Bytes::from_static(b"123"),
})
.expect("released permits should be reusable");
}
}

Loading…
Cancel
Save