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-media-providers/src/emby/service.rs

394 lines
14 KiB
Rust

//! Emby Service - Complete implementation
//!
//! This is the full HTTP client implementation.
//! Both gRPC server and local usage call this service.
use super::{client::EmbyClient, types::UserInfo, EmbyError};
use crate::transport_dto::emby::{
DeleteActiveEncodingsReq, Empty, FsListReq, FsListResp, GetItemReq, GetItemsReq, GetItemsResp,
Item, LoginReq, LoginResp, LogoutReq, MeReq, MeResp, PlaybackInfoReq, PlaybackInfoResp,
ReportPlaybackProgressReq, ReportPlaybackStartReq, ReportPlaybackStopReq, SystemInfoReq,
SystemInfoResp,
};
use async_trait::async_trait;
use reqwest::{Client, StatusCode};
/// Map an empty string to `None`, otherwise borrow it as `Some(&str)`.
fn opt_str(s: &str) -> Option<&str> {
(!s.is_empty()).then_some(s)
}
fn current_user_unavailable_for_token(error: &EmbyError) -> bool {
matches!(
error,
EmbyError::Http { status, url, .. }
if *status == StatusCode::BAD_REQUEST && url.ends_with("/Users/Me")
) || matches!(
error,
EmbyError::InvalidConfig(message)
if message.contains("/Users/Me")
)
}
fn username_matches(candidate_name: &str, requested_name: &str) -> bool {
candidate_name == requested_name || candidate_name.eq_ignore_ascii_case(requested_name)
}
fn select_user_by_username<'a>(
users: &'a [UserInfo],
username: &str,
) -> Result<&'a UserInfo, EmbyError> {
let trimmed = username.trim();
if trimmed.is_empty() {
return Err(EmbyError::InvalidConfig(
"username must not be empty".to_string(),
));
}
let matches: Vec<&UserInfo> = users
.iter()
.filter(|user| username_matches(&user.name, trimmed))
.collect();
match matches.as_slice() {
[user] => Ok(*user),
[] => Err(EmbyError::InvalidConfig(format!(
"No Emby/Jellyfin user found for username '{trimmed}'"
))),
_ => Err(EmbyError::InvalidConfig(format!(
"Multiple Emby/Jellyfin users matched username '{trimmed}'"
))),
}
}
/// Unified Emby service interface
///
/// This trait defines all Emby operations using provider transport DTOs.
#[async_trait]
pub trait EmbyInterface: Send + Sync {
async fn login(&self, request: LoginReq) -> Result<LoginResp, EmbyError>;
async fn me(&self, request: MeReq) -> Result<MeResp, EmbyError>;
async fn get_items(&self, request: GetItemsReq) -> Result<GetItemsResp, EmbyError>;
async fn get_item(&self, request: GetItemReq) -> Result<Item, EmbyError>;
async fn fs_list(&self, request: FsListReq) -> Result<FsListResp, EmbyError>;
async fn get_system_info(&self, request: SystemInfoReq) -> Result<SystemInfoResp, EmbyError>;
async fn logout(&self, request: LogoutReq) -> Result<Empty, EmbyError>;
async fn playback_info(&self, request: PlaybackInfoReq) -> Result<PlaybackInfoResp, EmbyError>;
async fn delete_active_encodings(
&self,
request: DeleteActiveEncodingsReq,
) -> Result<Empty, EmbyError>;
async fn report_playback_start(
&self,
request: ReportPlaybackStartReq,
) -> Result<Empty, EmbyError>;
async fn report_playback_stop(
&self,
request: ReportPlaybackStopReq,
) -> Result<Empty, EmbyError>;
async fn report_playback_progress(
&self,
request: ReportPlaybackProgressReq,
) -> Result<Empty, EmbyError>;
}
/// Emby service implementation
///
/// This is the complete implementation that makes actual HTTP calls.
/// Used by both local callers and gRPC server.
pub struct EmbyService {
client: Client,
}
impl EmbyService {
pub fn new() -> Result<Self, reqwest::Error> {
let client =
crate::build_provider_http_client(synctv_common::ssrf::SsrfGuard::strict_policy())?;
Ok(Self { client })
}
#[must_use]
pub const fn with_client(client: Client) -> Self {
Self { client }
}
fn anonymous_client(&self, host: &str) -> Result<EmbyClient, EmbyError> {
EmbyClient::with_http_client(host, self.client.clone())
}
fn authenticated_client(
&self,
host: &str,
token: &str,
user_id: &str,
) -> Result<EmbyClient, EmbyError> {
EmbyClient::with_credentials_and_http_client(host, token, user_id, self.client.clone())
}
fn system_client(&self, host: &str, token: &str) -> Result<EmbyClient, EmbyError> {
self.authenticated_client(host, token, "")
}
}
#[async_trait]
impl EmbyInterface for EmbyService {
async fn login(&self, request: LoginReq) -> Result<LoginResp, EmbyError> {
let username = request.username.trim();
if username.is_empty() {
return Err(EmbyError::InvalidConfig(
"username must not be empty".to_string(),
));
}
match request.credential {
Some(crate::transport_dto::emby::login_req::Credential::Password(password)) => {
let mut client = self.anonymous_client(&request.host)?;
let (token, user_id) = client.login(username, &password).await?;
let user_info = client.me().await?;
if !username_matches(&user_info.name, username) {
return Err(EmbyError::InvalidConfig(format!(
"authenticated Emby/Jellyfin user '{}' does not match requested username '{}'",
user_info.name, username
)));
}
Ok(LoginResp {
token,
user_id,
username: user_info.name,
server_id: user_info.server_id,
policy: user_info.policy.map(Into::into),
})
}
Some(crate::transport_dto::emby::login_req::Credential::ApiKey(api_key)) => {
let api_key = api_key.trim();
if api_key.is_empty() {
return Err(EmbyError::InvalidConfig(
"api_key must not be empty".to_string(),
));
}
let client = self.system_client(&request.host, api_key)?;
let selected_user = match client.me().await {
Ok(user_info) => {
if !username_matches(&user_info.name, username) {
return Err(EmbyError::InvalidConfig(format!(
"API key authenticated as Emby/Jellyfin user '{}' instead of requested username '{}'",
user_info.name, username
)));
}
user_info
}
Err(error) if current_user_unavailable_for_token(&error) => {
let users = client.list_users().await?;
(*select_user_by_username(&users, username)?).clone()
}
Err(error) => return Err(error),
};
Ok(LoginResp {
token: api_key.to_string(),
user_id: selected_user.id.clone(),
username: selected_user.name,
server_id: selected_user.server_id,
policy: selected_user.policy.map(Into::into),
})
}
None => Err(EmbyError::InvalidConfig(
"exactly one of password or api_key must be provided".to_string(),
)),
}
}
async fn me(&self, request: MeReq) -> Result<MeResp, EmbyError> {
let client = self.authenticated_client(&request.host, &request.token, &request.user_id)?;
let user_info = client.me().await?;
Ok(user_info.into())
}
async fn get_items(&self, request: GetItemsReq) -> Result<GetItemsResp, EmbyError> {
let client = self.authenticated_client(&request.host, &request.token, &request.user_id)?;
let parent_id = opt_str(&request.parent_id);
let search_term = opt_str(&request.search_term);
let items_response = client.get_items(parent_id, search_term).await?;
Ok(items_response.into())
}
async fn get_item(&self, request: GetItemReq) -> Result<Item, EmbyError> {
let client = self.authenticated_client(&request.host, &request.token, &request.user_id)?;
let item = client.get_item(&request.item_id).await?;
Ok(item.into())
}
async fn fs_list(&self, request: FsListReq) -> Result<FsListResp, EmbyError> {
use crate::transport_dto::emby::fs_list_req::Source;
let client = self.authenticated_client(&request.host, &request.token, &request.user_id)?;
let search_term = opt_str(&request.search_term);
let source = match request
.source
.ok_or_else(|| EmbyError::InvalidConfig("Emby list source is required".to_string()))?
{
Source::Folder(source) => super::EmbyListSource::Folder(
(!source.parent_id.is_empty()).then_some(source.parent_id),
),
Source::FavoriteItems(source) => {
super::EmbyListSource::FavoriteItems(source.item_types)
}
Source::FavoritePeople(_) => super::EmbyListSource::FavoritePeople,
Source::PersonItems(source) => super::EmbyListSource::PersonItems {
person_id: source.person_id,
item_types: source.item_types,
},
Source::ContinueWatching(_) => super::EmbyListSource::ContinueWatching,
Source::NextUp(_) => super::EmbyListSource::NextUp,
Source::RecentlyAdded(source) => {
super::EmbyListSource::RecentlyAdded(source.item_types)
}
Source::Playlists(_) => super::EmbyListSource::Playlists,
Source::Collections(_) => super::EmbyListSource::Collections,
Source::Genres(source) => super::EmbyListSource::Genres(source.item_types),
Source::GenreItems(source) => super::EmbyListSource::GenreItems {
genre_id: source.genre_id,
item_types: source.item_types,
},
};
let fs_response = client
.fs_list(source, request.start_index, request.limit, search_term)
.await?;
Ok(fs_response.into())
}
async fn get_system_info(&self, request: SystemInfoReq) -> Result<SystemInfoResp, EmbyError> {
let client = self.system_client(&request.host, &request.token)?;
let info = client.get_system_info().await?;
Ok(info.into())
}
async fn logout(&self, request: LogoutReq) -> Result<Empty, EmbyError> {
let client = self.system_client(&request.host, &request.token)?;
client.logout().await?;
Ok(Empty {})
}
async fn playback_info(&self, request: PlaybackInfoReq) -> Result<PlaybackInfoResp, EmbyError> {
let client = self.authenticated_client(&request.host, &request.token, &request.user_id)?;
let media_source_id = opt_str(&request.media_source_id);
let audio_idx = if request.audio_stream_index < 0 {
None
} else {
Some(request.audio_stream_index)
};
let subtitle_idx = if request.subtitle_stream_index < 0 {
None
} else {
Some(request.subtitle_stream_index)
};
let max_bitrate = if request.max_streaming_bitrate == 0 {
None
} else {
Some(request.max_streaming_bitrate)
};
let playback_info = client
.get_playback_info(super::client::PlaybackInfoRequest {
item_id: &request.item_id,
media_source_id,
audio_stream_index: audio_idx,
subtitle_stream_index: subtitle_idx,
max_streaming_bitrate: max_bitrate,
max_audio_channels: request.max_audio_channels,
enable_direct_play: request.enable_direct_play,
enable_direct_stream: request.enable_direct_stream,
enable_transcoding: request.enable_transcoding,
device_profile: request.device_profile.as_ref(),
})
.await?;
Ok(playback_info.into())
}
async fn delete_active_encodings(
&self,
request: DeleteActiveEncodingsReq,
) -> Result<Empty, EmbyError> {
let client = self.system_client(&request.host, &request.token)?;
client
.delete_active_encodings(&request.play_session_id)
.await?;
Ok(Empty {})
}
async fn report_playback_start(
&self,
request: ReportPlaybackStartReq,
) -> Result<Empty, EmbyError> {
let client = self.system_client(&request.host, &request.token)?;
let media_source_id = opt_str(&request.media_source_id);
client
.report_playback_start(
&request.item_id,
&request.play_session_id,
media_source_id,
request.position_ticks,
)
.await?;
Ok(Empty {})
}
async fn report_playback_stop(
&self,
request: ReportPlaybackStopReq,
) -> Result<Empty, EmbyError> {
let client = self.system_client(&request.host, &request.token)?;
client
.report_playback_stop(
&request.item_id,
&request.play_session_id,
request.position_ticks,
)
.await?;
Ok(Empty {})
}
async fn report_playback_progress(
&self,
request: ReportPlaybackProgressReq,
) -> Result<Empty, EmbyError> {
let client = self.system_client(&request.host, &request.token)?;
let media_source_id = opt_str(&request.media_source_id);
client
.report_playback_progress(
&request.item_id,
&request.play_session_id,
media_source_id,
request.position_ticks,
request.is_paused,
)
.await?;
Ok(Empty {})
}
}