Refactor schedule and user repositories into modular structure
- Moved schedule repository logic into separate modules for SQLite and PostgreSQL implementations. - Created a mapping module for shared data structures and mapping functions in the schedule repository. - Added new mapping module for user repository to handle user data transformations. - Implemented PostgreSQL and SQLite user repository adapters with necessary CRUD operations. - Added tests for user repository functionality, including saving, finding, and deleting users.
This commit is contained in:
371
k-tv-backend/infra/src/jellyfin/provider.rs
Normal file
371
k-tv-backend/infra/src/jellyfin/provider.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::{
|
||||
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
||||
MediaItemId, SeriesSummary,
|
||||
};
|
||||
|
||||
use super::config::JellyfinConfig;
|
||||
use super::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
||||
use super::models::{jellyfin_item_type, JellyfinItemsResponse};
|
||||
|
||||
pub struct JellyfinMediaProvider {
|
||||
pub(super) client: reqwest::Client,
|
||||
pub(super) 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner fetch: applies all filter fields plus an optional series name override.
|
||||
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 {
|
||||
// Series-level targeting: skip ParentId so the show is found regardless
|
||||
// of which library it lives in. SeriesName is already precise enough.
|
||||
params.push(("SeriesName", name.to_string()));
|
||||
// Return episodes in chronological order when a specific series is
|
||||
// requested — season first, then episode within the season.
|
||||
params.push(("SortBy", "ParentIndexNumber,IndexNumber".into()));
|
||||
params.push(("SortOrder", "Ascending".into()));
|
||||
} else {
|
||||
// No series filter — scope to the collection (library) if one is set.
|
||||
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(¶ms)
|
||||
.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))
|
||||
})?;
|
||||
|
||||
// Jellyfin's SeriesName query param is not a strict filter — it can
|
||||
// bleed items from other shows. Post-filter in Rust to guarantee that
|
||||
// only the requested series is returned.
|
||||
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
|
||||
.as_deref()
|
||||
.map(|s| s.eq_ignore_ascii_case(name))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
items.collect()
|
||||
};
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IMediaProvider for JellyfinMediaProvider {
|
||||
/// Fetch items matching `filter` from the Jellyfin library.
|
||||
///
|
||||
/// When `series_names` has more than one entry the results from each series
|
||||
/// are fetched sequentially and concatenated (Jellyfin only supports one
|
||||
/// `SeriesName` param per request).
|
||||
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
|
||||
}
|
||||
_ => {
|
||||
// Fetch each series independently, then interleave round-robin.
|
||||
// Round-robin ensures every show gets fair representation when a
|
||||
// downstream limit is applied (preview, block fill) even if one
|
||||
// series has far more episodes than another.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a single item by its opaque ID.
|
||||
///
|
||||
/// Returns `None` if the item is not found or cannot be mapped.
|
||||
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))
|
||||
}
|
||||
|
||||
/// List top-level virtual libraries available to the configured user.
|
||||
///
|
||||
/// Uses the `/Users/{userId}/Views` endpoint which returns exactly the
|
||||
/// top-level nodes the user has access to (Movies, TV Shows, etc.).
|
||||
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())
|
||||
}
|
||||
|
||||
/// List all Series items, optionally scoped to a collection (ParentId).
|
||||
///
|
||||
/// Results are sorted alphabetically. `RecursiveItemCount` gives the total
|
||||
/// episode count across all seasons without a second round-trip.
|
||||
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(¶ms)
|
||||
.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())
|
||||
}
|
||||
|
||||
/// List available genres from the Jellyfin `/Genres` endpoint.
|
||||
///
|
||||
/// Optionally filtered to a specific content type (Movie or Episode).
|
||||
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(¶ms)
|
||||
.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())
|
||||
}
|
||||
|
||||
/// Build an HLS stream URL for a Jellyfin item.
|
||||
///
|
||||
/// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC
|
||||
/// segments on the fly. HLS is preferred over a single MP4 stream because
|
||||
/// `StartTimeTicks` works reliably with HLS — each segment is independent,
|
||||
/// so Jellyfin can begin the playlist at the correct broadcast offset
|
||||
/// without needing to byte-range seek into an in-progress transcode.
|
||||
///
|
||||
/// The API key is embedded so the player needs no separate auth header.
|
||||
/// The caller (stream proxy route) appends `StartTimeTicks` when there is
|
||||
/// a non-zero broadcast offset.
|
||||
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
|
||||
Ok(format!(
|
||||
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate=40000000&mediaSourceId={}&api_key={}",
|
||||
self.config.base_url,
|
||||
item_id.as_ref(),
|
||||
item_id.as_ref(),
|
||||
self.config.api_key,
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user