You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-api/tests/dynamic_playback_api_tests.rs

1483 lines
51 KiB
Rust

#![allow(clippy::unwrap_used)]
mod support;
use synctv_api::ApiRuntimeSettings as Config;
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use synctv_api::{ClientApiImpl, ProxySigningKey};
use synctv_core::models::media::{AlistPlaybackLocator, AlistPlaybackMediaLocator};
use synctv_core::{
cache::{KeyBuilder, UsernameCache},
models::{
AlistMediaSourceConfig, FromProviderParams, Media, MediaSourceConfig, PlayMode,
PlaybackAlistMedia, Playlist, PlaylistId, ProviderInstance, ProviderTarget, RoomId,
SignupMethod, SourceProvider, User, UserId, UserRole, UserStatus,
},
provider::{
DynamicBrowsePathSegment, DynamicListQuery, DynamicListResult, DynamicPlaylistItem,
DynamicPlaylistProvider, ItemType, MediaProvider, NextPlayItem, PlaybackInfo,
PlaybackResult, ProviderContext, ProviderError,
},
repository::{MediaRepository, ProviderInstanceRepository, UserRepository},
service::{
BruteForceProtection, InMemoryTokenBlacklistStore, JwtService, ProvidersManager,
RemoteProviderManager, RoomService, RoomServiceOptions, UserService,
},
};
use synctv_core_testing::create_test_pool;
use synctv_realtime::sync::{ConnectionLimits, ConnectionManager};
fn alist_target(cursor: &str) -> ProviderTarget {
ProviderTarget::alist(cursor.to_string())
}
fn proto_alist_target(relative_path: &str) -> Option<synctv_proto::client::ProviderTarget> {
Some(synctv_proto::client::ProviderTarget {
target: Some(synctv_proto::client::provider_target::Target::Alist(
synctv_proto::client::AlistTarget {
relative_path: relative_path.to_string(),
},
)),
})
}
fn assert_proto_alist_target(
target: Option<&synctv_proto::client::ProviderTarget>,
expected_path: &str,
) {
let Some(synctv_proto::client::ProviderTarget {
target: Some(synctv_proto::client::provider_target::Target::Alist(target)),
}) = target
else {
panic!("expected alist provider target");
};
assert_eq!(target.relative_path, expected_path);
}
fn decode_alist_target(target: &ProviderTarget) -> String {
match target {
ProviderTarget::Alist(target) => target.relative_path.clone(),
_ => panic!("expected alist provider target"),
}
}
const NEXT_ITEM_SOURCE_CONFIG_SECRET: &str = "dynamic-next-item-secret";
fn public_id_codec() -> synctv_api::PublicIdCodec {
synctv_api::PublicIdCodec::plain()
}
fn make_user(username: &str) -> User {
let now = Utc::now();
User {
id: UserId::new(),
username: username.to_string(),
role: UserRole::User,
avatar_file_reference_id: None,
status: UserStatus::Active,
is_banned: false,
banned_at: None,
banned_by: None,
banned_reason: None,
signup_method: SignupMethod::Email,
created_at: now,
updated_at: now,
version: 0,
deleted_at: None,
}
}
fn make_user_service(pool: &sqlx::PgPool) -> UserService {
let jwt_service = JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap();
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(1000, 3600, 86400));
UserService::new_for_tests(
pool,
jwt_service,
username_cache,
token_blacklist,
KeyBuilder::new("test"),
BruteForceProtection::in_memory("test:user".to_string()),
)
}
fn make_test_alist_providers_manager(pool: &sqlx::PgPool) -> Arc<ProvidersManager> {
let provider_instance_manager = Arc::new(RemoteProviderManager::new(Arc::new(
ProviderInstanceRepository::new(pool.clone()),
)));
let mut providers_manager =
ProvidersManager::new(provider_instance_manager).expect("providers manager should build");
providers_manager.register_factory(
"alist",
Box::new(|instance_id, _config, _instance_manager| {
Ok(Arc::new(StubDynamicProvider::new(instance_id)))
}),
);
Arc::new(providers_manager)
}
fn make_room_service_with_provider_credentials(
pool: &sqlx::PgPool,
user_service: &UserService,
providers_manager: Arc<ProvidersManager>,
) -> RoomService {
let credential_encryption = synctv_core::credential_encryption::CredentialEncryption::new(
b"0123456789abcdef0123456789abcdef",
)
.unwrap();
let credential_repo = Arc::new(
synctv_core::repository::UserProviderCredentialRepository::new_with_encryption(
pool.clone(),
credential_encryption.clone(),
),
);
RoomService::new_with_providers_and_options(
pool.clone(),
user_service.clone(),
providers_manager,
RoomServiceOptions {
credential_encryption: Some(credential_encryption),
credential_repo: Some(credential_repo),
..RoomServiceOptions::test_defaults_with_settings(pool.clone())
},
)
.expect("room service should build")
}
#[derive(Debug)]
struct StubDynamicProvider {
instance_id: String,
}
#[derive(Debug)]
struct TransientPlaybackFailureProvider;
#[async_trait]
impl MediaProvider for TransientPlaybackFailureProvider {
fn name(&self) -> &'static str {
"direct_url"
}
async fn generate_playback(
&self,
_ctx: &ProviderContext<'_>,
_source_config: &MediaSourceConfig,
) -> Result<PlaybackResult, ProviderError> {
Err(ProviderError::NetworkError(
"test provider temporarily unavailable".to_string(),
))
}
}
impl StubDynamicProvider {
fn new(instance_id: impl Into<String>) -> Self {
Self {
instance_id: instance_id.into(),
}
}
fn is_bound_instance(&self) -> bool {
self.instance_id != "alist_default"
}
fn folder_cursor(&self) -> &'static str {
if self.is_bound_instance() {
"bound-season-1"
} else {
"season-1"
}
}
fn first_item_path(&self) -> &'static str {
if self.is_bound_instance() {
"bound-season-1/bound-episode-1.mp4"
} else {
"season-1/episode-1.mp4"
}
}
fn playback_target_path(&self) -> &'static str {
if self.is_bound_instance() {
"/bound-episode-1.mp4"
} else {
"/episode-1.mp4"
}
}
fn item(path: &str) -> NextPlayItem {
let name = path.rsplit('/').next().unwrap_or(path).to_string();
NextPlayItem {
name,
item_type: ItemType::Media,
source_config: MediaSourceConfig::Alist(AlistMediaSourceConfig {
server_id: NEXT_ITEM_SOURCE_CONFIG_SECRET.to_string(),
path: path.to_string(),
password: None,
proxy_mode: synctv_core::models::PlaybackProxyMode::Auto,
}),
target: alist_target(path),
}
}
}
#[async_trait]
impl MediaProvider for StubDynamicProvider {
fn name(&self) -> &'static str {
"alist"
}
async fn generate_playback(
&self,
_ctx: &ProviderContext<'_>,
source_config: &MediaSourceConfig,
) -> Result<PlaybackResult, ProviderError> {
let MediaSourceConfig::Alist(source_config) = source_config else {
return Err(ProviderError::InvalidConfig(
"Missing Alist source_config".to_string(),
));
};
if source_config.server_id != NEXT_ITEM_SOURCE_CONFIG_SECRET {
return Err(ProviderError::InvalidConfig(
"Unexpected dynamic item server_id".to_string(),
));
}
let path = source_config.path.as_str();
let direct_url = format!("https://{}.example.com{path}", self.instance_id);
let mut infos = std::collections::HashMap::new();
infos.insert("direct".to_string(), provider_playback_info(&direct_url));
infos.insert(
"proxy_direct".to_string(),
provider_playback_info(&direct_url),
);
Ok(PlaybackResult {
playback_infos: infos,
default_mode: "direct".to_string(),
provider: SourceProvider::Alist,
provider_instance_name: Some(self.instance_id.clone()),
duration_seconds: None,
playback_kind: Some(synctv_core::models::PlaybackKind::Regular),
metadata: None,
})
}
fn as_dynamic_playlist_provider(&self) -> Option<&dyn DynamicPlaylistProvider> {
Some(self)
}
}
fn provider_playback_info(url: &str) -> PlaybackInfo {
PlaybackInfo {
thumbnail: None,
medias: vec![synctv_core::models::PlaybackMedia {
name: String::new(),
format: "mp4".to_string(),
expire_at: None,
metadata: None,
p2p_swarm_id: None,
provider: synctv_core::models::PlaybackMediaProvider::Alist(
PlaybackAlistMedia::Direct {
url: url.to_string(),
headers: std::collections::HashMap::new(),
locator: AlistPlaybackLocator {
server_id: NEXT_ITEM_SOURCE_CONFIG_SECRET.to_string(),
path: url.to_string(),
password: None,
credential_owner_id: UserId::new(),
credential_revision: "test".to_string(),
provider_instance_name: None,
},
resource: AlistPlaybackMediaLocator::File,
},
),
}],
default_media_index: None,
subtitles: Vec::new(),
default_subtitle_index: None,
danmakus: Vec::new(),
default_danmaku_index: None,
}
}
#[async_trait]
impl DynamicPlaylistProvider for StubDynamicProvider {
async fn list_playlist(
&self,
_ctx: &ProviderContext<'_>,
_playlist: &Playlist,
target: Option<&ProviderTarget>,
_query: DynamicListQuery,
) -> Result<DynamicListResult, ProviderError> {
let items = match target
.map(decode_alist_target)
.as_deref()
.unwrap_or_default()
{
"" => vec![DynamicPlaylistItem {
name: self.folder_cursor().to_string(),
item_type: ItemType::Playlist,
target: alist_target(self.folder_cursor()),
size: None,
thumbnail: None,
description: None,
modified_at: None,
source_config: None,
metadata: None,
}],
cursor if cursor == self.folder_cursor() => vec![DynamicPlaylistItem {
name: self
.first_item_path()
.rsplit('/')
.next()
.unwrap_or(self.first_item_path())
.to_string(),
item_type: ItemType::Media,
target: alist_target(self.first_item_path()),
size: None,
thumbnail: None,
description: None,
modified_at: None,
source_config: None,
metadata: None,
}],
_ => Vec::new(),
};
Ok(DynamicListResult {
has_more: false,
items,
pagination: synctv_core::provider::DynamicPagination::Page { page: 1 },
supports_search: false,
})
}
async fn resolve_item(
&self,
_ctx: &ProviderContext<'_>,
_playlist: &Playlist,
target: &ProviderTarget,
) -> Result<Option<NextPlayItem>, ProviderError> {
let cursor = decode_alist_target(target);
Ok(match cursor.as_str() {
path if path == self.playback_target_path() || path == self.first_item_path() => {
Some(Self::item(&cursor))
}
_ => None,
})
}
async fn next(
&self,
_ctx: &ProviderContext<'_>,
_playlist: &Playlist,
target: &ProviderTarget,
play_mode: PlayMode,
) -> Result<Option<NextPlayItem>, ProviderError> {
let cursor = decode_alist_target(target);
Ok(match (cursor.as_str(), play_mode) {
(path, PlayMode::Sequential | PlayMode::RepeatAll | PlayMode::Shuffle)
if path == self.playback_target_path() =>
{
Some(Self::item(if self.is_bound_instance() {
"/bound-episode-2.mp4"
} else {
"/episode-2.mp4"
}))
}
(path, PlayMode::RepeatAll)
if path
== if self.is_bound_instance() {
"/bound-episode-2.mp4"
} else {
"/episode-2.mp4"
} =>
{
Some(Self::item(self.playback_target_path()))
}
(path, PlayMode::RepeatOne)
if path == self.playback_target_path()
|| path
== if self.is_bound_instance() {
"/bound-episode-2.mp4"
} else {
"/episode-2.mp4"
} =>
{
None
}
_ => None,
})
}
async fn browse_path(
&self,
_ctx: &ProviderContext<'_>,
_playlist: &Playlist,
target: Option<&ProviderTarget>,
) -> Result<Vec<DynamicBrowsePathSegment>, ProviderError> {
let Some(cursor) = target.map(decode_alist_target) else {
return Ok(Vec::new());
};
Ok(match cursor.as_str() {
path if path == self.folder_cursor() || path == self.first_item_path() => {
vec![DynamicBrowsePathSegment {
name: self.folder_cursor().to_string(),
target: alist_target(self.folder_cursor()),
}]
}
_ => Vec::new(),
})
}
}
async fn create_dynamic_playlist(
pool: &sqlx::PgPool,
room_id: &RoomId,
owner_id: &UserId,
provider_instance_name: &str,
) -> Playlist {
let now = Utc::now();
let instance = ProviderInstance {
name: provider_instance_name.to_string(),
endpoint: "http://localhost:50051".to_string(),
comment: Some("test provider instance".to_string()),
jwt_secret: None,
custom_ca: None,
timeout: "10s".to_string(),
tls: false,
insecure_tls: false,
providers: vec![synctv_core::models::SourceProvider::Alist],
enabled: true,
created_at: now,
updated_at: now,
};
ProviderInstanceRepository::new(pool.clone())
.create(&instance)
.await
.unwrap();
let playlist = Playlist {
id: PlaylistId::new(),
room_id: *room_id,
creator_id: Some(*owner_id),
name: "Dynamic Playlist".to_string(),
description: String::new(),
cover_file_reference_id: None,
parent_id: None,
position: 0.0,
source_provider: Some(synctv_core::models::SourceProvider::Alist),
source_config: Some(synctv_core_testing::alist_directory_playlist_source_config(
provider_instance_name,
"/",
)),
provider_instance_name: Some(provider_instance_name.to_string()),
created_at: Utc::now(),
updated_at: Utc::now(),
version: 0,
};
synctv_core::repository::PlaylistRepository::new(pool.clone())
.create(&playlist)
.await
.unwrap()
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_playback_returns_dynamic_playlist_item_playback_info() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let providers_manager = make_test_alist_providers_manager(&pool);
let room_service = make_room_service_with_provider_credentials(
&pool,
&user_service,
providers_manager.clone(),
);
let room_service = Arc::new(room_service);
providers_manager
.create_provider_with_default_config("alist", "alist_default")
.await
.unwrap();
let owner = user_repo
.create(&make_user("api_dynamic_owner"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Dynamic Playback".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let playlist = create_dynamic_playlist(&pool, &room.id, &owner.id, "alist_default").await;
let codec = public_id_codec();
let room_public_id = codec.encode_room_id(room.id).unwrap();
let playlist_public_id = codec.encode_playlist_id(playlist.id).unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service: room_service.clone(),
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
client_api
.start_playback(
&owner.id,
&room_public_id,
synctv_proto::client::StartPlaybackRequest {
media_id: String::new(),
playlist_id: playlist_public_id.clone(),
target: proto_alist_target("/episode-1.mp4"),
client_operation_id: None,
},
)
.await
.unwrap();
let response = client_api
.get_playback(
&owner.id,
&room_public_id,
synctv_proto::client::GetPlaybackRequest {
playback_client_profile: None,
},
)
.await
.unwrap();
let response_json = serde_json::to_string(&response).unwrap();
assert!(
!response_json.contains(NEXT_ITEM_SOURCE_CONFIG_SECRET),
"dynamic NextPlayItem source_config must not be exposed in client playback responses"
);
let state = response.playback_state.unwrap();
assert_eq!(state.playing_media_id, "");
assert_eq!(state.playing_playlist_id, playlist_public_id);
assert_proto_alist_target(state.target.as_ref(), "/episode-1.mp4");
let playback = response.playback.unwrap();
assert_eq!(playback.playlist_id, playlist_public_id);
assert_eq!(playback.name, "episode-1.mp4");
assert_proto_alist_target(playback.target.as_ref(), "/episode-1.mp4");
let direct = playback.playback_infos.get("proxy_direct").unwrap();
assert_eq!(direct.medias.len(), 1);
assert_eq!(
direct.medias[0].url,
"https://alist_default.example.com/episode-1.mp4"
);
let update_response = client_api
.update_playback_state(
&owner.id,
&room_public_id,
synctv_proto::client::UpdatePlaybackStateRequest {
r#type: synctv_proto::client::PlaybackUpdateType::Seek as i32,
playing: None,
position: Some(12.5),
speed: None,
version: Some(state.version),
expected_media_id: Some(state.playing_media_id.clone()),
expected_playlist_id: Some(state.playing_playlist_id.clone()),
expected_target_hash: Some(state.target_hash.clone()),
client_operation_id: None,
client_time_millis: None,
},
)
.await
.unwrap();
let update_state = update_response;
assert_eq!(update_state.playing_playlist_id, playlist_public_id);
assert!(
update_state.position >= 12.5,
"playing update response should not rewind below the requested seek position"
);
assert!(
update_state.position < 17.5,
"playing update response should not jump far beyond the requested seek position"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_list_playlist_items_returns_current_path_for_dynamic_playlist() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let providers_manager = make_test_alist_providers_manager(&pool);
let room_service = make_room_service_with_provider_credentials(
&pool,
&user_service,
providers_manager.clone(),
);
let room_service = Arc::new(room_service);
providers_manager
.create_provider_with_default_config("alist", "alist_default")
.await
.unwrap();
let owner = user_repo
.create(&make_user("api_dynamic_path_owner"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Dynamic Path".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let playlist = create_dynamic_playlist(&pool, &room.id, &owner.id, "alist_default").await;
let codec = public_id_codec();
let room_public_id = codec.encode_room_id(room.id).unwrap();
let playlist_public_id = codec.encode_playlist_id(playlist.id).unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service: room_service.clone(),
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
let response = client_api
.list_playlist_items(
&owner.id,
&room_public_id,
synctv_proto::client::ListPlaylistItemsRequest {
playlist_id: playlist_public_id.clone(),
target: proto_alist_target("season-1"),
pagination: Some(
synctv_proto::client::list_playlist_items_request::Pagination::Page(
synctv_proto::client::PagePagination { page: 1 },
),
),
page_size: 50,
search: String::new(),
source_provider: synctv_proto::source_config::SourceProvider::Unspecified as i32,
provider_instance_name: String::new(),
sort_by: synctv_proto::client::MediaListSortBy::Position as i32,
sort_direction: synctv_proto::client::SortDirection::Asc as i32,
availability: synctv_proto::client::ResourceAvailabilityFilter::All as i32,
refresh: false,
preview_source_config: None,
},
)
.await
.unwrap();
assert_eq!(response.dynamic_items.len(), 1);
assert_eq!(response.dynamic_items[0].name, "episode-1.mp4");
assert_proto_alist_target(
response.dynamic_items[0].target.as_ref(),
"season-1/episode-1.mp4",
);
assert_eq!(response.current_path.len(), 2);
assert_eq!(response.current_path[0].playlist_id, playlist_public_id);
assert_eq!(response.current_path[0].name, "Dynamic Playlist");
assert!(response.current_path[0].target.is_none());
assert_eq!(response.current_path[1].playlist_id, "");
assert_eq!(response.current_path[1].name, "season-1");
assert_proto_alist_target(response.current_path[1].target.as_ref(), "season-1");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_dynamic_playlist_get_playback_uses_bound_provider_instance() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let providers_manager = make_test_alist_providers_manager(&pool);
let room_service = make_room_service_with_provider_credentials(
&pool,
&user_service,
providers_manager.clone(),
);
let room_service = Arc::new(room_service);
providers_manager
.create_provider_with_default_config("alist", "alist_default")
.await
.unwrap();
providers_manager
.create_provider_with_default_config("alist", "alist_alt")
.await
.unwrap();
let owner = user_repo
.create(&make_user("api_dynamic_bound_owner"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Dynamic Bound Playback".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let playlist = create_dynamic_playlist(&pool, &room.id, &owner.id, "alist_alt").await;
let codec = public_id_codec();
let room_public_id = codec.encode_room_id(room.id).unwrap();
let playlist_public_id = codec.encode_playlist_id(playlist.id).unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service: room_service.clone(),
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
client_api
.start_playback(
&owner.id,
&room_public_id,
synctv_proto::client::StartPlaybackRequest {
media_id: String::new(),
playlist_id: playlist_public_id,
target: proto_alist_target("/bound-episode-1.mp4"),
client_operation_id: None,
},
)
.await
.unwrap();
let response = client_api
.get_playback(
&owner.id,
&room_public_id,
synctv_proto::client::GetPlaybackRequest {
playback_client_profile: None,
},
)
.await
.unwrap();
let playback = response.playback.unwrap();
let direct = playback.playback_infos.get("direct").unwrap();
assert_eq!(
direct.medias[0].url,
"https://alist_alt.example.com/bound-episode-1.mp4"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_static_provider_playback_with_signing_key_uses_provider_store_registry() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let provider_instance_manager = Arc::new(RemoteProviderManager::new(Arc::new(
ProviderInstanceRepository::new(pool.clone()),
)));
let providers_manager = Arc::new(
ProvidersManager::new(provider_instance_manager).expect("providers manager should build"),
);
providers_manager
.create_provider_with_default_config("direct_url", "direct_url")
.await
.unwrap();
let room_service = RoomService::new_with_providers_for_tests(
pool.clone(),
(*user_service).clone(),
providers_manager.clone(),
)
.expect("room service should build");
let room_service = Arc::new(room_service);
let owner = user_repo
.create(&make_user("api_playback_provider_owner"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Signed Provider Playback".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let media = Media::from_provider_with_params(FromProviderParams {
playlist_id: None,
room_id: room.id,
creator_id: Some(owner.id),
name: "Signed Provider Media".to_string(),
description: String::new(),
source_config: synctv_core_testing::direct_url_media_source_config_with_headers(
"https://example.com/video.mp4",
HashMap::from([(
"Authorization".to_string(),
"Bearer provider-token".to_string(),
)]),
),
source_provider: synctv_core::models::SourceProvider::DirectUrl,
provider_instance_name: None,
position: 0.0,
});
let media = media_repo.create(&media).await.unwrap();
let codec = public_id_codec();
let room_public_id = codec.encode_room_id(room.id).unwrap();
let media_public_id = codec.encode_media_id(media.id).unwrap();
let provider_stores = Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:".to_string(),
));
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service,
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores,
email_api: None,
passkey_service: None,
},
synctv_api::ClientApiRuntime {
signing_key: Arc::new(
ProxySigningKey::try_derive_from(b"Test_Secret_Key_For_JWT_Tokens_32Bytes!!")
.expect("test proxy signing key should derive"),
),
..support::client_api_runtime()
},
);
client_api
.start_playback(
&owner.id,
&room_public_id,
synctv_proto::client::StartPlaybackRequest {
media_id: media_public_id.clone(),
playlist_id: String::new(),
target: None,
client_operation_id: None,
},
)
.await
.unwrap();
let response = client_api
.get_playback(
&owner.id,
&room_public_id,
synctv_proto::client::GetPlaybackRequest {
playback_client_profile: None,
},
)
.await
.unwrap();
let playback = response.playback.unwrap();
let direct = playback.playback_infos.get("proxy_direct").unwrap();
assert_eq!(direct.medias.len(), 1);
assert!(
direct.medias[0]
.url
.starts_with("/api/playback-providers/direct-url/"),
"signed provider playback should expose proxy URL, got {}",
direct.medias[0].url
);
assert!(
direct.medias[0].url.contains("/streams/direct/0?"),
"signed direct-url playback should use stream proxy contract, got {}",
direct.medias[0].url
);
assert!(
direct.medias[0].headers.is_empty(),
"proxy-backed playback should not require client-side secret headers"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_playback_without_active_media_returns_idle_playback_info() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let room_service = RoomService::new_for_tests(pool.clone(), (*user_service).clone())
.expect("room service should build");
let room_service = Arc::new(room_service);
let owner = user_repo
.create(&make_user("api_idle_playback_owner"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Idle Playback".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let room_public_id = public_id_codec().encode_room_id(room.id).unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service,
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
let response = client_api
.get_playback(
&owner.id,
&room_public_id,
synctv_proto::client::GetPlaybackRequest {
playback_client_profile: None,
},
)
.await
.unwrap();
let _playback_state = response
.playback_state
.expect("idle room should still expose playback state");
let playback = response
.playback
.expect("idle room should still expose playback");
assert_eq!(playback.room_id, room_public_id);
assert_eq!(playback.media_id, "");
assert_eq!(playback.playlist_id, "");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_get_playback_returns_state_when_playback_info_generation_fails() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let provider_instance_manager = Arc::new(RemoteProviderManager::new(Arc::new(
ProviderInstanceRepository::new(pool.clone()),
)));
let mut providers_manager =
ProvidersManager::new(provider_instance_manager).expect("providers manager should build");
providers_manager.register_factory(
"direct_url",
Box::new(|_instance_id, _config, _instance_manager| {
Ok(Arc::new(TransientPlaybackFailureProvider))
}),
);
providers_manager
.create_provider_with_default_config("direct_url", "direct_url")
.await
.unwrap();
let room_service = RoomService::new_with_providers_for_tests(
pool.clone(),
(*user_service).clone(),
Arc::new(providers_manager),
)
.expect("room service should build");
let room_service = Arc::new(room_service);
let owner = user_repo
.create(&make_user("api_playback_state_only"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Playback State Only".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let media = Media::from_provider_with_params(FromProviderParams {
playlist_id: None,
room_id: room.id,
creator_id: Some(owner.id),
name: "Transient Playback Provider".to_string(),
description: String::new(),
source_config: synctv_core_testing::direct_url_media_source_config(
"https://example.com/video.mp4",
),
source_provider: synctv_core::models::SourceProvider::DirectUrl,
provider_instance_name: None,
position: 0.0,
});
let media = media_repo.create(&media).await.unwrap();
let codec = public_id_codec();
let room_public_id = codec.encode_room_id(room.id).unwrap();
let media_public_id = codec.encode_media_id(media.id).unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service: room_service.clone(),
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
client_api
.start_playback(
&owner.id,
&room_public_id,
synctv_proto::client::StartPlaybackRequest {
media_id: media_public_id.clone(),
playlist_id: String::new(),
target: None,
client_operation_id: None,
},
)
.await
.unwrap();
let response = client_api
.get_playback(
&owner.id,
&room_public_id,
synctv_proto::client::GetPlaybackRequest {
playback_client_profile: None,
},
)
.await
.unwrap();
let playback_state = response
.playback_state
.expect("playback state should still be returned when playback info generation fails");
assert_eq!(playback_state.playing_media_id, media_public_id);
assert!(playback_state.is_playing);
assert!(
response.playback.is_none(),
"playback info failures should degrade to state-only responses"
);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_start_playback_returns_error_for_invalid_live_proxy_source_config() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let media_repo = MediaRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let room_service = RoomService::new_for_tests(pool.clone(), (*user_service).clone())
.expect("room service should build");
let room_service = Arc::new(room_service);
room_service
.media_service()
.providers_manager()
.create_builtin_defaults()
.await
.expect("built-in providers should initialize");
let owner = user_repo
.create(&make_user("api_playback_invalid_live_proxy"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Invalid Live Proxy Playback".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let media = Media::from_provider_with_params(FromProviderParams {
playlist_id: None,
room_id: room.id,
creator_id: Some(owner.id),
name: "Invalid Live Proxy Playback Provider".to_string(),
description: String::new(),
source_config: synctv_core_testing::live_proxy_pull_live_media_source_config("not-a-url"),
source_provider: synctv_core::models::SourceProvider::LiveProxy,
provider_instance_name: None,
position: 0.0,
});
let media = media_repo.create(&media).await.unwrap();
let codec = public_id_codec();
let room_public_id = codec.encode_room_id(room.id).unwrap();
let media_public_id = codec.encode_media_id(media.id).unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service: room_service.clone(),
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
let error = client_api
.start_playback(
&owner.id,
&room_public_id,
synctv_proto::client::StartPlaybackRequest {
media_id: media_public_id,
playlist_id: String::new(),
target: None,
client_operation_id: None,
},
)
.await
.expect_err("invalid provider config should reject the playback switch");
assert!(matches!(
error,
synctv_api::ApiError::InvalidInput(message)
if message.contains("Invalid LiveProxy source URL")
));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_dynamic_playlist_list_items_uses_bound_provider_instance() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let providers_manager = make_test_alist_providers_manager(&pool);
let room_service = make_room_service_with_provider_credentials(
&pool,
&user_service,
providers_manager.clone(),
);
let room_service = Arc::new(room_service);
providers_manager
.create_provider_with_default_config("alist", "alist_default")
.await
.unwrap();
providers_manager
.create_provider_with_default_config("alist", "alist_alt")
.await
.unwrap();
let owner = user_repo
.create(&make_user("api_dynamic_bound_list_owner"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Dynamic Bound Browse".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let playlist = create_dynamic_playlist(&pool, &room.id, &owner.id, "alist_alt").await;
let codec = public_id_codec();
let room_public_id = codec.encode_room_id(room.id).unwrap();
let playlist_public_id = codec.encode_playlist_id(playlist.id).unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service: room_service.clone(),
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
let response = client_api
.list_playlist_items(
&owner.id,
&room_public_id,
synctv_proto::client::ListPlaylistItemsRequest {
playlist_id: playlist_public_id,
target: None,
pagination: Some(
synctv_proto::client::list_playlist_items_request::Pagination::Page(
synctv_proto::client::PagePagination { page: 1 },
),
),
page_size: 50,
search: String::new(),
source_provider: synctv_proto::source_config::SourceProvider::Unspecified as i32,
provider_instance_name: String::new(),
sort_by: synctv_proto::client::MediaListSortBy::Position as i32,
sort_direction: synctv_proto::client::SortDirection::Asc as i32,
availability: synctv_proto::client::ResourceAvailabilityFilter::All as i32,
refresh: false,
preview_source_config: None,
},
)
.await
.unwrap();
assert_eq!(response.dynamic_items.len(), 1);
assert_eq!(response.dynamic_items[0].name, "bound-season-1");
assert_proto_alist_target(response.dynamic_items[0].target.as_ref(), "bound-season-1");
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_list_playlist_items_allows_room_root_with_empty_playlist_id() {
let (_postgres, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let user_service = Arc::new(make_user_service(&pool));
let provider_instance_manager = Arc::new(RemoteProviderManager::new(Arc::new(
ProviderInstanceRepository::new(pool.clone()),
)));
let providers_manager = Arc::new(
ProvidersManager::new(provider_instance_manager).expect("providers manager should build"),
);
let room_service = RoomService::new_with_providers_for_tests(
pool.clone(),
(*user_service).clone(),
providers_manager,
)
.expect("room service should build");
let room_service = Arc::new(room_service);
let owner = user_repo
.create(&make_user("api_root_items_owner"))
.await
.unwrap();
let (room, _) = room_service
.create_room(
"API Root Items".to_string(),
String::new(),
owner.id,
None,
None,
)
.await
.unwrap();
let root_media = synctv_core::models::Media::from_direct_single_mode(
None,
room.id,
Some(owner.id),
"Root Media".to_string(),
"direct",
synctv_core::models::PlaybackInfo {
thumbnail: None,
medias: vec![synctv_core::models::PlaybackMedia {
name: String::new(),
format: "mp4".to_string(),
expire_at: None,
metadata: None,
p2p_swarm_id: None,
provider: synctv_core::models::PlaybackMediaProvider::DirectUrl(
synctv_core::models::PlaybackDirectUrlMedia::Direct {
url: "https://example.com/root.mp4".to_string(),
headers: std::collections::HashMap::new(),
},
),
}],
default_media_index: None,
subtitles: Vec::new(),
default_subtitle_index: None,
danmakus: Vec::new(),
default_danmaku_index: None,
},
0.0,
)
.expect("direct media should build");
synctv_core::repository::MediaRepository::new(pool.clone())
.create(&root_media)
.await
.unwrap();
let client_api = ClientApiImpl::new_with_runtime(
synctv_api::ClientApiOptions {
read_pool: None,
user_service,
room_service,
connection_service: Arc::new(ConnectionManager::new(ConnectionLimits::default())),
runtime_settings: Arc::new(Config::default()),
publish_key_service: None,
jwt_service: JwtService::new("Test_Secret_Key_For_JWT_Tokens_32Bytes!!").unwrap(),
live_streaming_infrastructure: None,
runtime_settings_store: None,
public_id_codec: Arc::new(synctv_api::PublicIdCodec::plain()),
chat_service: None,
provider_stores: Arc::new(synctv_core::provider::ProviderStoreRegistry::local_only(
"test:provider:",
)),
email_api: None,
passkey_service: None,
},
support::client_api_runtime(),
);
let room_public_id = public_id_codec().encode_room_id(room.id).unwrap();
let response = client_api
.list_playlist_items(
&owner.id,
&room_public_id,
synctv_proto::client::ListPlaylistItemsRequest {
playlist_id: String::new(),
target: None,
pagination: Some(
synctv_proto::client::list_playlist_items_request::Pagination::Page(
synctv_proto::client::PagePagination { page: 1 },
),
),
page_size: 50,
search: String::new(),
source_provider: synctv_proto::source_config::SourceProvider::Unspecified as i32,
provider_instance_name: String::new(),
sort_by: synctv_proto::client::MediaListSortBy::Position as i32,
sort_direction: synctv_proto::client::SortDirection::Asc as i32,
availability: synctv_proto::client::ResourceAvailabilityFilter::All as i32,
refresh: false,
preview_source_config: None,
},
)
.await
.unwrap();
assert_eq!(response.playlists.len(), 0);
assert_eq!(response.media.len(), 1);
assert_eq!(response.media[0].name, "Root Media");
assert!(response.dynamic_items.is_empty());
assert!(response.current_path.is_empty());
}