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-core/src/provider/twitch.rs

1771 lines
66 KiB
Rust

//! Twitch media provider adapter.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use futures::StreamExt;
use sha2::{Digest, Sha256};
use super::{
DynamicListQuery, DynamicListResult, DynamicPagination, DynamicPlaylistItem,
DynamicPlaylistItemThumbnail, DynamicPlaylistProvider, ItemType, MediaProvider, NextPlayItem,
PlaybackInfo, PlaybackResult, ProviderContext, ProviderCredentialDependency,
ProviderCredentialPolicy, ProviderError, SourceConfig, SourceCover,
};
use crate::models::{
MediaSourceConfig, PlayMode, PlaybackDanmaku, PlaybackDanmakuProvider, PlaybackMedia,
PlaybackMediaMetadata, PlaybackMediaProvider, PlaybackMetadata, PlaybackTwitchDanmaku,
PlaybackTwitchMedia, PlaylistSourceConfig, ProviderCredential, ProviderTarget,
TwitchChapterMetadata, TwitchMediaSourceConfig, TwitchPlaybackMetadata,
TwitchPlaybackResourceKind, TwitchPlaylistContent, TwitchPlaylistSourceConfig,
TwitchTargetKind, UserId, UserProviderCredential,
};
use crate::repository::UserProviderCredentialRepository;
use synctv_media_providers::twitch::{
TwitchBrowseItem, TwitchBrowseKind, TwitchClient, TwitchMetadata, TwitchPlayback,
TwitchResource, TwitchResourceKind, TwitchSession, TwitchSessionIdentity, TwitchStreamItem,
};
const DYNAMIC_PAGE_SIZE: usize = 50;
const DYNAMIC_SHUFFLE_LIMIT: usize = 200;
pub struct TwitchProvider {
client: TwitchClient,
credential_repo: Option<Arc<UserProviderCredentialRepository>>,
}
#[derive(Debug, Clone)]
pub struct TwitchBind {
pub id: i64,
pub server_id: String,
pub login: String,
pub twitch_user_id: String,
pub client_id: String,
pub scopes: Vec<String>,
pub created_at: i64,
pub provider_instance_name: Option<String>,
}
pub type TwitchChatStream = std::pin::Pin<
Box<dyn futures::Stream<Item = Result<TwitchChatEvent, ProviderError>> + Send + 'static>,
>;
#[derive(Debug, Clone)]
pub struct TwitchChatEvent {
pub id: String,
pub user_name: String,
pub text: String,
pub color: Option<String>,
pub badges: Vec<String>,
pub sent_at_ms: Option<u64>,
}
impl Default for TwitchProvider {
fn default() -> Self {
Self::new()
}
}
impl TwitchProvider {
pub const NAME: &'static str = "twitch";
#[must_use]
pub fn new() -> Self {
Self {
client: TwitchClient::new().expect("Twitch HTTP client should build"),
credential_repo: None,
}
}
#[must_use]
pub fn with_http_client(client: reqwest::Client) -> Self {
Self {
client: TwitchClient::with_http_client(client),
credential_repo: None,
}
}
#[must_use]
pub fn with_credential_repo(
&self,
credential_repo: Arc<UserProviderCredentialRepository>,
) -> Self {
Self {
client: self.client.clone(),
credential_repo: Some(credential_repo),
}
}
fn credential_repo_or<'a>(
&'a self,
fallback: Option<&'a UserProviderCredentialRepository>,
) -> Option<&'a UserProviderCredentialRepository> {
self.credential_repo.as_deref().or(fallback)
}
#[must_use]
pub fn credential_server_id_for_instance(provider_instance_name: Option<&str>) -> String {
let instance_name = crate::models::normalize_provider_instance_name(provider_instance_name)
.unwrap_or_default();
hex::encode(Sha256::digest(
format!("twitch\n{instance_name}").as_bytes(),
))
}
async fn session(
&self,
ctx: &ProviderContext<'_>,
credential_policy: ProviderCredentialPolicy,
) -> Result<TwitchSession, ProviderError> {
let Some(repo) = self.credential_repo_or(ctx.credential_repo) else {
return Ok(TwitchSession::default());
};
let owner_id = ctx.selected_credential_user_id(credential_policy);
let Some(owner_id) = owner_id else {
return Ok(TwitchSession::default());
};
let server_id =
Self::credential_server_id_for_instance(super::bound_provider_instance_name(ctx));
self.session_for_owner(repo, owner_id, &server_id).await
}
async fn session_for_owner(
&self,
repo: &UserProviderCredentialRepository,
owner_id: UserId,
server_id: &str,
) -> Result<TwitchSession, ProviderError> {
let Some(credential) = repo
.get_by_provider_and_server(owner_id, Self::NAME, server_id)
.await
.map_err(|error| ProviderError::Internal(error.to_string()))?
else {
return Ok(TwitchSession::default());
};
match credential.credential_data {
ProviderCredential::Twitch {
login,
twitch_user_id,
client_id,
scopes,
auth_token,
device_id,
client_integrity,
..
} => Ok(TwitchSession {
login: Some(login),
user_id: Some(twitch_user_id),
client_id: Some(client_id),
scopes,
auth_token: Some(auth_token),
device_id,
client_integrity,
}),
_ => Err(ProviderError::InvalidCredentialType),
}
}
async fn stored_session(
&self,
owner_id: UserId,
provider_instance_name: Option<&str>,
) -> Result<TwitchSession, ProviderError> {
let Some(repo) = self.credential_repo.as_deref() else {
return Ok(TwitchSession::default());
};
let server_id = Self::credential_server_id_for_instance(provider_instance_name);
self.session_for_owner(repo, owner_id, &server_id).await
}
pub async fn persist_session(
&self,
user_id: UserId,
session: TwitchSession,
provider_instance_name: Option<String>,
) -> Result<(String, TwitchSessionIdentity), ProviderError> {
let identity = self.client.validate_session(&session).await?;
let auth_token = session.auth_token.ok_or_else(|| {
ProviderError::InvalidConfig("Twitch auth token is required".to_string())
})?;
let provider_instance_name =
crate::models::normalize_provider_instance_name_owned(provider_instance_name);
let server_id = Self::credential_server_id_for_instance(provider_instance_name.as_deref());
let now = Utc::now();
let credential = UserProviderCredential {
id: 0,
user_id,
provider: Self::NAME.to_string(),
server_id: server_id.clone(),
provider_instance_name,
credential_data: ProviderCredential::Twitch {
login: identity.login.clone(),
twitch_user_id: identity.user_id.clone(),
client_id: identity.client_id.clone(),
scopes: identity.scopes.clone(),
auth_token,
device_id: session.device_id,
client_integrity: session.client_integrity,
},
expires_at: identity
.expires_in
.and_then(|seconds| i64::try_from(seconds).ok())
.map(|seconds| now + chrono::Duration::seconds(seconds)),
created_at: now,
updated_at: now,
};
self.credential_repo
.as_deref()
.ok_or_else(|| {
ProviderError::Internal("Twitch credential repository is unavailable".to_string())
})?
.upsert_by_user_provider_server(&credential)
.await
.map_err(|error| ProviderError::Internal(error.to_string()))?;
Ok((server_id, identity))
}
pub async fn list_binds(
&self,
user_id: UserId,
provider_instance_name: Option<&str>,
) -> Result<Vec<TwitchBind>, ProviderError> {
let requested = crate::models::normalize_provider_instance_name(provider_instance_name);
self.credential_repo
.as_deref()
.ok_or_else(|| {
ProviderError::Internal("Twitch credential repository is unavailable".to_string())
})?
.get_readable_by_provider(user_id, Self::NAME)
.await
.map_err(|error| ProviderError::Internal(error.to_string()))?
.into_iter()
.filter(|credential| {
requested
.is_none_or(|name| credential.provider_instance_name.as_deref() == Some(name))
})
.map(|credential| {
let ProviderCredential::Twitch {
login,
twitch_user_id,
client_id,
scopes,
..
} = credential.credential_data
else {
return Err(ProviderError::InvalidCredentialType);
};
Ok(TwitchBind {
id: credential.id,
server_id: credential.server_id,
login,
twitch_user_id,
client_id,
scopes,
created_at: credential.created_at.timestamp(),
provider_instance_name: credential.provider_instance_name,
})
})
.collect()
}
pub async fn delete_credential(
&self,
user_id: UserId,
server_id: &str,
) -> Result<bool, ProviderError> {
let repo = self.credential_repo.as_deref().ok_or_else(|| {
ProviderError::Internal("Twitch credential repository is unavailable".to_string())
})?;
let Some(credential) = repo
.get_by_provider_and_server(user_id, Self::NAME, server_id)
.await
.map_err(|error| ProviderError::Internal(error.to_string()))?
else {
return Ok(false);
};
repo.delete(credential.id)
.await
.map_err(|error| ProviderError::Internal(error.to_string()))?;
Ok(true)
}
pub async fn resolve_for_user(
&self,
user_id: UserId,
resource: &str,
provider_instance_name: Option<&str>,
) -> Result<(TwitchPlayback, TwitchMetadata), ProviderError> {
let resource = TwitchClient::parse_resource(resource)?;
let session = self.stored_session(user_id, provider_instance_name).await?;
Ok(tokio::try_join!(
self.client.playback(&resource, Some(&session)),
self.client.metadata(&resource, Some(&session)),
)?)
}
pub async fn list_channel_items_for_user(
&self,
user_id: UserId,
channel: &str,
kind: TwitchBrowseKind,
cursor: Option<&str>,
page_size: u32,
provider_instance_name: Option<&str>,
) -> Result<synctv_media_providers::twitch::TwitchBrowsePage, ProviderError> {
let session = self.stored_session(user_id, provider_instance_name).await?;
Ok(self
.client
.browse_channel(channel, kind, cursor, page_size, Some(&session))
.await?)
}
pub async fn list_followed_live_for_user(
&self,
user_id: UserId,
cursor: Option<&str>,
page_size: u32,
provider_instance_name: Option<&str>,
) -> Result<synctv_media_providers::twitch::TwitchStreamPage, ProviderError> {
let session = self.stored_session(user_id, provider_instance_name).await?;
Self::require_helix_session(&session)?;
Self::require_scope(&session, "user:read:follows")?;
Ok(self
.client
.followed_live(cursor, page_size, &session)
.await?)
}
pub async fn list_category_streams_for_user(
&self,
user_id: UserId,
category_id: &str,
cursor: Option<&str>,
page_size: u32,
provider_instance_name: Option<&str>,
) -> Result<synctv_media_providers::twitch::TwitchStreamPage, ProviderError> {
let session = self.stored_session(user_id, provider_instance_name).await?;
Self::require_helix_session(&session)?;
Ok(self
.client
.category_streams(category_id, cursor, page_size, &session)
.await?)
}
pub async fn list_top_categories_for_user(
&self,
user_id: UserId,
cursor: Option<&str>,
page_size: u32,
provider_instance_name: Option<&str>,
) -> Result<synctv_media_providers::twitch::TwitchCategoryPage, ProviderError> {
let session = self.stored_session(user_id, provider_instance_name).await?;
Self::require_helix_session(&session)?;
Ok(self
.client
.top_categories(cursor, page_size, &session)
.await?)
}
pub async fn search_live_channels_for_user(
&self,
user_id: UserId,
query: &str,
cursor: Option<&str>,
page_size: u32,
provider_instance_name: Option<&str>,
) -> Result<synctv_media_providers::twitch::TwitchChannelSearchPage, ProviderError> {
let session = self.stored_session(user_id, provider_instance_name).await?;
Self::require_helix_session(&session)?;
Ok(self
.client
.search_live_channels(query, cursor, page_size, &session)
.await?)
}
pub async fn schedule_for_user(
&self,
user_id: UserId,
broadcaster_id: &str,
cursor: Option<&str>,
page_size: u32,
provider_instance_name: Option<&str>,
) -> Result<synctv_media_providers::twitch::TwitchSchedulePage, ProviderError> {
let session = self.stored_session(user_id, provider_instance_name).await?;
Self::require_helix_session(&session)?;
Ok(self
.client
.schedule(broadcaster_id, cursor, page_size, &session)
.await?)
}
pub async fn get_resource(
&self,
store: Option<&Arc<dyn super::ProviderStore>>,
version: &str,
mode_name: &str,
media_index: usize,
request_context: Option<&super::ExecutionControl>,
range_header: Option<&str>,
) -> Result<super::PlaybackTransportAction, ProviderError> {
let versioned =
super::playback_transport::lookup_versioned(store, version, request_context).await?;
let media = versioned
.result
.playback_infos
.get(mode_name)
.and_then(|info| info.medias.get(media_index))
.ok_or(ProviderError::NotFound)?;
let PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Refresh {
resource_kind,
resource_id,
quality_name,
credential_owner_id,
provider_instance_name,
}) = &media.provider
else {
return Err(ProviderError::InvalidConfig(
"Twitch cached playback resource is invalid".to_string(),
));
};
let server_id = Self::credential_server_id_for_instance(provider_instance_name.as_deref());
let session = match credential_owner_id {
Some(owner_id) => {
let repo = self.credential_repo.as_deref().ok_or_else(|| {
ProviderError::Internal(
"Twitch credential repository is unavailable".to_string(),
)
})?;
self.session_for_owner(repo, *owner_id, &server_id).await?
}
None => TwitchSession::default(),
};
let resource = TwitchResource {
kind: match resource_kind {
TwitchPlaybackResourceKind::Channel => TwitchResourceKind::Channel,
TwitchPlaybackResourceKind::Video => TwitchResourceKind::Video,
TwitchPlaybackResourceKind::Clip => TwitchResourceKind::Clip,
},
id: resource_id.clone(),
};
let playback = self.client.playback(&resource, Some(&session)).await?;
let quality = playback
.qualities
.into_iter()
.find(|quality| quality.name == *quality_name)
.ok_or(ProviderError::NotFound)?;
super::playback_transport::transport_action_for_target_url(
quality.url,
HashMap::new(),
range_header,
)
}
pub fn get_segment(
&self,
target_url: String,
range_header: Option<&str>,
) -> Result<super::PlaybackTransportAction, ProviderError> {
super::playback_transport::transport_action_for_target_url(
target_url,
HashMap::new(),
range_header,
)
}
pub async fn watch_chat(
&self,
store: Option<&Arc<dyn super::ProviderStore>>,
version: &str,
mode_name: &str,
media_index: usize,
request_context: Option<&super::ExecutionControl>,
) -> Result<TwitchChatStream, ProviderError> {
let versioned =
super::playback_transport::lookup_versioned(store, version, request_context).await?;
let media = versioned
.result
.playback_infos
.get(mode_name)
.and_then(|info| info.medias.get(media_index))
.ok_or(ProviderError::NotFound)?;
let PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Refresh {
resource_kind: TwitchPlaybackResourceKind::Channel,
resource_id,
credential_owner_id,
provider_instance_name,
..
}) = &media.provider
else {
return Err(ProviderError::InvalidConfig(
"Twitch chat requires a live channel resource".to_string(),
));
};
let server_id = Self::credential_server_id_for_instance(provider_instance_name.as_deref());
let session = match credential_owner_id {
Some(owner_id) => {
let repo = self.credential_repo.as_deref().ok_or_else(|| {
ProviderError::Internal(
"Twitch credential repository is unavailable".to_string(),
)
})?;
self.session_for_owner(repo, *owner_id, &server_id).await?
}
None => TwitchSession::default(),
};
let stream =
synctv_media_providers::twitch::watch_chat(resource_id, Some(&session)).await?;
Ok(Box::pin(stream.map(|event| {
event
.map(|event| TwitchChatEvent {
id: event.id,
user_name: event.user_name,
text: event.text,
color: event.color,
badges: event.badges,
sent_at_ms: event.sent_at_ms,
})
.map_err(ProviderError::from)
})))
}
fn resource(config: &TwitchMediaSourceConfig) -> Result<TwitchResource, ProviderError> {
let (kind, id) = match config {
TwitchMediaSourceConfig::Live { channel, .. } => (TwitchResourceKind::Channel, channel),
TwitchMediaSourceConfig::Video { video_id, .. } => {
(TwitchResourceKind::Video, video_id)
}
TwitchMediaSourceConfig::Clip { slug, .. } => (TwitchResourceKind::Clip, slug),
};
if id.trim().is_empty() {
return Err(ProviderError::InvalidConfig(
"Twitch resource id is required".to_string(),
));
}
Ok(TwitchResource {
kind,
id: id.trim().to_string(),
})
}
fn twitch_config(
source_config: &MediaSourceConfig,
) -> Result<&TwitchMediaSourceConfig, ProviderError> {
match source_config {
MediaSourceConfig::Twitch(config) => Ok(config),
_ => Err(ProviderError::InvalidConfig(
"Twitch provider requires Twitch media source_config".to_string(),
)),
}
}
const fn media_credential_policy(config: &TwitchMediaSourceConfig) -> ProviderCredentialPolicy {
ProviderCredentialPolicy::from_shared(match config {
TwitchMediaSourceConfig::Live { shared, .. }
| TwitchMediaSourceConfig::Video { shared, .. }
| TwitchMediaSourceConfig::Clip { shared, .. } => *shared,
})
}
fn playlist_config(
source_config: &PlaylistSourceConfig,
) -> Result<&TwitchPlaylistSourceConfig, ProviderError> {
match source_config {
PlaylistSourceConfig::Twitch(config) => Ok(config),
_ => Err(ProviderError::InvalidConfig(
"Twitch provider requires Twitch playlist source_config".to_string(),
)),
}
}
fn browse_kind(content: TwitchPlaylistContent) -> TwitchBrowseKind {
match content {
TwitchPlaylistContent::Videos => TwitchBrowseKind::Videos,
TwitchPlaylistContent::Highlights => TwitchBrowseKind::Highlights,
TwitchPlaylistContent::Uploads => TwitchBrowseKind::Uploads,
TwitchPlaylistContent::Clips => TwitchBrowseKind::Clips,
}
}
fn require_helix_session(session: &TwitchSession) -> Result<(), ProviderError> {
if session.auth_token.as_deref().is_none_or(str::is_empty)
|| session.client_id.as_deref().is_none_or(str::is_empty)
{
return Err(ProviderError::CredentialRequired);
}
Ok(())
}
fn require_scope(session: &TwitchSession, scope: &str) -> Result<(), ProviderError> {
if session.scopes.iter().any(|value| value == scope) {
Ok(())
} else {
Err(ProviderError::Authentication(format!(
"Twitch OAuth scope {scope} is required"
)))
}
}
fn require_playlist_session(
config: &TwitchPlaylistSourceConfig,
session: &TwitchSession,
) -> Result<(), ProviderError> {
match config {
TwitchPlaylistSourceConfig::Channel { .. } => Ok(()),
TwitchPlaylistSourceConfig::FollowedLive { .. } => {
Self::require_helix_session(session)?;
Self::require_scope(session, "user:read:follows")
}
TwitchPlaylistSourceConfig::CategoryLive { .. }
| TwitchPlaylistSourceConfig::SearchLive { .. } => Self::require_helix_session(session),
}
}
const fn playlist_credential_policy(
config: &TwitchPlaylistSourceConfig,
) -> ProviderCredentialPolicy {
ProviderCredentialPolicy::from_shared(match config {
TwitchPlaylistSourceConfig::Channel { shared, .. }
| TwitchPlaylistSourceConfig::FollowedLive { shared }
| TwitchPlaylistSourceConfig::CategoryLive { shared, .. }
| TwitchPlaylistSourceConfig::SearchLive { shared, .. } => *shared,
})
}
fn encode_target(resource: &TwitchResource) -> Result<ProviderTarget, ProviderError> {
if resource.id.trim().is_empty() {
return Err(ProviderError::InvalidConfig(
"Twitch target id is required".to_string(),
));
}
let kind = match resource.kind {
TwitchResourceKind::Video => TwitchTargetKind::Video,
TwitchResourceKind::Clip => TwitchTargetKind::Clip,
TwitchResourceKind::Channel => TwitchTargetKind::Live,
};
Ok(ProviderTarget::twitch(kind, resource.id.clone()))
}
fn decode_target(target: &ProviderTarget) -> Result<TwitchResource, ProviderError> {
let ProviderTarget::Twitch(target) = target else {
return Err(ProviderError::InvalidConfig(
"Twitch playlist requires a Twitch target".to_string(),
));
};
if target.id.trim().is_empty() {
return Err(ProviderError::InvalidConfig(
"Twitch target id is required".to_string(),
));
}
Ok(TwitchResource {
kind: match target.kind {
TwitchTargetKind::Video => TwitchResourceKind::Video,
TwitchTargetKind::Clip => TwitchResourceKind::Clip,
TwitchTargetKind::Live => TwitchResourceKind::Channel,
},
id: target.id.clone(),
})
}
fn directory_item(item: TwitchBrowseItem) -> Result<DynamicPlaylistItem, ProviderError> {
let resource = item.resource.clone();
let metadata = PlaybackMetadata::Twitch(TwitchPlaybackMetadata {
resource_id: resource.id.clone(),
title: item.title.clone(),
author: String::new(),
category: None,
thumbnail_url: item.thumbnail_url.clone(),
description: None,
view_count: item.view_count,
published_at: item.published_at.clone(),
chapters: Vec::new(),
storyboard_url: None,
is_live: false,
is_currently_live: None,
});
let description = item.view_count.map(|views| format!("{views} views"));
Ok(DynamicPlaylistItem {
name: item.title,
item_type: ItemType::Media,
target: Self::encode_target(&item.resource)?,
size: None,
thumbnail: item.thumbnail_url.map(DynamicPlaylistItemThumbnail::Url),
description,
modified_at: item
.published_at
.as_deref()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp()),
source_config: None,
metadata: Some(metadata),
})
}
fn stream_directory_item(item: TwitchStreamItem) -> Result<DynamicPlaylistItem, ProviderError> {
let channel = item.channel.clone();
let metadata = PlaybackMetadata::Twitch(TwitchPlaybackMetadata {
resource_id: channel.clone(),
title: item.title.clone(),
author: item.display_name.clone(),
category: (!item.category_name.is_empty()).then_some(item.category_name.clone()),
thumbnail_url: (!item.thumbnail_url.is_empty()).then_some(item.thumbnail_url.clone()),
description: None,
view_count: Some(item.viewer_count),
published_at: Some(item.started_at.clone()),
chapters: Vec::new(),
storyboard_url: None,
is_live: true,
is_currently_live: Some(true),
});
let category = (!item.category_name.is_empty()).then_some(item.category_name);
let description = match category {
Some(category) => Some(format!("{category} · {} viewers", item.viewer_count)),
None => Some(format!("{} viewers", item.viewer_count)),
};
Ok(DynamicPlaylistItem {
name: item.title,
item_type: ItemType::Media,
target: Self::encode_target(&TwitchResource {
kind: TwitchResourceKind::Channel,
id: item.channel,
})?,
size: None,
thumbnail: (!item.thumbnail_url.is_empty())
.then_some(DynamicPlaylistItemThumbnail::Url(item.thumbnail_url)),
description,
modified_at: chrono::DateTime::parse_from_rfc3339(&item.started_at)
.ok()
.map(|value| value.timestamp()),
source_config: None,
metadata: Some(metadata),
})
}
fn next_item(
base: &TwitchPlaylistSourceConfig,
item: &DynamicPlaylistItem,
) -> Result<NextPlayItem, ProviderError> {
let resource = Self::decode_target(&item.target)?;
let source_config = match resource.kind {
TwitchResourceKind::Video => TwitchMediaSourceConfig::Video {
video_id: resource.id,
shared: Self::playlist_credential_policy(base).uses_resource_owner(),
},
TwitchResourceKind::Clip => TwitchMediaSourceConfig::Clip {
slug: resource.id,
shared: Self::playlist_credential_policy(base).uses_resource_owner(),
},
TwitchResourceKind::Channel => TwitchMediaSourceConfig::Live {
channel: resource.id,
shared: Self::playlist_credential_policy(base).uses_resource_owner(),
},
};
Ok(NextPlayItem {
name: item.name.clone(),
item_type: ItemType::Media,
source_config: MediaSourceConfig::Twitch(source_config),
target: item.target.clone(),
})
}
fn scan_ordered_page(
items: Vec<DynamicPlaylistItem>,
target: &ProviderTarget,
first: &mut Option<DynamicPlaylistItem>,
found_current: &mut bool,
) -> Option<DynamicPlaylistItem> {
for item in items {
first.get_or_insert_with(|| item.clone());
if *found_current {
return Some(item);
}
*found_current = item.target == *target;
}
None
}
fn metadata_model(metadata: TwitchMetadata, is_live_resource: bool) -> TwitchPlaybackMetadata {
TwitchPlaybackMetadata {
resource_id: metadata.id,
title: metadata.title,
author: metadata.author,
category: metadata.game,
thumbnail_url: metadata.thumbnail_url,
description: metadata.description,
view_count: metadata.view_count,
published_at: metadata.published_at,
chapters: metadata
.chapters
.into_iter()
.map(|chapter| TwitchChapterMetadata {
title: chapter.title,
start_seconds: chapter.start_seconds,
end_seconds: chapter.end_seconds,
})
.collect(),
storyboard_url: metadata.storyboard_url,
is_live: is_live_resource,
is_currently_live: is_live_resource.then_some(metadata.is_live),
}
}
fn playback_result(
playback: TwitchPlayback,
metadata: TwitchMetadata,
credential_owner_id: Option<UserId>,
provider_instance_name: Option<&str>,
) -> Result<PlaybackResult, ProviderError> {
let mut infos = HashMap::new();
for quality in playback.qualities {
let format = if quality.url.contains(".m3u8") {
"m3u8"
} else {
"mp4"
};
let mode = if format == "m3u8" { "hls" } else { format };
let info = infos
.entry(mode.to_string())
.or_insert_with(|| PlaybackInfo {
thumbnail: metadata.thumbnail_url.clone(),
medias: Vec::new(),
default_media_index: None,
subtitles: Vec::new(),
default_subtitle_index: None,
danmakus: (playback.resource.kind == TwitchResourceKind::Channel)
.then(|| PlaybackDanmaku {
name: "Twitch Chat".to_string(),
format: Some("synctv-twitch-live".to_string()),
p2p_swarm_id: None,
provider: PlaybackDanmakuProvider::Twitch(
PlaybackTwitchDanmaku::Refresh { media_index: 0 },
),
})
.into_iter()
.collect(),
default_danmaku_index: (playback.resource.kind == TwitchResourceKind::Channel)
.then_some(0),
});
let media_index = info.medias.len();
let is_source = quality.name.eq_ignore_ascii_case("chunked")
|| quality.name.to_ascii_lowercase().contains("source");
let resource_kind = match playback.resource.kind {
TwitchResourceKind::Channel => TwitchPlaybackResourceKind::Channel,
TwitchResourceKind::Video => TwitchPlaybackResourceKind::Video,
TwitchResourceKind::Clip => TwitchPlaybackResourceKind::Clip,
};
let p2p_swarm_id = (resource_kind != TwitchPlaybackResourceKind::Channel).then(|| {
super::provider_p2p_swarm_id(
Self::NAME,
provider_instance_name,
"media",
&format!(
"{}:{}:quality:{}",
twitch_resource_kind_name(resource_kind),
playback.resource.id,
quality.name
),
)
});
info.medias.push(PlaybackMedia {
name: quality.name.clone(),
format: format.to_string(),
expire_at: None,
metadata: Some(PlaybackMediaMetadata {
resolution: quality
.width
.zip(quality.height)
.map(|(width, height)| format!("{width}x{height}")),
bitrate: quality
.bandwidth
.and_then(|value| i64::try_from(value).ok()),
codec: quality.codecs,
fps: quality
.frame_rate
.as_deref()
.and_then(|value| value.parse::<f64>().ok())
.and_then(rounded_i32),
}),
p2p_swarm_id,
provider: PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Refresh {
resource_kind,
resource_id: playback.resource.id.clone(),
quality_name: quality.name.clone(),
credential_owner_id,
provider_instance_name: provider_instance_name.map(str::to_owned),
}),
});
if is_source || info.default_media_index.is_none() {
info.default_media_index = Some(media_index);
}
}
if infos.is_empty() {
return Err(ProviderError::ApiError(
"Twitch returned no playable qualities".to_string(),
));
}
let default_mode = infos
.contains_key("hls")
.then(|| "hls".to_string())
.or_else(|| infos.keys().min().cloned())
.ok_or_else(|| ProviderError::ApiError("Twitch playback is empty".to_string()))?;
let duration_seconds = metadata
.duration_seconds
.map(|value| std::time::Duration::from_secs(value).as_secs_f64());
let is_live_resource = playback.resource.kind == TwitchResourceKind::Channel;
Ok(PlaybackResult {
playback_infos: infos,
default_mode,
provider: crate::models::SourceProvider::Twitch,
provider_instance_name: None,
duration_seconds,
playback_kind: Some(if metadata.is_live {
crate::models::PlaybackKind::Live
} else {
crate::models::PlaybackKind::Regular
}),
metadata: Some(PlaybackMetadata::Twitch(Self::metadata_model(
metadata,
is_live_resource,
))),
})
}
}
const fn twitch_resource_kind_name(kind: TwitchPlaybackResourceKind) -> &'static str {
match kind {
TwitchPlaybackResourceKind::Channel => "channel",
TwitchPlaybackResourceKind::Video => "video",
TwitchPlaybackResourceKind::Clip => "clip",
}
}
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,
});
}
}
for danmaku in &mut info.danmakus {
let PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { media_index }) =
&danmaku.provider
else {
continue;
};
danmaku.provider = PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Proxy {
version: version.to_string(),
expires_at,
mode_name: mode_name.clone(),
media_index: *media_index,
});
}
}
}
#[allow(clippy::cast_possible_truncation)]
fn rounded_i32(value: f64) -> Option<i32> {
let rounded = value.round();
(rounded.is_finite() && rounded >= f64::from(i32::MIN) && rounded <= f64::from(i32::MAX))
.then_some(rounded as i32)
}
#[async_trait]
impl MediaProvider for TwitchProvider {
fn name(&self) -> &'static str {
Self::NAME
}
fn as_dynamic_playlist_provider(&self) -> Option<&dyn DynamicPlaylistProvider> {
Some(self)
}
async fn media_metadata(
&self,
ctx: &ProviderContext<'_>,
source_config: &MediaSourceConfig,
) -> Result<Option<PlaybackMetadata>, ProviderError> {
ctx.check_active()
.map_err(|error| ProviderError::NetworkError(error.to_string()))?;
let config = Self::twitch_config(source_config)?;
let resource = Self::resource(config)?;
let cache_key = serde_json::to_string(source_config)
.map_err(|error| ProviderError::Internal(error.to_string()))?;
let credential_policy = Self::media_credential_policy(config);
super::cached_provider_metadata_or_fill(
Self::NAME,
&cache_key,
if resource.kind == TwitchResourceKind::Channel {
Duration::from_secs(15)
} else {
Duration::from_secs(300)
},
ctx,
|| async {
let session = self.session(ctx, credential_policy).await?;
let metadata = self.client.metadata(&resource, Some(&session)).await?;
Ok(Some(PlaybackMetadata::Twitch(Self::metadata_model(
metadata,
resource.kind == TwitchResourceKind::Channel,
))))
},
)
.await
}
async fn generate_playback(
&self,
ctx: &ProviderContext<'_>,
source_config: &MediaSourceConfig,
) -> Result<PlaybackResult, ProviderError> {
ctx.check_active()
.map_err(|error| ProviderError::NetworkError(error.to_string()))?;
let config = Self::twitch_config(source_config)?;
let resource = Self::resource(config)?;
let credential_policy = Self::media_credential_policy(config);
let credential_owner_id = ctx.selected_credential_user_id(credential_policy);
if credential_policy.uses_resource_owner() && credential_owner_id.is_none() {
return Err(ProviderError::Internal(
"Twitch credential owner is unavailable".to_string(),
));
}
let session = self.session(ctx, credential_policy).await?;
let provider_instance_name =
super::bound_provider_instance_name(ctx).map(ToString::to_string);
let credential_server_id =
Self::credential_server_id_for_instance(provider_instance_name.as_deref());
let cache_key = format!(
"playback:{}:{}:{}:{}",
match resource.kind {
TwitchResourceKind::Channel => "channel",
TwitchResourceKind::Video => "video",
TwitchResourceKind::Clip => "clip",
},
resource.id,
credential_owner_id.map_or_else(|| "anonymous".to_string(), |id| id.to_string()),
credential_server_id,
);
Box::pin(super::cached_versioned_playback_or_fill(
Self::NAME,
&cache_key,
Duration::from_hours(6),
ctx,
mark_twitch_playback_resources,
|| async {
let (playback, metadata) = tokio::try_join!(
self.client.playback(&resource, Some(&session)),
self.client.metadata(&resource, Some(&session)),
)?;
Self::playback_result(
playback,
metadata,
credential_owner_id,
provider_instance_name.as_deref(),
)
},
))
.await
}
async fn validate_source_config(
&self,
ctx: &ProviderContext<'_>,
source_config: SourceConfig<'_>,
) -> Result<(), ProviderError> {
ctx.check_active()
.map_err(|error| ProviderError::NetworkError(error.to_string()))?;
match source_config {
SourceConfig::Media(source_config) => {
let config = Self::twitch_config(source_config)?;
let resource = Self::resource(config)?;
let session = self
.session(ctx, Self::media_credential_policy(config))
.await?;
if resource.kind == TwitchResourceKind::Channel {
self.client.metadata(&resource, Some(&session)).await?;
} else {
self.client.playback(&resource, Some(&session)).await?;
}
}
SourceConfig::DynamicPlaylist(source_config) => {
let config = Self::playlist_config(source_config)?;
let session = self
.session(ctx, Self::playlist_credential_policy(config))
.await?;
Self::require_playlist_session(config, &session)?;
match config {
TwitchPlaylistSourceConfig::Channel {
channel, content, ..
} => {
self.client
.browse_channel(
channel,
Self::browse_kind(*content),
None,
1,
Some(&session),
)
.await?;
}
TwitchPlaylistSourceConfig::FollowedLive { .. } => {
self.client.followed_live(None, 1, &session).await?;
}
TwitchPlaylistSourceConfig::CategoryLive { category_id, .. } => {
self.client
.category_streams(category_id, None, 1, &session)
.await?;
}
TwitchPlaylistSourceConfig::SearchLive { query, .. } => {
self.client
.search_live_channels(query, None, 1, &session)
.await?;
}
}
}
}
Ok(())
}
fn credential_dependencies(
&self,
ctx: &ProviderContext<'_>,
source_config: SourceConfig<'_>,
) -> Result<Vec<ProviderCredentialDependency>, ProviderError> {
let (credential_policy, credential_required) = match source_config {
SourceConfig::Media(config) => {
let policy = Self::media_credential_policy(Self::twitch_config(config)?);
(policy, policy.uses_resource_owner())
}
SourceConfig::DynamicPlaylist(config) => {
let config = Self::playlist_config(config)?;
(
Self::playlist_credential_policy(config),
!matches!(config, TwitchPlaylistSourceConfig::Channel { .. }),
)
}
};
let Some(owner_id) = ctx.selected_credential_user_id(credential_policy) else {
return Ok(Vec::new());
};
let server_id =
Self::credential_server_id_for_instance(super::bound_provider_instance_name(ctx));
Ok(vec![
if credential_policy.uses_resource_owner() || credential_required {
ProviderCredentialDependency::new(
crate::models::SourceProvider::Twitch,
owner_id,
server_id,
)
} else {
ProviderCredentialDependency::optional(
crate::models::SourceProvider::Twitch,
owner_id,
server_id,
)
},
])
}
async fn source_cover(
&self,
ctx: &ProviderContext<'_>,
source_config: SourceConfig<'_>,
) -> Result<Option<SourceCover>, ProviderError> {
ctx.check_active()
.map_err(|error| ProviderError::NetworkError(error.to_string()))?;
let (resource, credential_policy) = match source_config {
SourceConfig::Media(config) => {
let config = Self::twitch_config(config)?;
(
Some(Self::resource(config)?),
Self::media_credential_policy(config),
)
}
SourceConfig::DynamicPlaylist(config) => {
let config = Self::playlist_config(config)?;
let resource = match config {
TwitchPlaylistSourceConfig::Channel { channel, .. } => Some(TwitchResource {
kind: TwitchResourceKind::Channel,
id: channel.clone(),
}),
_ => None,
};
(resource, Self::playlist_credential_policy(config))
}
};
let session = self.session(ctx, credential_policy).await?;
if let SourceConfig::DynamicPlaylist(config) = source_config {
Self::require_playlist_session(Self::playlist_config(config)?, &session)?;
}
if let Some(resource) = resource {
return Ok(self
.client
.metadata(&resource, Some(&session))
.await?
.thumbnail_url
.map(|url| SourceCover::Url { url }));
}
let config = match source_config {
SourceConfig::DynamicPlaylist(config) => Self::playlist_config(config)?,
SourceConfig::Media(_) => unreachable!(),
};
let cover = match config {
TwitchPlaylistSourceConfig::FollowedLive { .. } => self
.client
.followed_live(None, 1, &session)
.await?
.items
.into_iter()
.next()
.map(|item| item.thumbnail_url),
TwitchPlaylistSourceConfig::CategoryLive { category_id, .. } => self
.client
.category_streams(category_id, None, 1, &session)
.await?
.items
.into_iter()
.next()
.map(|item| item.thumbnail_url),
TwitchPlaylistSourceConfig::SearchLive { query, .. } => self
.client
.search_live_channels(query, None, 1, &session)
.await?
.items
.into_iter()
.next()
.map(|item| item.thumbnail_url),
TwitchPlaylistSourceConfig::Channel { .. } => None,
};
Ok(cover.map(|url| SourceCover::Url { url }))
}
}
#[async_trait]
impl DynamicPlaylistProvider for TwitchProvider {
async fn list_playlist(
&self,
ctx: &ProviderContext<'_>,
playlist: &crate::models::Playlist,
target: Option<&ProviderTarget>,
query: DynamicListQuery,
) -> Result<DynamicListResult, ProviderError> {
ctx.check_active()
.map_err(|error| ProviderError::NetworkError(error.to_string()))?;
if target.is_some() {
return Err(ProviderError::InvalidConfig(
"Twitch channel playlists have a single browse level".to_string(),
));
}
if query
.search
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
return Err(ProviderError::InvalidConfig(
"Twitch channel playlists do not expose server-side search".to_string(),
));
}
let config = playlist
.source_config
.as_ref()
.ok_or_else(|| ProviderError::InvalidConfig("Missing source_config".to_string()))?;
let config = Self::playlist_config(config)?;
let session = self
.session(ctx, Self::playlist_credential_policy(config))
.await?;
Self::require_playlist_session(config, &session)?;
let cursor = match &query.pagination {
DynamicPagination::Cursor { cursor } => cursor.as_deref(),
DynamicPagination::Page { page: 1 } => None,
DynamicPagination::Page { .. } => {
return Err(ProviderError::InvalidConfig(
"Twitch requires cursor pagination after the first page".to_string(),
));
}
};
let page_size = u32::try_from(query.page_size.max(1)).map_err(|_| {
ProviderError::InvalidConfig("Twitch page size exceeds u32::MAX".to_string())
})?;
let (items, next_cursor) = match config {
TwitchPlaylistSourceConfig::Channel {
channel, content, ..
} => {
let page = self
.client
.browse_channel(
channel,
Self::browse_kind(*content),
cursor,
page_size,
Some(&session),
)
.await?;
(
page.items
.into_iter()
.map(Self::directory_item)
.collect::<Result<Vec<_>, _>>()?,
page.next_cursor,
)
}
TwitchPlaylistSourceConfig::FollowedLive { .. } => {
let page = self
.client
.followed_live(cursor, page_size, &session)
.await?;
(
page.items
.into_iter()
.map(Self::stream_directory_item)
.collect::<Result<Vec<_>, _>>()?,
page.next_cursor,
)
}
TwitchPlaylistSourceConfig::CategoryLive { category_id, .. } => {
let page = self
.client
.category_streams(category_id, cursor, page_size, &session)
.await?;
(
page.items
.into_iter()
.map(Self::stream_directory_item)
.collect::<Result<Vec<_>, _>>()?,
page.next_cursor,
)
}
TwitchPlaylistSourceConfig::SearchLive { query, .. } => {
let page = self
.client
.search_live_channels(query, cursor, page_size, &session)
.await?;
let items = page
.items
.into_iter()
.filter(|item| item.is_live)
.map(|item| {
Self::stream_directory_item(TwitchStreamItem {
stream_id: String::new(),
user_id: item.user_id,
channel: item.channel,
display_name: item.display_name,
title: item.title,
category_id: item.category_id,
category_name: item.category_name,
thumbnail_url: item.thumbnail_url,
viewer_count: 0,
started_at: item.started_at,
language: item.language,
tags: item.tags,
is_mature: false,
})
})
.collect::<Result<Vec<_>, _>>()?;
(items, page.next_cursor)
}
};
let has_more = next_cursor.is_some();
Ok(DynamicListResult {
items,
pagination: DynamicPagination::Cursor {
cursor: next_cursor,
},
has_more,
supports_search: false,
})
}
async fn resolve_item(
&self,
ctx: &ProviderContext<'_>,
playlist: &crate::models::Playlist,
target: &ProviderTarget,
) -> Result<Option<NextPlayItem>, ProviderError> {
Self::decode_target(target)?;
let base =
Self::playlist_config(playlist.source_config.as_ref().ok_or_else(|| {
ProviderError::InvalidConfig("Missing source_config".to_string())
})?)?;
let mut cursor = None;
loop {
let result = self
.list_playlist(
ctx,
playlist,
None,
DynamicListQuery {
pagination: DynamicPagination::Cursor { cursor },
page_size: DYNAMIC_PAGE_SIZE,
..DynamicListQuery::default()
},
)
.await?;
if let Some(item) = result.items.iter().find(|item| &item.target == target) {
return Self::next_item(base, item).map(Some);
}
match result.pagination {
DynamicPagination::Cursor { cursor: Some(next) } if result.has_more => {
cursor = Some(next);
}
_ => return Ok(None),
}
}
}
async fn next(
&self,
ctx: &ProviderContext<'_>,
playlist: &crate::models::Playlist,
target: &ProviderTarget,
play_mode: PlayMode,
) -> Result<Option<NextPlayItem>, ProviderError> {
if play_mode == PlayMode::RepeatOne {
return Ok(None);
}
Self::decode_target(target)?;
let base =
Self::playlist_config(playlist.source_config.as_ref().ok_or_else(|| {
ProviderError::InvalidConfig("Missing source_config".to_string())
})?)?;
let mut cursor = None;
let mut first = None;
let mut found_current = false;
let mut shuffle = Vec::new();
loop {
let result = self
.list_playlist(
ctx,
playlist,
None,
DynamicListQuery {
pagination: DynamicPagination::Cursor { cursor },
page_size: DYNAMIC_PAGE_SIZE,
..DynamicListQuery::default()
},
)
.await?;
let next_cursor = match &result.pagination {
DynamicPagination::Cursor { cursor } if result.has_more => cursor.clone(),
_ => None,
};
if play_mode == PlayMode::Shuffle {
for item in result.items {
first.get_or_insert_with(|| item.clone());
if item.target != *target && shuffle.len() < DYNAMIC_SHUFFLE_LIMIT {
shuffle.push(item);
}
}
} else if let Some(item) =
Self::scan_ordered_page(result.items, target, &mut first, &mut found_current)
{
return Self::next_item(base, &item).map(Some);
}
let Some(next) = next_cursor else {
break;
};
if play_mode == PlayMode::Shuffle && shuffle.len() >= DYNAMIC_SHUFFLE_LIMIT {
break;
}
cursor = Some(next);
}
let selected = match play_mode {
PlayMode::RepeatAll if found_current => first.as_ref(),
PlayMode::Shuffle => shuffle
.get(rand::random_range(0..shuffle.len().max(1)))
.or(first.as_ref()),
PlayMode::Sequential | PlayMode::RepeatAll | PlayMode::RepeatOne => None,
};
selected.map(|item| Self::next_item(base, item)).transpose()
}
}
#[cfg(test)]
mod tests {
use super::*;
use synctv_media_providers::twitch::{TwitchQuality, TwitchResource};
#[test]
fn provider_exposes_dynamic_playlist_capability() {
crate::install_process_crypto_provider();
let provider = TwitchProvider::new();
assert!(provider.as_dynamic_playlist_provider().is_some());
}
fn item(kind: TwitchTargetKind, id: &str) -> DynamicPlaylistItem {
DynamicPlaylistItem {
name: id.to_string(),
item_type: ItemType::Media,
target: ProviderTarget::twitch(kind, id.to_string()),
size: None,
thumbnail: None,
description: None,
modified_at: None,
source_config: None,
metadata: None,
}
}
#[test]
fn sequential_scan_advances_across_cursor_page_boundary() {
let target = ProviderTarget::twitch(TwitchTargetKind::Video, "2".to_string());
let mut first = None;
let mut found_current = false;
assert!(TwitchProvider::scan_ordered_page(
vec![
item(TwitchTargetKind::Video, "1"),
item(TwitchTargetKind::Video, "2"),
],
&target,
&mut first,
&mut found_current,
)
.is_none());
assert!(found_current);
let next = TwitchProvider::scan_ordered_page(
vec![
item(TwitchTargetKind::Video, "3"),
item(TwitchTargetKind::Video, "4"),
],
&target,
&mut first,
&mut found_current,
)
.expect("the first item on the next cursor page should be selected");
assert_eq!(
next.target,
ProviderTarget::twitch(TwitchTargetKind::Video, "3".to_string())
);
}
#[test]
fn repeat_all_scan_preserves_first_item_for_cursor_wrap() {
let target = ProviderTarget::twitch(TwitchTargetKind::Clip, "4".to_string());
let mut first = None;
let mut found_current = false;
assert!(TwitchProvider::scan_ordered_page(
vec![
item(TwitchTargetKind::Clip, "1"),
item(TwitchTargetKind::Clip, "2"),
],
&target,
&mut first,
&mut found_current,
)
.is_none());
assert!(TwitchProvider::scan_ordered_page(
vec![
item(TwitchTargetKind::Clip, "3"),
item(TwitchTargetKind::Clip, "4"),
],
&target,
&mut first,
&mut found_current,
)
.is_none());
assert!(found_current);
assert_eq!(
first
.expect("repeat-all should retain the first item")
.target,
ProviderTarget::twitch(TwitchTargetKind::Clip, "1".to_string())
);
}
#[test]
fn playlist_target_preserves_twitch_resource_kind() {
let video = TwitchResource {
kind: TwitchResourceKind::Video,
id: "1234".to_string(),
};
let clip = TwitchResource {
kind: TwitchResourceKind::Clip,
id: "1234".to_string(),
};
let video_target =
TwitchProvider::encode_target(&video).expect("test operation should succeed");
let clip_target =
TwitchProvider::encode_target(&clip).expect("test operation should succeed");
assert_ne!(video_target, clip_target);
assert_eq!(
TwitchProvider::decode_target(&video_target).expect("test operation should succeed"),
video
);
assert_eq!(
TwitchProvider::decode_target(&clip_target).expect("test operation should succeed"),
clip
);
}
#[test]
fn playlist_item_builds_provider_specific_media_source() {
let base = TwitchPlaylistSourceConfig::Channel {
channel: "synctv".to_string(),
content: TwitchPlaylistContent::Clips,
shared: true,
};
let item = DynamicPlaylistItem {
name: "A clip".to_string(),
item_type: ItemType::Media,
target: ProviderTarget::twitch(TwitchTargetKind::Clip, "ClipSlug".to_string()),
size: None,
thumbnail: None,
description: None,
modified_at: None,
source_config: None,
metadata: None,
};
let next = TwitchProvider::next_item(&base, &item).expect("test operation should succeed");
assert_eq!(next.target, item.target);
assert!(matches!(
next.source_config,
MediaSourceConfig::Twitch(TwitchMediaSourceConfig::Clip {
slug,
shared: true,
}) if slug == "ClipSlug"
));
}
#[test]
fn live_playlist_item_builds_live_media_source() {
let base = TwitchPlaylistSourceConfig::CategoryLive {
category_id: "game-1".to_string(),
category_name: "Development".to_string(),
shared: true,
};
let item = DynamicPlaylistItem {
name: "Live channel".to_string(),
item_type: ItemType::Media,
target: ProviderTarget::twitch(TwitchTargetKind::Live, "synctv".to_string()),
size: None,
thumbnail: None,
description: None,
modified_at: None,
source_config: None,
metadata: None,
};
let next = TwitchProvider::next_item(&base, &item).expect("test operation should succeed");
assert!(matches!(
next.source_config,
MediaSourceConfig::Twitch(TwitchMediaSourceConfig::Live {
channel,
shared: true,
}) if channel == "synctv"
));
}
#[test]
fn playback_groups_twitch_qualities_into_one_hls_mode() {
let mut result = TwitchProvider::playback_result(
TwitchPlayback {
resource: TwitchResource {
kind: TwitchResourceKind::Channel,
id: "synctv".to_string(),
},
master_url: Some("https://usher.example.test/master.m3u8".to_string()),
qualities: vec![
TwitchQuality {
name: "chunked".to_string(),
url: "https://video.example.test/source.m3u8".to_string(),
bandwidth: Some(6_000_000),
width: Some(1920),
height: Some(1080),
frame_rate: Some("60".to_string()),
codecs: Some("avc1.64002A".to_string()),
},
TwitchQuality {
name: "720p60".to_string(),
url: "https://video.example.test/720.m3u8".to_string(),
bandwidth: Some(3_000_000),
width: Some(1280),
height: Some(720),
frame_rate: Some("60".to_string()),
codecs: None,
},
],
token: None,
},
TwitchMetadata {
id: "synctv".to_string(),
title: "SyncTV Live".to_string(),
author: "SyncTV".to_string(),
game: Some("Software and Game Development".to_string()),
thumbnail_url: Some("https://image.example.test/live.jpg".to_string()),
is_live: true,
description: None,
duration_seconds: None,
view_count: Some(42),
published_at: None,
chapters: Vec::new(),
storyboard_url: None,
},
Some(UserId::expect_positive(1)),
None,
)
.expect("playback should map");
assert_eq!(result.default_mode, "hls");
assert_eq!(result.playback_infos.len(), 1);
assert_eq!(
result.playback_kind,
Some(crate::models::PlaybackKind::Live)
);
let source = result
.playback_infos
.get("hls")
.expect("test operation should succeed");
assert_eq!(source.medias.len(), 2);
assert_eq!(source.default_media_index, Some(0));
assert!(source
.medias
.iter()
.all(|media| media.p2p_swarm_id.is_none()));
assert!(source.danmakus[0].p2p_swarm_id.is_none());
assert!(matches!(
source.medias[0].provider,
PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Refresh { .. })
));
assert!(matches!(
source.danmakus[0].provider,
PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Refresh { .. })
));
mark_twitch_playback_resources(&mut result, "version-1", 12345);
let source = result
.playback_infos
.get("hls")
.expect("test operation should succeed");
assert!(source
.medias
.iter()
.all(|media| media.p2p_swarm_id.is_none()));
assert!(source.danmakus[0].p2p_swarm_id.is_none());
assert!(matches!(
source.medias[0].provider,
PlaybackMediaProvider::Twitch(PlaybackTwitchMedia::Proxy {
ref version,
expires_at: 12345,
..
}) if version == "version-1"
));
assert!(matches!(
source.danmakus[0].provider,
PlaybackDanmakuProvider::Twitch(PlaybackTwitchDanmaku::Proxy { .. })
));
}
}