use std::sync::Arc; use async_trait::async_trait; use domain::{ Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem, MediaItemId, ProviderCapabilities, StreamQuality, StreamingProtocol, }; use super::config::LocalFilesConfig; use super::index::{LocalIndex, decode_id}; use super::scanner::LocalFileItem; use super::transcoder::TranscodeManager; pub struct LocalFilesProvider { pub index: Arc, base_url: String, transcode_manager: Option>, } const SHORT_DURATION_SECS: u32 = 1200; // 20 minutes impl LocalFilesProvider { pub fn new( index: Arc, config: LocalFilesConfig, transcode_manager: Option>, ) -> Self { Self { index, base_url: config.base_url.trim_end_matches('/').to_string(), transcode_manager, } } } fn to_media_item(id: MediaItemId, item: &LocalFileItem) -> MediaItem { let content_type = if item.duration_secs < 1200 { ContentType::Short } else { ContentType::Movie }; MediaItem { id, title: item.title.clone(), content_type, duration_secs: item.duration_secs, description: None, genres: vec![], year: item.year, tags: item.tags.clone(), series_name: None, season_number: None, episode_number: None, thumbnail_url: None, collection_id: None, } } #[async_trait] impl IMediaProvider for LocalFilesProvider { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { collections: true, series: false, genres: false, tags: true, decade: true, search: true, streaming_protocol: if self.transcode_manager.is_some() { StreamingProtocol::Hls } else { StreamingProtocol::DirectFile }, rescan: true, transcode: self.transcode_manager.is_some(), } } async fn fetch_items(&self, filter: &MediaFilter) -> DomainResult> { let all = self.index.get_all().await; let results = all .into_iter() .filter_map(|(id, item)| { // content_type: derive heuristically, then filter let content_type = if item.duration_secs < SHORT_DURATION_SECS { ContentType::Short } else { ContentType::Movie }; if let Some(ref ct) = filter.content_type && &content_type != ct { return None; } // collections: match against top_dir if !filter.collections.is_empty() && !filter.collections.contains(&item.top_dir) { return None; } // tags: OR — item must have at least one matching tag if !filter.tags.is_empty() { let has = filter .tags .iter() .any(|tag| item.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))); if !has { return None; } } // decade: year in [decade, decade+9] if let Some(decade) = filter.decade { match item.year { Some(y) if y >= decade && y <= decade + 9 => {} _ => return None, } } // duration bounds if let Some(min) = filter.min_duration_secs && item.duration_secs < min { return None; } if let Some(max) = filter.max_duration_secs && item.duration_secs > max { return None; } // search_term: case-insensitive substring in title if let Some(ref q) = filter.search_term && !item.title.to_lowercase().contains(&q.to_lowercase()) { return None; } Some(to_media_item(id, &item)) }) .collect(); Ok(results) } async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult> { Ok(self .index .get(item_id) .await .map(|item| to_media_item(item_id.clone(), &item))) } async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult { match quality { StreamQuality::Transcode(_) if self.transcode_manager.is_some() => { let tm = self.transcode_manager.as_ref().unwrap(); let rel = decode_id(item_id).ok_or_else(|| { DomainError::InfrastructureError("invalid item id encoding".into()) })?; let src = self.index.root_dir.join(&rel); tm.ensure_transcoded(item_id.as_ref(), &src).await?; Ok(format!( "{}/api/v1/files/transcode/{}/playlist.m3u8", self.base_url, item_id.as_ref() )) } _ => Ok(format!( "{}/api/v1/files/stream/{}", self.base_url, item_id.as_ref() )), } } async fn list_collections(&self) -> DomainResult> { let dirs = self.index.collections().await; Ok(dirs .into_iter() .map(|d| Collection { id: d.clone(), name: d, collection_type: None, }) .collect()) } } /// Decode an encoded ID from a URL path segment to its relative path string. pub fn decode_stream_id(encoded: &str) -> Option { decode_id(&MediaItemId::new(encoded)) }