cleanup: strip comments, extract constants, DRY shared helpers across adapters + infra-wiring

- strip all comments except WHY workaround notes (3 remain)
- remove all #[allow(dead_code)]; fix via _prefix rename
- extract named constants: JWT time units, token types, default config values, jellyfin fallback bitrate
- DRY: move serialize_enum_as_string, content_type_str, parse_content_type, parse_genres_blob to adapter-common
- sqlite+postgres library.rs use shared helpers instead of local copies
- sqlite+postgres channel.rs use shared serialize_enum_as_string
- remove dead `let _ = ext` in scanner.rs
This commit is contained in:
2026-07-12 04:21:21 +02:00
parent eff14228af
commit 25b33b6a0e
38 changed files with 188 additions and 630 deletions

View File

@@ -1,10 +1,6 @@
/// Connection details for a single Jellyfin instance.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JellyfinConfig {
/// e.g. `"http://192.168.1.10:8096"` -- no trailing slash.
pub base_url: String,
/// Jellyfin API key (Settings -> API Keys).
pub api_key: String,
/// The Jellyfin user ID used for library browsing.
pub user_id: String,
}

View File

@@ -1,9 +1,3 @@
//! Jellyfin media provider adapter.
//!
//! Implements [`domain::ports::IMediaProvider`] by talking to the Jellyfin HTTP API.
//! The domain never sees Jellyfin-specific types -- this module translates
//! between Jellyfin's API model and the domain's abstract `MediaItem`/`MediaFilter`.
mod config;
mod mapping;
mod models;

View File

@@ -2,11 +2,8 @@ use domain::{ContentType, MediaItem, MediaItemId};
use crate::models::JellyfinItem;
/// Ticks are Jellyfin's time unit: 1 tick = 100 nanoseconds -> 10,000,000 ticks/sec.
pub(crate) const TICKS_PER_SEC: i64 = 10_000_000;
/// Map a raw Jellyfin item to a domain `MediaItem`. Returns `None` for unknown
/// item types (e.g. Season, Series, Folder) so they are silently skipped.
pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
let content_type = match item.item_type.as_str() {
"Movie" => ContentType::Movie,
@@ -31,7 +28,7 @@ pub(crate) fn map_jellyfin_item(item: JellyfinItem) -> Option<MediaItem> {
item.series_name,
item.parent_index_number,
item.index_number,
None, // thumbnail_url
None, // collection_id
None,
None,
))
}

View File

@@ -1,10 +1,6 @@
use domain::ContentType;
use serde::Deserialize;
// ============================================================================
// Jellyfin API response types
// ============================================================================
#[derive(Debug, Deserialize)]
pub(crate) struct JellyfinItemsResponse {
#[serde(rename = "Items")]
@@ -29,19 +25,14 @@ pub(crate) struct JellyfinItem {
pub production_year: Option<u16>,
#[serde(rename = "Tags")]
pub tags: Option<Vec<String>>,
/// TV show name (episodes only).
#[serde(rename = "SeriesName")]
pub series_name: Option<String>,
/// Season number (episodes only).
#[serde(rename = "ParentIndexNumber")]
pub parent_index_number: Option<u32>,
/// Episode number within the season (episodes only).
#[serde(rename = "IndexNumber")]
pub index_number: Option<u32>,
/// Collection type for virtual library folders (e.g. "movies", "tvshows").
#[serde(rename = "CollectionType")]
pub collection_type: Option<String>,
/// Total number of child items (used for Series to count episodes).
#[serde(rename = "RecursiveItemCount")]
pub recursive_item_count: Option<u32>,
}
@@ -64,7 +55,6 @@ pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
match ct {
ContentType::Movie => "Movie",
ContentType::Episode => "Episode",
// Jellyfin has no native "Short" type; short films are filed as Movies.
ContentType::Short => "Movie",
}
}

View File

@@ -12,6 +12,8 @@ use crate::models::{
jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse,
};
const FALLBACK_HLS_BITRATE: u32 = 8_000_000;
pub struct JellyfinMediaProvider {
client: reqwest::Client,
config: JellyfinConfig,
@@ -28,7 +30,6 @@ impl JellyfinMediaProvider {
}
}
/// Inner fetch: applies all filter fields plus an optional series name override.
async fn fetch_items_for_series(
&self,
filter: &MediaFilter,
@@ -72,19 +73,13 @@ impl JellyfinMediaProvider {
}
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()));
// Prevent Jellyfin from returning Season/Series container items.
if filter.content_type.is_none() {
params.push(("IncludeItemTypes", "Episode".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()));
}
@@ -116,9 +111,8 @@ impl JellyfinMediaProvider {
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.
// 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
@@ -163,11 +157,6 @@ 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 => {
@@ -175,7 +164,6 @@ impl IMediaProvider for JellyfinMediaProvider {
self.fetch_items_for_series(filter, series).await
}
_ => {
// Fetch each series independently, then interleave round-robin.
let mut per_series: Vec<Vec<MediaItem>> = Vec::new();
for series_name in &filter.series_names {
let items = self
@@ -199,7 +187,6 @@ impl IMediaProvider for JellyfinMediaProvider {
}
}
/// Fetch a single item by its opaque ID.
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
let url = format!(
"{}/Users/{}/Items",
@@ -231,7 +218,6 @@ impl IMediaProvider for JellyfinMediaProvider {
Ok(body.items.into_iter().next().and_then(map_jellyfin_item))
}
/// List top-level virtual libraries available to the configured user.
async fn list_collections(&self) -> DomainResult<Vec<Collection>> {
let url = format!(
"{}/Users/{}/Views",
@@ -270,7 +256,6 @@ impl IMediaProvider for JellyfinMediaProvider {
.collect())
}
/// List all Series items, optionally scoped to a collection (ParentId).
async fn list_series(&self, collection_id: Option<&str>) -> DomainResult<Vec<SeriesSummary>> {
let url = format!(
"{}/Users/{}/Items",
@@ -327,7 +312,6 @@ impl IMediaProvider for JellyfinMediaProvider {
.collect())
}
/// List available genres from the Jellyfin `/Genres` endpoint.
async fn list_genres(&self, content_type: Option<&ContentType>) -> DomainResult<Vec<String>> {
let url = format!("{}/Genres", self.config.base_url);
@@ -409,8 +393,7 @@ impl IMediaProvider for JellyfinMediaProvider {
));
}
}
// Fallback: HLS at 8 Mbps
Ok(self.hls_url(item_id, 8_000_000))
Ok(self.hls_url(item_id, FALLBACK_HLS_BITRATE))
}
StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)),
}