Files
k-tv/crates/adapters/jellyfin/src/provider.rs
Gabriel Kaszewski 39f5f99bfd refactor: replace get_stream_url with get_source_uri per ADR-0002
Providers now expose a source URI (for FFmpeg) instead of a
viewer-facing stream URL. Playout Service will own transcoding.

- Add SourceUri value object (NetworkUrl | FilePath)
- Remove StreamQuality, StreamingProtocol from domain
- Simplify ProviderCapabilities (drop streaming_protocol, transcode)
- Jellyfin: return static direct stream URL
- Local files: return absolute file path
- Rename use case get_stream_url -> get_source
- Stream endpoint returns JSON SourceUri instead of 307 redirect
2026-07-12 07:34:13 +02:00

345 lines
11 KiB
Rust

use async_trait::async_trait;
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary};
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, SourceUri};
use crate::config::JellyfinConfig;
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
use crate::models::{jellyfin_item_type, JellyfinItemsResponse};
pub struct JellyfinMediaProvider {
client: reqwest::Client,
config: JellyfinConfig,
}
impl JellyfinMediaProvider {
pub fn new(config: JellyfinConfig) -> Self {
Self {
client: reqwest::Client::new(),
config: JellyfinConfig {
base_url: config.base_url.trim_end_matches('/').to_string(),
..config
},
}
}
async fn fetch_items_for_series(
&self,
filter: &MediaFilter,
series_name: Option<&str>,
) -> DomainResult<Vec<MediaItem>> {
let url = format!(
"{}/Users/{}/Items",
self.config.base_url, self.config.user_id
);
let mut params: Vec<(&str, String)> = vec![
("Recursive", "true".into()),
(
"Fields",
"Genres,Tags,RunTimeTicks,ProductionYear,Overview".into(),
),
];
if let Some(ct) = &filter.content_type {
params.push(("IncludeItemTypes", jellyfin_item_type(ct).into()));
}
if !filter.genres.is_empty() {
params.push(("Genres", filter.genres.join("|")));
}
if let Some(decade) = filter.decade {
params.push(("MinYear", decade.to_string()));
params.push(("MaxYear", (decade + 9).to_string()));
}
if !filter.tags.is_empty() {
params.push(("Tags", filter.tags.join("|")));
}
if let Some(min) = filter.min_duration_secs {
params.push(("MinRunTimeTicks", (min as i64 * TICKS_PER_SEC).to_string()));
}
if let Some(max) = filter.max_duration_secs {
params.push(("MaxRunTimeTicks", (max as i64 * TICKS_PER_SEC).to_string()));
}
if let Some(name) = series_name {
params.push(("SeriesName", name.to_string()));
params.push(("SortBy", "ParentIndexNumber,IndexNumber".into()));
params.push(("SortOrder", "Ascending".into()));
if filter.content_type.is_none() {
params.push(("IncludeItemTypes", "Episode".into()));
}
} else {
if let Some(parent_id) = filter.collections.first() {
params.push(("ParentId", parent_id.clone()));
}
}
if let Some(q) = &filter.search_term {
params.push(("SearchTerm", q.clone()));
}
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&params)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
// WHY: Jellyfin's SeriesName query param is a fuzzy match that can return
// items from other shows; post-filter to guarantee correctness.
let items = body.items.into_iter().filter_map(map_jellyfin_item);
let items: Vec<MediaItem> = if let Some(name) = series_name {
items
.filter(|item| {
item.series_name()
.map(|s| s.eq_ignore_ascii_case(name))
.unwrap_or(false)
})
.collect()
} else {
items.collect()
};
Ok(items)
}
}
#[async_trait]
impl IMediaProvider for JellyfinMediaProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
collections: true,
series: true,
genres: true,
tags: true,
decade: true,
search: true,
rescan: false,
}
}
async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
match filter.series_names.len() {
0 | 1 => {
let series = filter.series_names.first().map(String::as_str);
self.fetch_items_for_series(filter, series).await
}
_ => {
let mut per_series: Vec<Vec<MediaItem>> = Vec::new();
for series_name in &filter.series_names {
let items = self
.fetch_items_for_series(filter, Some(series_name.as_str()))
.await?;
if !items.is_empty() {
per_series.push(items);
}
}
let max_len = per_series.iter().map(|s| s.len()).max().unwrap_or(0);
let mut all = Vec::with_capacity(per_series.iter().map(|s| s.len()).sum());
for i in 0..max_len {
for s in &per_series {
if let Some(item) = s.get(i) {
all.push(item.clone());
}
}
}
Ok(all)
}
}
}
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
let url = format!(
"{}/Users/{}/Items",
self.config.base_url, self.config.user_id
);
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&[
("Ids", item_id.as_ref()),
("Fields", "Genres,Tags,RunTimeTicks,ProductionYear"),
])
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Ok(None);
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body.items.into_iter().next().and_then(map_jellyfin_item))
}
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
let url = format!(
"{}/Users/{}/Views",
self.config.base_url, self.config.user_id
);
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body
.items
.into_iter()
.map(|item| Collection {
id: item.id,
name: item.name,
collection_type: item.collection_type,
})
.collect())
}
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
let url = format!(
"{}/Users/{}/Items",
self.config.base_url, self.config.user_id
);
let mut params: Vec<(&str, String)> = vec![
("Recursive", "true".into()),
("IncludeItemTypes", "Series".into()),
(
"Fields",
"Genres,ProductionYear,RecursiveItemCount".into(),
),
("SortBy", "SortName".into()),
("SortOrder", "Ascending".into()),
];
if let Some(id) = collection_id {
params.push(("ParentId", id.to_string()));
}
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&params)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body
.items
.into_iter()
.map(|item| SeriesSummary {
id: item.id,
name: item.name,
episode_count: item.recursive_item_count.unwrap_or(0),
genres: item.genres.unwrap_or_default(),
year: item.production_year,
})
.collect())
}
async fn list_genres(&self, content_type: Option<&ContentType>) -> DomainResult<Vec<String>> {
let url = format!("{}/Genres", self.config.base_url);
let mut params: Vec<(&str, String)> = vec![
("UserId", self.config.user_id.clone()),
("SortBy", "SortName".into()),
("SortOrder", "Ascending".into()),
];
if let Some(ct) = content_type {
params.push(("IncludeItemTypes", jellyfin_item_type(ct).into()));
}
let response = self
.client
.get(&url)
.header("X-Emby-Token", &self.config.api_key)
.query(&params)
.send()
.await
.map_err(|e| {
DomainError::InfrastructureError(format!("Jellyfin request failed: {e}"))
})?;
if !response.status().is_success() {
return Err(DomainError::InfrastructureError(format!(
"Jellyfin returned HTTP {}",
response.status()
)));
}
let body: JellyfinItemsResponse = response.json().await.map_err(|e| {
DomainError::InfrastructureError(format!("Failed to parse Jellyfin response: {e}"))
})?;
Ok(body.items.into_iter().map(|item| item.name).collect())
}
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
Ok(SourceUri::NetworkUrl {
url: format!(
"{}/Videos/{}/stream?static=true&api_key={}",
self.config.base_url,
item_id.as_ref(),
self.config.api_key,
),
})
}
}