diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index 6fc866508..abb925647 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -85,10 +85,62 @@ fn rewrite_resource_url(encrypt_secret: &[u8; 16], resource_url: &str, item: UiP return item; } let mut item = item; + if item.logo.starts_with('/') { + return item; + } item.logo = concat_path(resource_url, &obfuscate_text(encrypt_secret, &item.logo)).intern(); item } +#[cfg(test)] +mod tests { + use super::rewrite_resource_url; + use shared::{ + model::{PlaylistItemType, UiPlaylistItem, XtreamCluster}, + utils::{obfuscate_text, Internable}, + }; + + fn sample_item(logo: &str) -> UiPlaylistItem { + UiPlaylistItem { + virtual_id: 1, + provider_id: "provider".intern(), + name: "name".intern(), + title: "title".intern(), + group: "group".intern(), + logo: logo.intern(), + url: "file:///tmp/video.mkv".intern(), + item_type: PlaylistItemType::Live, + xtream_cluster: XtreamCluster::Live, + category_id: 0, + rating: 0.0, + } + } + + #[test] + fn rewrite_resource_url_keeps_internal_api_paths() { + let secret = [7u8; 16]; + let item = sample_item("/api/v1/library/thumbnail/test-uuid"); + + let rewritten = rewrite_resource_url(&secret, "/api/v1/playlist/resource", item); + + assert_eq!(rewritten.logo.as_ref(), "/api/v1/library/thumbnail/test-uuid"); + } + + #[test] + fn rewrite_resource_url_wraps_external_urls() { + let secret = [7u8; 16]; + let item = sample_item("https://example.com/poster.jpg"); + + let rewritten = rewrite_resource_url(&secret, "/api/v1/playlist/resource", item); + let expected_suffix = obfuscate_text(&secret, "https://example.com/poster.jpg"); + + assert_eq!( + rewritten.logo.as_ref(), + format!("/api/v1/playlist/resource/{expected_suffix}") + ); + } +} + pub(in crate::api::endpoints) async fn get_playlist_for_input( cfg_input: Option<&Arc>, app_state: &Arc, diff --git a/backend/src/api/endpoints/library_api.rs b/backend/src/api/endpoints/library_api.rs index e56d59ce0..ae682e478 100644 --- a/backend/src/api/endpoints/library_api.rs +++ b/backend/src/api/endpoints/library_api.rs @@ -1,7 +1,7 @@ use crate::{api::{ library_scan::{spawn_library_scan, LibraryScanTaskOptions}, model::{AppState, EventMessage}, -}, auth::permission_layer, library::{resolve_metadata_storage_path, LibraryProcessor}}; +}, auth::permission_layer, library::{resolve_metadata_storage_path, LibraryProcessor, MetadataStorage}}; use axum::response::IntoResponse; use log::{debug, warn}; use serde_json::json; @@ -50,7 +50,6 @@ async fn scan_library( } } }; - let client = app_state.http_client.load_full().as_ref().clone(); let event_manager = Arc::clone(&app_state.event_manager); spawn_library_scan( @@ -75,7 +74,12 @@ async fn get_library_status( let client = app_state.http_client.load_full().as_ref().clone(); // Get statistics from processor let processor = - LibraryProcessor::new(config.clone(), config_snapshot.metadata_update.as_ref(), client, &config_snapshot.storage_dir); + LibraryProcessor::new( + config.clone(), + config_snapshot.metadata_update.as_ref(), + client, + &config_snapshot.storage_dir, + ); let entries = processor.get_all_entries().await; let movies = entries.iter().filter(|e| e.metadata.is_movie()).count(); @@ -101,6 +105,64 @@ async fn get_library_status( axum::Json(response).into_response() } +async fn get_thumbnail( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Path(id): axum::extract::Path, + headers: axum::http::HeaderMap, +) -> axum::response::Response { + let config_snapshot = app_state.app_config.config.load(); + let Some(library_config) = config_snapshot.library.as_ref().filter(|l| l.enabled) else { + return axum::http::StatusCode::NOT_FOUND.into_response(); + }; + + if !library_config.thumbnails.enabled { + return axum::http::StatusCode::NOT_FOUND.into_response(); + } + + let storage_path = resolve_metadata_storage_path( + config_snapshot.metadata_update.as_ref(), + &config_snapshot.storage_dir, + ); + let storage = MetadataStorage::new(storage_path); + + if let Some(entry) = storage.load_by_uuid(&id).await { + if let Some(hash) = entry.thumbnail_hash.as_ref() { + let mtime = entry.thumbnail_mtime.unwrap_or(0); + let etag = format!("\"{hash}-{mtime}\""); + return serve_thumbnail_hash(&storage, hash, etag, &headers).await; + } + } + + let etag = format!("\"{id}\""); + serve_thumbnail_hash(&storage, &id, etag, &headers).await +} + +async fn serve_thumbnail_hash( + storage: &MetadataStorage, + hash: &str, + etag: String, + headers: &axum::http::HeaderMap, +) -> axum::response::Response { + if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH) { + if if_none_match.as_bytes() == etag.as_bytes() { + return axum::http::StatusCode::NOT_MODIFIED.into_response(); + } + } + + let thumb_path = storage.get_thumbnail_path(hash); + match tokio::fs::read(&thumb_path).await { + Ok(data) => { + let headers = [ + (axum::http::header::CONTENT_TYPE, "image/jpeg".to_string()), + (axum::http::header::CACHE_CONTROL, "max-age=86400, public".to_string()), + (axum::http::header::ETAG, etag), + ]; + (headers, data).into_response() + } + Err(_) => axum::http::StatusCode::NOT_FOUND.into_response(), + } +} + /// Registers Library API routes. pub fn library_api_register( router: axum::Router>, @@ -115,9 +177,11 @@ pub fn library_api_register( .route( "/library/scan", axum::routing::post(scan_library).layer(permission_layer!(app_state, Permission::LibraryWrite)), - ), + ) + .route("/library/thumbnail/{uuid}", axum::routing::get(get_thumbnail)), None => router .route("/library/scan", axum::routing::post(scan_library)) - .route("/library/status", axum::routing::get(get_library_status)), + .route("/library/status", axum::routing::get(get_library_status)) + .route("/library/thumbnail/{uuid}", axum::routing::get(get_thumbnail)), } -} +} \ No newline at end of file diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index 5b08f3402..26b6f603f 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -27,6 +27,8 @@ use shared::{ }; use std::{collections::BTreeMap, sync::Arc}; +pub const API_V1_PATH: &str = "api/v1"; + async fn create_ipinfo_check(app_state: &Arc) -> Option<(Option, Option)> { let config = app_state.app_config.config.load(); if let Some(ipcheck) = config.ipcheck.as_ref() { @@ -156,7 +158,7 @@ pub fn v1_api_register( base_router = base_router.merge(user_api_register(app_state, web_ui_path)); } - let api_prefix = concat_path_leading_slash(web_ui_path, "api/v1"); + let api_prefix = concat_path_leading_slash(web_ui_path, API_V1_PATH); base_router .nest(&api_prefix, public_router) .nest(&api_prefix, router) diff --git a/backend/src/library/metadata.rs b/backend/src/library/metadata.rs index 6cce3f9e4..8631e645c 100644 --- a/backend/src/library/metadata.rs +++ b/backend/src/library/metadata.rs @@ -134,7 +134,7 @@ pub struct SeriesMetadata { } // Episode metadata for TV series -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct EpisodeMetadata { #[serde(default)] pub id: u32, @@ -158,6 +158,8 @@ pub struct EpisodeMetadata { pub rating: Option, #[serde(default)] pub thumb: Option, + #[serde(default)] + pub thumbnail_id: Option, pub file_path: String, pub file_size: u64, pub file_modified: i64, @@ -270,6 +272,10 @@ pub struct MetadataCacheEntry { pub file_size: u64, pub file_modified: i64, pub metadata: MediaMetadata, + #[serde(default)] + pub thumbnail_hash: Option, + #[serde(default)] + pub thumbnail_mtime: Option, } impl MetadataCacheEntry { @@ -286,6 +292,8 @@ impl MetadataCacheEntry { file_size, file_modified, metadata, + thumbnail_hash: None, + thumbnail_mtime: None, } } diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index 58615cb81..c8b743031 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -246,6 +246,11 @@ mod tests { movie_category: "Movies".intern(), series_category: "Series".intern(), }, + thumbnails: crate::model::ThumbnailConfig { + enabled: false, + width: 320, + height: 180, + }, } } diff --git a/backend/src/library/metadata_storage.rs b/backend/src/library/metadata_storage.rs index 003b805db..fc400a884 100644 --- a/backend/src/library/metadata_storage.rs +++ b/backend/src/library/metadata_storage.rs @@ -1,23 +1,34 @@ use crate::library::metadata::{MediaMetadata, MetadataCacheEntry}; use log::{debug, error, info}; +use path_clean::PathClean; use std::collections::HashMap; use std::fmt::Write; use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; use tokio::fs; use tokio::io::AsyncWriteExt; -use path_clean::PathClean; +use tokio::sync::Mutex; + +const THUMBNAILS_PATH: &str = "thumbnails"; +const LIBRARY_PATH: &str = "library"; +const THUMBNAIL_CLEANUP_GRACE_PERIOD: Duration = Duration::from_secs(30); // Metadata storage for local VOD files // Stores metadata as JSON files with UUID-based filenames #[derive(Clone)] pub struct MetadataStorage { storage_dir: PathBuf, + mutation_guard: Arc>, } impl MetadataStorage { // Creates a new metadata storage instance pub fn new(storage_dir: PathBuf) -> Self { - Self { storage_dir } + Self { + storage_dir, + mutation_guard: Arc::new(Mutex::new(())), + } } // Initializes the storage directory @@ -26,12 +37,14 @@ impl MetadataStorage { info!("Creating metadata storage directory: {}", self.storage_dir.display()); } fs::create_dir_all(&self.storage_dir).await?; - fs::create_dir_all(self.storage_dir.join("library")).await?; + fs::create_dir_all(self.storage_dir.join(LIBRARY_PATH)).await?; + fs::create_dir_all(self.storage_dir.join(THUMBNAILS_PATH)).await?; Ok(()) } // Stores metadata for a video file pub async fn store(&self, entry: &MetadataCacheEntry) -> std::io::Result<()> { + let _guard = self.mutation_guard.lock().await; let file_path = self.get_library_metadata_file_path(&entry.uuid); debug!("Storing metadata for {}: {}", entry.file_path, file_path.clean().display()); @@ -77,7 +90,7 @@ impl MetadataStorage { // Loads all metadata entries from storage pub async fn load_all(&self) -> Vec { let mut entries = Vec::new(); - let library_dir = self.storage_dir.join("library"); + let library_dir = self.storage_dir.join(LIBRARY_PATH); let mut read_dir = match fs::read_dir(&library_dir).await { Ok(dir) => dir, @@ -109,6 +122,8 @@ impl MetadataStorage { // Deletes metadata for a specific UUID pub async fn delete_by_uuid(&self, uuid: &str) -> std::io::Result<()> { + let _guard = self.mutation_guard.lock().await; + let entry = self.load_by_uuid(uuid).await; let file_path = self.get_library_metadata_file_path(uuid); if fs::try_exists(&file_path).await.unwrap_or(false) { @@ -116,9 +131,38 @@ impl MetadataStorage { fs::remove_file(&file_path).await?; } + let cached_entries = self.load_all().await; + + if let Some(entry) = entry { + for thumbnail_id in referenced_thumbnail_ids_for_entry(&entry) { + self.delete_unreferenced_thumbnail(thumbnail_id.as_str(), Some(&cached_entries)).await; + } + } + Ok(()) } + async fn delete_unreferenced_thumbnail(&self, hash: &str, cached_entries: Option<&[MetadataCacheEntry]>) { + let loaded_entries; + let entries = if let Some(entries) = cached_entries { entries } else { + loaded_entries = self.load_all().await; + &loaded_entries + }; + + let still_referenced = entries + .iter() + .any(|entry| entry_references_thumbnail(entry, hash)); + if still_referenced { + return; + } + + let path = self.get_thumbnail_path(hash); + if fs::try_exists(&path).await.unwrap_or(false) { + debug!("Deleting thumbnail file: {}", path.display()); + let _ = fs::remove_file(path).await; + } + } + // Cleans up metadata for files that no longer exist pub async fn cleanup_orphaned(&self) -> std::io::Result { let entries = self.load_all().await; @@ -153,7 +197,56 @@ impl MetadataStorage { // Gets the metadata file path for a Local library fn get_library_metadata_file_path(&self, uuid: &str) -> PathBuf { - self.storage_dir.join("library").join(format!("{uuid}.json")) + self.storage_dir.join(LIBRARY_PATH).join(format!("{uuid}.json")) + } + + pub fn get_thumbnail_path(&self, hash: &str) -> PathBuf { + self.storage_dir.join(THUMBNAILS_PATH).join(format!("{hash}.jpg")) + } + + pub async fn store_thumbnail(&self, hash: &str, data: &[u8]) -> std::io::Result<()> { + let _guard = self.mutation_guard.lock().await; + let path = self.get_thumbnail_path(hash); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + let mut file = fs::File::create(&path).await?; + file.write_all(data).await?; + file.flush().await?; + debug!("Stored thumbnail: {}", path.display()); + Ok(()) + } + + pub async fn has_thumbnail(&self, hash: &str) -> bool { + fs::try_exists(self.get_thumbnail_path(hash)).await.unwrap_or(false) + } + + /// Removes thumbnail files that are not referenced by any metadata entry. + pub async fn cleanup_orphaned_thumbnails(&self) { + let _guard = self.mutation_guard.lock().await; + let thumb_dir = self.storage_dir.join(THUMBNAILS_PATH); + if !fs::try_exists(&thumb_dir).await.unwrap_or(false) { + return; + } + + // Collect all referenced hashes + let entries = self.load_all().await; + let referenced: std::collections::HashSet = entries + .iter() + .flat_map(referenced_thumbnail_ids_for_entry) + .collect(); + + // Walk thumbnail dir and delete unreferenced files + let Ok(mut dir) = fs::read_dir(&thumb_dir).await else { return }; + while let Ok(Some(entry)) = dir.next_entry().await { + let path = entry.path(); + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + if !referenced.contains(stem) && !is_recent_thumbnail(&path).await { + debug!("Removing orphaned thumbnail: {}", path.display()); + let _ = fs::remove_file(&path).await; + } + } + } } fn get_tmdb_movie_data_file_path(&self, tmdb_id: u32) -> PathBuf { @@ -189,7 +282,7 @@ impl MetadataStorage { async fn store_file(&self, content: &[u8], file_path: PathBuf) -> std::io::Result { // Ensure parent directory exists - unconditionally if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).await?; + fs::create_dir_all(parent).await?; } let mut file = fs::File::create(&file_path).await?; @@ -319,10 +412,51 @@ impl MetadataStorage { } } +async fn is_recent_thumbnail(path: &std::path::Path) -> bool { + let Ok(metadata) = fs::metadata(path).await else { + return false; + }; + + let reference_time = metadata.modified().or_else(|_| metadata.created()); + let Ok(reference_time) = reference_time else { + return false; + }; + + SystemTime::now() + .duration_since(reference_time) + .is_ok_and(|age| age < THUMBNAIL_CLEANUP_GRACE_PERIOD) +} + +fn referenced_thumbnail_ids_for_entry(entry: &MetadataCacheEntry) -> Vec { + let mut ids = Vec::new(); + if let Some(hash) = entry.thumbnail_hash.as_ref() { + ids.push(hash.clone()); + } + if let MediaMetadata::Series(series) = &entry.metadata { + if let Some(episodes) = series.episodes.as_ref() { + ids.extend(episodes.iter().filter_map(|episode| episode.thumbnail_id.clone())); + } + } + ids +} + +fn entry_references_thumbnail(entry: &MetadataCacheEntry, hash: &str) -> bool { + entry.thumbnail_hash.as_deref() == Some(hash) + || match &entry.metadata { + MediaMetadata::Movie(_) => false, + MediaMetadata::Series(series) => series + .episodes + .as_ref() + .is_some_and(|episodes| episodes.iter().any(|episode| episode.thumbnail_id.as_deref() == Some(hash))), + } +} + #[cfg(test)] mod tests { use super::*; use crate::library::metadata::{MetadataSource, MovieMetadata}; + use filetime::{set_file_mtime, FileTime}; + use std::time::{Duration, SystemTime}; #[tokio::test] async fn test_store_and_load() { @@ -357,4 +491,104 @@ mod tests { let deleted = storage.load_by_uuid(&entry.uuid).await; assert!(deleted.is_none()); } + + #[tokio::test] + async fn test_thumbnail_storage_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let storage = MetadataStorage::new(dir.path().to_path_buf()); + storage.initialize().await.unwrap(); + + let hash = "abc123def456"; + let data = b"fake jpeg data"; + + assert!(!storage.has_thumbnail(hash).await); + storage.store_thumbnail(hash, data).await.unwrap(); + assert!(storage.has_thumbnail(hash).await); + + let path = storage.get_thumbnail_path(hash); + assert!(path.exists()); + let read_back = tokio::fs::read(&path).await.unwrap(); + assert_eq!(read_back, data); + } + + #[tokio::test] + async fn test_delete_by_uuid_removes_unreferenced_thumbnail() { + let dir = tempfile::tempdir().unwrap(); + let storage = MetadataStorage::new(dir.path().to_path_buf()); + storage.initialize().await.unwrap(); + + let mut entry = MetadataCacheEntry::new( + "/test/movie.mp4".to_string(), + 1024, + 1_234_567_890, + MediaMetadata::Movie(MovieMetadata::default()), + ); + entry.thumbnail_hash = Some("thumb-delete-me".to_string()); + storage.store(&entry).await.unwrap(); + storage.store_thumbnail("thumb-delete-me", b"jpeg").await.unwrap(); + + storage.delete_by_uuid(&entry.uuid).await.unwrap(); + + assert!(!storage.has_thumbnail("thumb-delete-me").await); + } + + #[tokio::test] + async fn test_delete_by_uuid_keeps_shared_thumbnail() { + let dir = tempfile::tempdir().unwrap(); + let storage = MetadataStorage::new(dir.path().to_path_buf()); + storage.initialize().await.unwrap(); + + let mut entry_a = MetadataCacheEntry::new( + "/test/a.mp4".to_string(), + 100, + 1, + MediaMetadata::Movie(MovieMetadata::default()), + ); + entry_a.thumbnail_hash = Some("shared-thumb".to_string()); + + let mut entry_b = MetadataCacheEntry::new( + "/test/b.mp4".to_string(), + 200, + 2, + MediaMetadata::Movie(MovieMetadata::default()), + ); + entry_b.thumbnail_hash = Some("shared-thumb".to_string()); + + storage.store(&entry_a).await.unwrap(); + storage.store(&entry_b).await.unwrap(); + storage.store_thumbnail("shared-thumb", b"jpeg").await.unwrap(); + + storage.delete_by_uuid(&entry_a.uuid).await.unwrap(); + + assert!(storage.has_thumbnail("shared-thumb").await); + } + + #[tokio::test] + async fn test_cleanup_orphaned_thumbnails_keeps_recent_orphans() { + let dir = tempfile::tempdir().unwrap(); + let storage = MetadataStorage::new(dir.path().to_path_buf()); + storage.initialize().await.unwrap(); + + storage.store_thumbnail("fresh-orphan", b"jpeg").await.unwrap(); + + storage.cleanup_orphaned_thumbnails().await; + + assert!(storage.has_thumbnail("fresh-orphan").await); + } + + #[tokio::test] + async fn test_cleanup_orphaned_thumbnails_removes_stale_orphans() { + let dir = tempfile::tempdir().unwrap(); + let storage = MetadataStorage::new(dir.path().to_path_buf()); + storage.initialize().await.unwrap(); + + storage.store_thumbnail("stale-orphan", b"jpeg").await.unwrap(); + let thumbnail_path = storage.get_thumbnail_path("stale-orphan"); + let stale_time = FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)); + set_file_mtime(&thumbnail_path, stale_time).unwrap(); + + storage.cleanup_orphaned_thumbnails().await; + + assert!(!storage.has_thumbnail("stale-orphan").await); + } } diff --git a/backend/src/library/mod.rs b/backend/src/library/mod.rs index 5f8775af9..750e64ec1 100644 --- a/backend/src/library/mod.rs +++ b/backend/src/library/mod.rs @@ -8,6 +8,7 @@ mod metadata_resolver; mod processor; mod metadata_iterator; mod tmdb; +mod thumbnail; pub use scanner::*; pub use classifier::*; @@ -18,3 +19,4 @@ pub use metadata_storage::*; pub use metadata_resolver::*; pub use processor::*; pub use metadata_iterator::*; +pub use thumbnail::*; diff --git a/backend/src/library/processor.rs b/backend/src/library/processor.rs index 24d9b7aa5..436d812b5 100644 --- a/backend/src/library/processor.rs +++ b/backend/src/library/processor.rs @@ -1,9 +1,9 @@ use crate::api::model::create_http_client; -use crate::library::metadata::{MediaMetadata, MetadataCacheEntry}; +use crate::library::metadata::{EpisodeMetadata, MediaMetadata, MetadataCacheEntry}; use crate::library::metadata_resolver::MetadataResolver; use crate::library::metadata_storage::MetadataStorage; use crate::library::scanner::LibraryScanner; -use crate::library::{MediaGroup, MediaGrouper}; +use crate::library::{MediaGroup, MediaGrouper, thumbnail::{self, ThumbnailExtractor}}; use crate::model::{AppConfig, LibraryConfig, MetadataUpdateConfig}; use log::{debug, error, info, warn}; use path_clean::PathClean; @@ -26,6 +26,7 @@ pub struct LibraryProcessor { scanner: LibraryScanner, resolver: MetadataResolver, storage: MetadataStorage, + thumbnail_extractor: Option, app_config: Option>, // Need access to global config for FFprobe settings } @@ -61,7 +62,12 @@ impl LibraryProcessor { config .library .as_ref() - .map(|lib_cfg| Self::new(lib_cfg.clone(), config.metadata_update.as_ref(), client, &config.storage_dir)) + .map(|lib_cfg| { + let mut processor = + Self::new(lib_cfg.clone(), config.metadata_update.as_ref(), client, &config.storage_dir); + processor.app_config = Some(Arc::new(app_config.clone())); + processor + }) } // Creates a new Library processor with the given configuration @@ -76,11 +82,18 @@ impl LibraryProcessor { let storage = MetadataStorage::new(storage_path); let resolver = MetadataResolver::from_config(Some(&config), metadata_update_config, client, Some(storage.clone())); + let thumbnail_extractor = if config.thumbnails.enabled { + Some(ThumbnailExtractor::new(config.thumbnails.clone())) + } else { + None + }; + Self { config, scanner, resolver, storage, + thumbnail_extractor, app_config: None, } } @@ -157,6 +170,10 @@ impl LibraryProcessor { } } + if self.thumbnail_extractor.is_some() { + self.storage.cleanup_orphaned_thumbnails().await; + } + info!("Library scan completed: {result:?}"); Ok(result) } @@ -196,7 +213,7 @@ impl LibraryProcessor { async fn process_movie(&self, group: &MediaGroup, existing_map: &HashMap, force_rescan: bool, _can_probe: bool) -> Result { let MediaGroup::Movie { file, .. } = group else { return Err(format!("Expected movie to resolve but got {group}")) }; // Check if file already exists in cache - let (cache_entry, status) = if let Some(existing_entry) = existing_map.get(&file.file_path) { + let (mut cache_entry, status) = if let Some(existing_entry) = existing_map.get(&file.file_path) { // Check if file has been modified if !force_rescan && !existing_entry.is_file_modified(file, 0, 0) { debug!("File unchanged, skipping: {}", file.file_path); @@ -207,13 +224,15 @@ impl LibraryProcessor { // Reuse existing UUID let metadata = self.resolve_metadata(group).await?; //self.enrich_metadata_with_ffprobe(&mut metadata, &file.file_path, can_probe); - + let entry = MetadataCacheEntry { uuid: existing_entry.uuid.clone(), file_path: file.file_path.clone(), file_size: file.size_bytes, file_modified: file.modified_timestamp, metadata, + thumbnail_hash: existing_entry.thumbnail_hash.clone(), + thumbnail_mtime: existing_entry.thumbnail_mtime, }; (entry, ProcessAction::Updated) @@ -232,11 +251,14 @@ impl LibraryProcessor { (entry, ProcessAction::Added) }; + self.extract_thumbnail_if_needed(&mut cache_entry, &file.file_path, file.modified_timestamp).await; self.storage.store(&cache_entry).await.map_err(|e| e.to_string())?; self.write_metadata_files(&cache_entry).await.map_err(|e| e.to_string())?; Ok(status) } + + #[allow(clippy::too_many_lines)] async fn process_series_group( &self, group: &MediaGroup, @@ -255,6 +277,10 @@ impl LibraryProcessor { }) .unwrap_or_else(|| show_key.to_string()); + // Build a map of existing per-episode thumbnail state so it can be + // carried forward when the series metadata is rebuilt from scratch. + let mut existing_ep_thumbs: HashMap<(u32, u32), (Option, i64)> = HashMap::new(); + // Check if file already exists in cache let (mut chache_entry, status) = if let Some(existing_entry) = existing_map.get(&series_file_path) { if !force_rescan { @@ -266,6 +292,17 @@ impl LibraryProcessor { } debug!("File modified, updating metadata: {show_key}"); + // Preserve existing per-episode thumbnail state before rebuilding + if let MediaMetadata::Series(ref existing_series) = existing_entry.metadata { + if let Some(ref eps) = existing_series.episodes { + for ep in eps { + existing_ep_thumbs.insert( + (ep.season, ep.episode), + (ep.thumbnail_id.clone(), ep.file_modified), + ); + } + } + } // Reuse existing UUID let metadata = self.resolve_metadata(group).await?; @@ -275,6 +312,8 @@ impl LibraryProcessor { file_size: 0, file_modified: 0, metadata, + thumbnail_hash: existing_entry.thumbnail_hash.clone(), + thumbnail_mtime: existing_entry.thumbnail_mtime, }; (entry, ProcessAction::Updated) } else { @@ -292,30 +331,80 @@ impl LibraryProcessor { }; if let (MediaMetadata::Series(ref mut series_metadata), MediaGroup::Series { episodes, .. }) = (&mut chache_entry.metadata, group) { - if let Some(series_episodes) = series_metadata.episodes.as_mut() { - // maybe we have the same episode as 2 different files - let mut double_episodes = vec![]; - for episode in episodes { - for series_episode in &mut *series_episodes { - if episode.episode == series_episode.episode && episode.season == series_episode.season { - if series_episode.file_path.is_empty() { - let mut new_episode = series_episode.clone(); - new_episode.file_path.clone_from(&episode.file.file_path); - new_episode.file_modified = episode.file.modified_timestamp; - new_episode.file_size = episode.file.size_bytes; - double_episodes.push(new_episode); + let series_episodes = series_metadata.episodes.get_or_insert_with(|| { + // No episode list from TMDB/NFO — synthesize stubs from scanned files + // so that file paths, sizes and timestamps get populated below. + episodes.iter().map(|ep| EpisodeMetadata { + title: ep.metadata.title.clone(), + season: ep.season, + episode: ep.episode, + file_path: String::new(), + file_size: 0, + file_modified: 0, + ..EpisodeMetadata::default() + }).collect() + }); + + // maybe we have the same episode as 2 different files + let mut double_episodes = vec![]; + for episode in episodes { + for series_episode in &mut *series_episodes { + if episode.episode == series_episode.episode && episode.season == series_episode.season { + let previous_file_modified = series_episode.file_modified; + if series_episode.file_path.is_empty() { + // Carry forward existing thumbnail state so we don't + // re-extract thumbnails that are already cached. + let prev_mtime = if let Some((ref existing_thumb_id, existing_mtime)) = + existing_ep_thumbs.get(&(episode.season, episode.episode)) + { + series_episode.thumbnail_id.clone_from(existing_thumb_id); + Some(*existing_mtime) } else { - series_episode.file_path.clone_from(&episode.file.file_path); - series_episode.file_modified = episode.file.modified_timestamp; - series_episode.file_size = episode.file.size_bytes; - } + None + }; + series_episode.file_path.clone_from(&episode.file.file_path); + series_episode.file_modified = episode.file.modified_timestamp; + series_episode.file_size = episode.file.size_bytes; + self.update_episode_thumbnail( + series_episode, + &episode.file.file_path, + episode.file.modified_timestamp, + prev_mtime, + ).await; + } else { + let mut new_episode = series_episode.clone(); + new_episode.file_path.clone_from(&episode.file.file_path); + new_episode.file_modified = episode.file.modified_timestamp; + new_episode.file_size = episode.file.size_bytes; + self.update_episode_thumbnail( + &mut new_episode, + &episode.file.file_path, + episode.file.modified_timestamp, + Some(previous_file_modified), + ).await; + double_episodes.push(new_episode); } } } - if !double_episodes.is_empty() { - series_episodes.append(&mut double_episodes); - series_episodes.sort_by_key(|episode| (episode.season, episode.episode)); - } + } + if !double_episodes.is_empty() { + series_episodes.append(&mut double_episodes); + series_episodes.sort_by_key(|episode| (episode.season, episode.episode)); + } + + series_metadata.number_of_episodes = u32::try_from(series_episodes.len()).unwrap_or(0); + if series_metadata.number_of_seasons == 0 { + series_metadata.number_of_seasons = unique_season_count(series_episodes); + } + } + + if let MediaGroup::Series { episodes, .. } = group { + if let Some(first_ep) = episodes.first() { + self.extract_thumbnail_if_needed( + &mut chache_entry, + &first_ep.file.file_path, + first_ep.file.modified_timestamp, + ).await; } } @@ -329,6 +418,99 @@ impl LibraryProcessor { self.resolver.resolve(file).await.ok_or_else(|| format!("Could not resolve metadata for {file}")) } + /// Extracts and caches a thumbnail if no TMDB poster is available. + /// Uses mtime-based cache invalidation: re-extracts if source file + /// has been modified since last extraction. + async fn extract_thumbnail_if_needed( + &self, + cache_entry: &mut MetadataCacheEntry, + file_path: &str, + file_mtime: i64, + ) { + // Skip if already has a poster from TMDB/NFO + if cache_entry.metadata.poster().is_some() { + // Clear stale generated-thumbnail references so they can be reclaimed + cache_entry.thumbnail_hash = None; + cache_entry.thumbnail_mtime = None; + return; + } + + let Some(ref extractor) = self.thumbnail_extractor else { return }; + + let hash = thumbnail::file_hash(file_path); + + // Check if we already have a valid cached thumbnail + if self.storage.has_thumbnail(&hash).await { + // Re-extract if source file was modified since last extraction + if cache_entry.thumbnail_mtime == Some(file_mtime) { + cache_entry.thumbnail_hash = Some(hash); + return; + } + debug!("Source file modified, re-extracting thumbnail: {file_path}"); + } + + match extractor.extract_from_file(file_path).await { + Ok(data) => { + if let Err(err) = self.storage.store_thumbnail(&hash, &data).await { + error!("Failed to store thumbnail for {file_path}: {err}"); + return; + } + debug!("Extracted thumbnail for: {file_path}"); + cache_entry.thumbnail_hash = Some(hash); + cache_entry.thumbnail_mtime = Some(file_mtime); + } + Err(err) => { + warn!("Thumbnail extraction failed for {file_path}: {err}"); + } + } + } + + async fn update_episode_thumbnail( + &self, + episode: &mut EpisodeMetadata, + file_path: &str, + file_mtime: i64, + previous_file_mtime: Option, + ) { + if !episode.thumb.as_deref().unwrap_or_default().is_empty() + && episode.thumbnail_id.as_deref().unwrap_or_default().is_empty() + { + return; + } + + if let Some(thumbnail_id) = self.extract_thumbnail_id_for_file(file_path, file_mtime, previous_file_mtime).await { + episode.thumbnail_id = Some(thumbnail_id); + } + } + + async fn extract_thumbnail_id_for_file( + &self, + file_path: &str, + file_mtime: i64, + previous_file_mtime: Option, + ) -> Option { + let extractor = self.thumbnail_extractor.as_ref()?; + let hash = thumbnail::file_hash(file_path); + + if self.storage.has_thumbnail(&hash).await && previous_file_mtime == Some(file_mtime) { + return Some(hash); + } + + match extractor.extract_from_file(file_path).await { + Ok(data) => { + if let Err(err) = self.storage.store_thumbnail(&hash, &data).await { + error!("Failed to store episode thumbnail for {file_path}: {err}"); + return None; + } + Some(hash) + } + Err(err) => { + warn!("Episode thumbnail extraction failed for {file_path}: {err}"); + None + } + } + } + // Writes metadata files (JSON, NFO) based on configuration async fn write_metadata_files(&self, entry: &MetadataCacheEntry) -> Result<(), std::io::Error> { // JSON is always written by storage.store() @@ -350,6 +532,13 @@ impl LibraryProcessor { } } +fn unique_season_count(episodes: &[EpisodeMetadata]) -> u32 { + let mut seasons: Vec = episodes.iter().map(|episode| episode.season).collect(); + seasons.sort_unstable(); + seasons.dedup(); + u32::try_from(seasons.len()).unwrap_or(0) +} + #[cfg(test)] mod tests { @@ -369,4 +558,16 @@ mod tests { assert_eq!(result.files_scanned, 100); assert_eq!(result.files_added, 50); } + + #[test] + fn test_unique_season_count_handles_unsorted_duplicates() { + let episodes = vec![ + EpisodeMetadata { season: 2, ..EpisodeMetadata::default() }, + EpisodeMetadata { season: 1, ..EpisodeMetadata::default() }, + EpisodeMetadata { season: 2, ..EpisodeMetadata::default() }, + EpisodeMetadata { season: 3, ..EpisodeMetadata::default() }, + ]; + + assert_eq!(unique_season_count(&episodes), 3); + } } diff --git a/backend/src/library/scanner.rs b/backend/src/library/scanner.rs index 5d0c1b1ae..d1affdcbe 100644 --- a/backend/src/library/scanner.rs +++ b/backend/src/library/scanner.rs @@ -54,20 +54,17 @@ pub struct SeriesEpisodeFile { pub file: ScannedMediaFile, pub season: u32, pub episode: u32, + pub auto_assigned_episode: bool, pub metadata: Box, } pub struct MediaGrouper; impl MediaGrouper { - pub fn group(mut files: Vec) -> Vec { + pub fn group(files: Vec) -> Vec { let mut series_map: HashMap> = HashMap::new(); let mut movies = Vec::new(); - // Sort by path before classification so fallback episode numbering - // matches the final display order deterministically. - files.sort_by(|a, b| a.file_path.cmp(&b.file_path)); - let mut episode_counters = HashMap::new(); for file in files { let classification = MediaClassifier::classify(&file, &mut episode_counters); @@ -76,6 +73,7 @@ impl MediaGrouper { movies.push(MediaGroup::Movie { file, metadata: Box::new(metadata) }); } MediaClassification::Series { key, metadata, season, episode,.. } => { + let auto_assigned_episode = metadata.episodes.is_empty() || metadata.seasons.is_empty(); series_map .entry(key) .or_default() @@ -83,6 +81,7 @@ impl MediaGrouper { file, season, episode, + auto_assigned_episode, metadata: Box::new(metadata), }); } @@ -93,7 +92,11 @@ impl MediaGrouper { result.extend( series_map.into_iter() .map(|(key, mut episodes)| { - episodes.sort_by(|a, b| a.file.file_path.cmp(&b.file.file_path)); + assign_fallback_episode_numbers(&mut episodes); + episodes.sort_by(|a, b| { + (a.season, a.episode, a.file.modified_timestamp, &a.file.file_path) + .cmp(&(b.season, b.episode, b.file.modified_timestamp, &b.file.file_path)) + }); MediaGroup::Series { show_key: key, episodes, @@ -111,6 +114,34 @@ impl MediaGrouper { } } +fn assign_fallback_episode_numbers(episodes: &mut [SeriesEpisodeFile]) { + let next_fallback_episode = episodes + .iter() + .filter(|episode| !episode.auto_assigned_episode && episode.season == 1) + .map(|episode| episode.episode) + .max() + .unwrap_or(0) + .saturating_add(1); + + let mut fallback_indices: Vec = episodes + .iter() + .enumerate() + .filter_map(|(idx, episode)| episode.auto_assigned_episode.then_some(idx)) + .collect(); + + fallback_indices.sort_by(|left, right| { + let left_episode = &episodes[*left]; + let right_episode = &episodes[*right]; + (left_episode.file.modified_timestamp, &left_episode.file.file_path) + .cmp(&(right_episode.file.modified_timestamp, &right_episode.file.file_path)) + }); + + for (offset, idx) in fallback_indices.into_iter().enumerate() { + episodes[idx].season = 1; + episodes[idx].episode = next_fallback_episode.saturating_add(u32::try_from(offset).unwrap_or(0)); + } +} + /// Represents a discovered video file with its metadata #[derive(Debug, Clone)] @@ -312,6 +343,29 @@ mod tests { movie_category: "Local Movies".intern(), series_category: "Local Series".intern(), }, + thumbnails: crate::model::ThumbnailConfig { + enabled: false, + width: 320, + height: 180, + }, + } + } + + fn create_group_test_file( + file_name: &str, + parent_path: &str, + modified_timestamp: i64, + content_type: LibraryContentType, + ) -> ScannedMediaFile { + let path = PathBuf::from(parent_path).join(file_name); + ScannedMediaFile { + file_path: path.display().to_string(), + path, + file_name: file_name.to_string(), + extension: "mkv".to_string(), + size_bytes: 1024, + modified_timestamp, + content_type, } } @@ -331,4 +385,27 @@ mod tests { assert!(result.is_ok()); assert_eq!(result.unwrap().len(), 0); } + + #[test] + fn test_group_assigns_fallback_episode_numbers_by_modified_time() { + let files = vec![ + create_group_test_file("MyShow.2020.1080p.mkv", "/tv/MyShow", 200, LibraryContentType::Series), + create_group_test_file("MyShow.2020.S01E03.mkv", "/tv/MyShow", 300, LibraryContentType::Series), + create_group_test_file("MyShow.2021.720p.mkv", "/tv/MyShow", 100, LibraryContentType::Series), + ]; + + let groups = MediaGrouper::group(files); + let MediaGroup::Series { episodes, .. } = &groups[0] else { + panic!("Expected series group"); + }; + + let episode_numbers: HashMap<_, _> = episodes + .iter() + .map(|episode| (episode.file.file_name.as_str(), episode.episode)) + .collect(); + + assert_eq!(episode_numbers["MyShow.2020.S01E03.mkv"], 3); + assert_eq!(episode_numbers["MyShow.2021.720p.mkv"], 4); + assert_eq!(episode_numbers["MyShow.2020.1080p.mkv"], 5); + } } diff --git a/backend/src/library/thumbnail.rs b/backend/src/library/thumbnail.rs new file mode 100644 index 000000000..147657e56 --- /dev/null +++ b/backend/src/library/thumbnail.rs @@ -0,0 +1,144 @@ +use crate::model::ThumbnailConfig; +use log::debug; +use std::path::Path; +use std::time::Duration; +use tokio::process::Command; + +const FFMPEG_TIMEOUT: Duration = Duration::from_secs(60); + +/// Computes a stable BLAKE3 hash for a file path (or URL). +pub fn file_hash(path: &str) -> String { + blake3::hash(path.as_bytes()).to_hex().to_string() +} + +pub struct ThumbnailExtractor { + config: ThumbnailConfig, +} + +impl ThumbnailExtractor { + pub fn new(config: ThumbnailConfig) -> Self { Self { config } } + + /// Checks if the system `ffmpeg` binary is available. + pub async fn check_ffmpeg_availability() -> bool { + match Command::new("ffmpeg").arg("-version").output().await { + Ok(output) => output.status.success(), + Err(_) => false, + } + } + + /// Extracts a thumbnail from a local file. Returns JPEG bytes on success. + /// + /// The library scan already runs in a detached background task, so thumbnail + /// extraction here must stay simple and must not introduce its own parallel + /// ffmpeg scheduling inside the scan worker. + pub async fn extract_from_file(&self, file_path: &str) -> Result, String> { + let path = Path::new(file_path); + if !path.exists() { + return Err(format!("File not found: {file_path}")); + } + + self.run_ffmpeg(file_path).await + } + + // TODO: Future feature: support thumbnail extraction from remote HTTP(S) + // inputs via ranged reads without tying this logic into the local library + // scan path yet. + + fn build_scale_filter(&self) -> String { + format!( + "scale={}:{}:force_original_aspect_ratio=increase,crop={}:{}", + self.config.width, self.config.height, self.config.width, self.config.height + ) + } + + /// Runs ffmpeg to extract a single frame at ~180s into the video. + /// Falls back to position 0 if the video is shorter than 180s. + async fn run_ffmpeg(&self, input_path: &str) -> Result, String> { + let temp_dir = tempfile::tempdir() + .map_err(|e| format!("Failed to create temp dir: {e}"))?; + let output_path = temp_dir.path().join("thumb.jpg"); + let scale_filter = self.build_scale_filter(); + + let output = self.run_ffmpeg_with_timeout(&[ + "-ss", "180", + "-i", input_path, + "-frames:v", "1", + "-vf", &scale_filter, + "-q:v", "1", + "-y", + &output_path.to_string_lossy(), + ]).await + .map_err(|e| format!("Failed to run ffmpeg: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Retry at position 0 if seeking past end of short video + if stderr.contains("Output file is empty") || stderr.contains("nothing was encoded") { + debug!("Video shorter than 180s, retrying at position 0: {input_path}"); + let output = self.run_ffmpeg_with_timeout(&[ + "-ss", "0", + "-i", input_path, + "-frames:v", "1", + "-vf", &scale_filter, + "-q:v", "1", + "-y", + &output_path.to_string_lossy(), + ]).await + .map_err(|e| format!("Failed to run ffmpeg retry: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("ffmpeg failed at position 0: {stderr}")); + } + } else { + return Err(format!("ffmpeg failed: {stderr}")); + } + } + + tokio::fs::read(&output_path) + .await + .map_err(|e| format!("Failed to read thumbnail: {e}")) + } + + /// Spawns ffmpeg with the given args and enforces a bounded timeout. + /// On timeout the child process is killed via `kill_on_drop`. + async fn run_ffmpeg_with_timeout(&self, args: &[&str]) -> Result { + let child = Command::new("ffmpeg") + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?; + + tokio::time::timeout(FFMPEG_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| "Timed out running ffmpeg".to_string())? + .map_err(|e| e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_file_hash_deterministic() { + let h1 = file_hash("/some/path/video.mkv"); + let h2 = file_hash("/some/path/video.mkv"); + assert_eq!(h1, h2); + } + + #[test] + fn test_file_hash_different_paths() { + let h1 = file_hash("/a/video.mkv"); + let h2 = file_hash("/b/video.mkv"); + assert_ne!(h1, h2); + } + + #[test] + fn test_thumbnail_url_format() { + let url = format!("/api/v1/library/thumbnail/{}", "test-uuid-123"); + assert_eq!(url, "/api/v1/library/thumbnail/test-uuid-123"); + } +} \ No newline at end of file diff --git a/backend/src/library/tmdb.rs b/backend/src/library/tmdb.rs index fc624b32b..3e211ab1e 100644 --- a/backend/src/library/tmdb.rs +++ b/backend/src/library/tmdb.rs @@ -231,6 +231,7 @@ impl TmdbSeriesInfoEpisodeDetails { runtime: self.runtime, rating: Some(self.vote_average), thumb: self.still_path.as_ref().and_then(|path| resolve_tmdb_image_url(path)), + thumbnail_id: None, file_path: String::new(), file_size: 0, file_modified: 0, @@ -518,4 +519,3 @@ mod tests { // assert_eq!(a.id, 84892) // } } - diff --git a/backend/src/model/config/library.rs b/backend/src/model/config/library.rs index 750f8b310..46378e1db 100644 --- a/backend/src/model/config/library.rs +++ b/backend/src/model/config/library.rs @@ -34,6 +34,13 @@ pub struct LibraryPlaylistConfig { pub series_category: Arc, } +#[derive(Debug, Clone)] +pub struct ThumbnailConfig { + pub enabled: bool, + pub width: u32, + pub height: u32, +} + #[derive(Debug, Clone)] pub struct LibraryConfig { pub enabled: bool, @@ -41,6 +48,7 @@ pub struct LibraryConfig { pub supported_extensions: Vec, pub metadata: LibraryMetadataConfig, pub playlist: LibraryPlaylistConfig, + pub thumbnails: ThumbnailConfig, } impl LibraryConfig { @@ -110,6 +118,11 @@ impl From<&LibraryConfigDto> for LibraryConfig { movie_category: dto.playlist.movie_category.as_str().intern(), series_category: dto.playlist.series_category.as_str().intern(), }, + thumbnails: ThumbnailConfig { + enabled: dto.thumbnails.enabled, + width: dto.thumbnails.width, + height: dto.thumbnails.height, + }, } } } diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 93df1f19b..33c73491b 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -1,19 +1,36 @@ -use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; +use crate::library::{EpisodeMetadata, MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; use crate::library::resolve_metadata_storage_path; use crate::model::{AppConfig, ConfigInput}; use shared::concat_string; use shared::error::TuliproxError; use shared::model::UUIDType; use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, normalize_episode_title}; -use shared::utils::{generate_playlist_uuid, Internable}; +use shared::utils::{concat_path_leading_slash, generate_playlist_uuid, Internable}; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; +fn thumbnail_url(entry: &MetadataCacheEntry, api_base_path: &str) -> Option { + entry.thumbnail_hash + .as_ref() + .map(|_| concat_path_leading_slash(api_base_path, &format!("library/thumbnail/{}", entry.uuid))) +} + +fn episode_thumbnail_url(episode: &EpisodeMetadata, api_base_path: &str) -> Option { + episode + .thumbnail_id + .as_deref() + .map(|id| concat_path_leading_slash(api_base_path, &format!("library/thumbnail/{id}"))) +} + pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> (Vec, Vec) { let config = &*app_config.config.load(); let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) }; if !library_config.enabled { return (vec![], vec![]); } + let api_base_path = concat_path_leading_slash( + config.web_ui.as_ref().and_then(|w| w.path.as_deref()).unwrap_or(""), + "api/v1", + ); let storage_path = resolve_metadata_storage_path(config.metadata_update.as_ref(), &config.storage_dir) .join("library"); @@ -33,10 +50,10 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A while let Some(entry) = metadata_iter.next().await { match entry.metadata { MediaMetadata::Movie(_) => { - to_playlist_item(&entry, &input.name, &library_config.playlist.movie_category, &mut group_movies.channels); + to_playlist_item(&entry, &input.name, &library_config.playlist.movie_category, &api_base_path, &mut group_movies.channels); } MediaMetadata::Series(_) => { - to_playlist_item(&entry, &input.name, &library_config.playlist.series_category, &mut group_series.channels); + to_playlist_item(&entry, &input.name, &library_config.playlist.series_category, &api_base_path, &mut group_series.channels); } } } @@ -52,96 +69,17 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A (groups, vec![]) } - -fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &Arc, group_name: &str, channels: &mut Vec) { - let metadata = &entry.metadata; - - match metadata { - MediaMetadata::Movie(_) => { - let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry); - let title = metadata.title().intern(); - let group = group_name.intern(); - channels.push(PlaylistItem { - header: PlaylistItemHeader { - uuid: UUIDType::from_valid_uuid(&entry.uuid), - name: Arc::clone(&title), - title, - group, - logo: metadata.poster().unwrap_or("").intern(), - url: concat_string!("file://", &entry.file_path).into(), - xtream_cluster: XtreamCluster::Video, - additional_properties, - item_type: PlaylistItemType::LocalVideo, - input_name: Arc::clone(input_name), - ..PlaylistItemHeader::default() - } - }); - } - MediaMetadata::Series(_series) => { - if let Some(additional_properties) = metadata_cache_entry_to_xtream_series_info(entry) { - let mut episodes = vec![]; - let group_arc: Arc = group_name.intern(); - if let StreamProperties::Series(series_properties) = &additional_properties { - if let Some(details_props) = series_properties.details.as_ref() { - if let Some(prop_episodes) = details_props.episodes.as_ref() { - for episode in prop_episodes { - let logo: Arc = if episode.movie_image.is_empty() { metadata.poster().unwrap_or("").intern() } else { episode.movie_image.clone() }; - let container_extension = Path::new(&*episode.direct_source) - .extension() - .and_then(|s| s.to_str()) - .map(ToString::to_string).unwrap_or_default(); - episodes.push(PlaylistItem { - header: PlaylistItemHeader { - id: episode.id.to_string().into(), - // we use parent_code for local series to find the parent series info and straighten the virtual_ids - parent_code: entry.uuid.clone().into(), - uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source), - logo: logo.clone(), - name: episode.title.clone(), - group: Arc::clone(&group_arc), - title: episode.title.clone(), - url: episode.direct_source.clone(), - xtream_cluster: XtreamCluster::Series, - item_type: PlaylistItemType::LocalSeries, - category_id: 0, - input_name: Arc::clone(input_name), - additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { - episode_id: episode.id, - episode: episode.episode_num, - season: episode.season, - added: Some(episode.added.clone()), - release_date: Some(episode.release_date.clone()), - series_release_date: None, - tmdb: episode.tmdb, - movie_image: logo, - container_extension: container_extension.intern(), - audio: None, - video: None, - }))), - ..Default::default() - } - }); - } - } - } - } - - let series_info = PlaylistItem { - header: PlaylistItemHeader { - uuid: UUIDType::from_valid_uuid(&entry.uuid), - id: entry.uuid.clone().into(), - name: metadata.title().intern(), - group: group_name.intern(), - title: metadata.title().intern(), - logo: metadata.poster().unwrap_or("").intern(), - url: concat_string!("file://", &entry.file_path).into(), - xtream_cluster: XtreamCluster::Series, - item_type: PlaylistItemType::LocalSeriesInfo, - input_name: Arc::clone(input_name), - additional_properties: Some(additional_properties), - ..PlaylistItemHeader::default() - } - }; +fn to_playlist_item( + entry: &MetadataCacheEntry, + input_name: &Arc, + group_name: &str, + api_base_path: &str, + channels: &mut Vec, +) { + match &entry.metadata { + MediaMetadata::Movie(_) => channels.push(build_movie_playlist_item(entry, input_name, group_name, api_base_path)), + MediaMetadata::Series(_) => { + if let Some((series_info, episodes)) = build_series_playlist_items(entry, input_name, group_name, api_base_path) { channels.push(series_info); channels.extend(episodes); } @@ -149,14 +87,175 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &Arc, group_nam } } +fn build_movie_playlist_item( + entry: &MetadataCacheEntry, + input_name: &Arc, + group_name: &str, + api_base_path: &str, +) -> PlaylistItem { + let metadata = &entry.metadata; + let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry, api_base_path); + let title = metadata.title().intern(); + let group = group_name.intern(); + + PlaylistItem { + header: PlaylistItemHeader { + uuid: UUIDType::from_valid_uuid(&entry.uuid), + name: Arc::clone(&title), + title, + group, + logo: metadata + .poster() + .or(thumbnail_url(entry, api_base_path).as_deref()) + .unwrap_or("") + .intern(), + url: concat_string!("file://", &entry.file_path).into(), + xtream_cluster: XtreamCluster::Video, + additional_properties, + item_type: PlaylistItemType::LocalVideo, + input_name: Arc::clone(input_name), + ..PlaylistItemHeader::default() + }, + } +} + +fn build_series_playlist_items( + entry: &MetadataCacheEntry, + input_name: &Arc, + group_name: &str, + api_base_path: &str, +) -> Option<(PlaylistItem, Vec)> { + let additional_properties = metadata_cache_entry_to_xtream_series_info(entry, api_base_path)?; + let series_info = build_series_info_playlist_item(entry, input_name, group_name, api_base_path, &additional_properties); + let episodes = build_series_episode_playlist_items(entry, input_name, group_name, api_base_path, &additional_properties); + Some((series_info, episodes)) +} + +fn build_series_info_playlist_item( + entry: &MetadataCacheEntry, + input_name: &Arc, + group_name: &str, + api_base_path: &str, + additional_properties: &StreamProperties, +) -> PlaylistItem { + let metadata = &entry.metadata; + + PlaylistItem { + header: PlaylistItemHeader { + uuid: UUIDType::from_valid_uuid(&entry.uuid), + id: entry.uuid.clone().into(), + name: metadata.title().intern(), + group: group_name.intern(), + title: metadata.title().intern(), + logo: metadata + .poster() + .or(thumbnail_url(entry, api_base_path).as_deref()) + .unwrap_or("") + .intern(), + url: concat_string!("file://", &entry.file_path).into(), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::LocalSeriesInfo, + input_name: Arc::clone(input_name), + additional_properties: Some(additional_properties.clone()), + ..PlaylistItemHeader::default() + }, + } +} + +fn build_series_episode_playlist_items( + entry: &MetadataCacheEntry, + input_name: &Arc, + group_name: &str, + api_base_path: &str, + additional_properties: &StreamProperties, +) -> Vec { + let metadata = &entry.metadata; + let group_arc: Arc = group_name.intern(); + + match additional_properties { + StreamProperties::Series(series_properties) => series_properties + .details + .as_ref() + .and_then(|details| details.episodes.as_ref()) + .map(|episodes| { + episodes + .iter() + .map(|episode| build_series_episode_playlist_item(entry, metadata, input_name, &group_arc, api_base_path, episode)) + .collect() + }) + .unwrap_or_default(), + _ => vec![], + } +} + +fn build_series_episode_playlist_item( + entry: &MetadataCacheEntry, + metadata: &MediaMetadata, + input_name: &Arc, + group_name: &Arc, + api_base_path: &str, + episode: &SeriesStreamDetailEpisodeProperties, +) -> PlaylistItem { + let logo: Arc = if episode.movie_image.is_empty() { + metadata + .poster() + .or(thumbnail_url(entry, api_base_path).as_deref()) + .unwrap_or("") + .intern() + } else { + episode.movie_image.clone() + }; + let container_extension = Path::new(&*episode.direct_source) + .extension() + .and_then(|s| s.to_str()) + .map(ToString::to_string) + .unwrap_or_default(); + + PlaylistItem { + header: PlaylistItemHeader { + id: episode.id.to_string().into(), + parent_code: entry.uuid.clone().into(), + uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source), + logo: logo.clone(), + name: episode.title.clone(), + group: Arc::clone(group_name), + title: episode.title.clone(), + url: episode.direct_source.clone(), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::LocalSeries, + category_id: 0, + input_name: Arc::clone(input_name), + additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { + episode_id: episode.id, + episode: episode.episode_num, + season: episode.season, + added: Some(episode.added.clone()), + release_date: Some(episode.release_date.clone()), + series_release_date: None, + tmdb: episode.tmdb, + movie_image: logo, + container_extension: container_extension.intern(), + audio: None, + video: None, + }))), + ..Default::default() + }, + } +} + pub fn metadata_cache_entry_to_xtream_movie_info( entry: &MetadataCacheEntry, + api_base_path: &str, ) -> Option { let movie = match &entry.metadata { MediaMetadata::Movie(m) => m, MediaMetadata::Series(_) => return None, }; + let binding = thumbnail_url(entry, api_base_path); + let thumbnail_poster = binding.as_deref(); + let poster = movie.poster.as_deref().or(movie.fanart.as_deref()).or(thumbnail_poster); + let container_extension = Path::new(&entry.file_path) .extension() .and_then(|s| s.to_str()) @@ -168,7 +267,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info( name: movie.title.clone().into(), category_id: 0, stream_id: 0, - stream_icon: movie.poster.as_deref().or(movie.fanart.as_deref()).unwrap_or("").to_owned().into(), + stream_icon: poster.unwrap_or("").to_owned().into(), direct_source: "".into(), custom_sid: None, added: entry.file_modified.intern(), @@ -182,8 +281,8 @@ pub fn metadata_cache_entry_to_xtream_movie_info( details: Some(VideoStreamDetailProperties { kinopoisk_url: movie.tmdb_id.map(|id| concat_string!("https://www.themoviedb.org/movie/", &id.to_string()).into()), o_name: movie.original_title.clone().map(Into::into), - cover_big: movie.poster.clone().map(Into::into), - movie_image: movie.poster.clone().map(Into::into), + cover_big: poster.map(Into::into), + movie_image: poster.map(Into::into), release_date: movie.year.map(|y| format!("{y}-01-01").into()), episode_run_time: movie.runtime, director: movie.directors.as_ref().map(|d| d.join(", ").into()), @@ -202,12 +301,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info( .as_ref() .filter(|s| !s.is_empty()) .map(|f| vec![f.clone().into()]) - .or_else(|| { - movie.poster - .as_ref() - .filter(|s| !s.is_empty()) - .map(|p| vec![p.clone().into()]) - }), + .or_else(|| poster.map(|p| vec![p.into()])), duration_secs: movie.runtime.map(|r| (r * 60).to_string().into()), duration: movie.runtime.map(|r| { let h = r / 60; @@ -229,6 +323,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info( #[allow(clippy::too_many_lines)] pub fn metadata_cache_entry_to_xtream_series_info( entry: &MetadataCacheEntry, + api_base_path: &str, ) -> Option { let series = match &entry.metadata { MediaMetadata::Movie(_) => return None, @@ -299,7 +394,12 @@ pub fn metadata_cache_entry_to_xtream_series_info( duration: episode.runtime .map(|r| format!("{:02}:{:02}:00", r / 60, r % 60)) .unwrap_or_default().into(), - movie_image: episode.thumb.clone().unwrap_or_default().into(), + movie_image: episode + .thumb + .clone() + .or_else(|| episode_thumbnail_url(episode, api_base_path)) + .unwrap_or_default() + .into(), audio: None, video: None, bitrate: 0, @@ -347,3 +447,59 @@ pub fn metadata_cache_entry_to_xtream_series_info( Some(StreamProperties::Series(Box::new(properties))) } + +#[cfg(test)] +mod tests { + use super::{metadata_cache_entry_to_xtream_series_info, thumbnail_url}; + use crate::library::{MediaMetadata, MetadataCacheEntry, MovieMetadata, SeriesMetadata, EpisodeMetadata}; + use shared::model::StreamProperties; + + #[test] + fn thumbnail_url_uses_v1_api_prefix() { + let mut entry = MetadataCacheEntry::new( + "/tmp/video.mkv".to_string(), + 123, + 456, + MediaMetadata::Movie(MovieMetadata::default()), + ); + entry.uuid = "test-uuid-123".to_string(); + entry.thumbnail_hash = Some("thumb-hash".to_string()); + + assert_eq!( + thumbnail_url(&entry, "/api/v1").as_deref(), + Some("/api/v1/library/thumbnail/test-uuid-123"), + ); + } + + #[test] + fn series_episode_uses_local_thumbnail_when_episode_thumb_missing() { + let entry = MetadataCacheEntry::new( + "/tmp/show/S01E01.mkv".to_string(), + 123, + 456, + MediaMetadata::Series(SeriesMetadata { + title: "Test Series".to_string(), + episodes: Some(vec![EpisodeMetadata { + title: "Episode 1".to_string(), + season: 1, + episode: 1, + file_path: "/tmp/show/S01E01.mkv".to_string(), + thumbnail_id: Some("31fedbc18dca3fa273fba98afda584486ad4f1d8e1ca06740435b97b14f2ec8b".to_string()), + ..EpisodeMetadata::default() + }]), + ..SeriesMetadata::default() + }), + ); + + let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1") else { + panic!("expected series stream properties"); + }; + let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("episodes missing"); + let episode = episodes.first().expect("episode missing"); + + assert_eq!( + episode.movie_image.as_ref(), + "/api/v1/library/thumbnail/31fedbc18dca3fa273fba98afda584486ad4f1d8e1ca06740435b97b14f2ec8b", + ); + } +} diff --git a/docs/src/configuration/local-library.md b/docs/src/configuration/local-library.md index 5554eb935..fc8990b10 100644 --- a/docs/src/configuration/local-library.md +++ b/docs/src/configuration/local-library.md @@ -60,8 +60,33 @@ library: playlist: movie_category: "Local Movies" series_category: "Local Shows" + thumbnails: + enabled: true + width: 320 + height: 180 ``` +### Thumbnail Extraction + +When TMDB posters are unavailable, Tuliprox can automatically extract a thumbnail +from the video file itself using `ffmpeg`. The extracted frame is cached and served +via a local API endpoint. + +**Requires:** `ffmpeg` must be installed and available in the system `PATH`. + +| Parameter | Type | Default | Description | +|:----------|:-------|:--------|:--------------------------------------| +| `enabled` | Bool | `false` | Enable automatic thumbnail extraction | +| `width` | Number | `320` | Output width in pixels (16:9) | +| `height` | Number | `180` | Output height in pixels | + +Thumbnails are extracted at ~10 seconds into the video (falls back to 0s for short clips). +They are re-extracted automatically when the source file changes. Orphaned thumbnails +are cleaned up during each library scan. + +Remote URL thumbnail extraction via HTTP range requests is not implemented yet. +That remains a future feature. + ### Library Configuration Parameters | Block / Parameter | Type | Default | Description | @@ -127,10 +152,13 @@ your disk when requested by the IPTV player! The MediaClassifier logic respects the `content_type` defined for configured directories. -* **auto** (default): Uses auto-detection to determine if a file is a movie or a series based on filename patterns (e.g. `S01E01`). +* **auto** (default): Uses auto-detection to determine if a file is a movie or a series based on filename patterns (e.g. + `S01E01`). * **movie**: Forces all files to be classified as movies, overriding any auto-detection results. -* **series**: Forces classification as a series. If episode/season patterns are detected in the filename (e.g. `S02E05`), those values are used. - If no pattern is found, the file is still classified as a series with **season 1** and an **auto-incremented episode number** +* **series**: Forces classification as a series. If episode/season patterns are detected in the filename (e.g. + `S02E05`), those values are used. + If no pattern is found, the file is still classified as a series with **season 1** and an **auto-incremented episode + number** (starting at 1, in file scan order). ### Triggering Scans (CLI & API) diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index cf5377d38..66267b8d4 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -1162,6 +1162,10 @@ "TEMPLATE_PATH": "Template Path", "THROTTLE": "Throttle", "THROTTLE_KBPS": "Throttle kbps", + "THUMBNAILS": "Thumbnails", + "WIDTH": "Width", + "HEIGHT": "Height", + "QUALITY": "Quality", "TIMEZONE": "Timezone", "TMDB": "TMDB", "TOKEN": "Token", diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index d64fa2a83..8325d394b 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -1,7 +1,10 @@ use crate::app::components::config::config_page::ConfigForm; -use shared::model::{ - ConfigDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, LibraryConfigDto, LibraryMetadataConfigDto, - LibraryPlaylistConfigDto, WebAuthConfigDto, WebUiConfigDto, +use shared::{ + model::{ + ConfigDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, LibraryConfigDto, LibraryMetadataConfigDto, + LibraryPlaylistConfigDto, ThumbnailConfigDto, WebAuthConfigDto, WebUiConfigDto, + }, + utils::is_default_supported_library_extensions, }; macro_rules! set_config_field { @@ -17,9 +20,10 @@ macro_rules! set_config_field { fn is_library_toggle_only_update(cfg: &LibraryConfigDto) -> bool { cfg.scan_directories.is_empty() - && cfg.supported_extensions.is_empty() + && (cfg.supported_extensions.is_empty() || is_default_supported_library_extensions(&cfg.supported_extensions)) && cfg.metadata == LibraryMetadataConfigDto::default() && cfg.playlist == LibraryPlaylistConfigDto::default() + && cfg.thumbnails == ThumbnailConfigDto::default() } fn update_hdhomerun_field(config: &mut ConfigDto, mut hdhr_cfg: HdHomeRunConfigDto) { @@ -137,6 +141,7 @@ mod tests { let library = config.library.expect("library config should be preserved"); assert!(!library.enabled); + assert!(!library.thumbnails.enabled); assert_eq!(library.scan_directories.len(), 1); assert_eq!(library.scan_directories[0].path, "/media"); } diff --git a/frontend/src/app/components/config/library_config_view.rs b/frontend/src/app/components/config/library_config_view.rs index fef6cc5d1..a7cf08635 100644 --- a/frontend/src/app/components/config/library_config_view.rs +++ b/frontend/src/app/components/config/library_config_view.rs @@ -12,13 +12,13 @@ use crate::{ }, context::ConfigContext, }, - config_field, config_field_bool, config_field_child, edit_field_bool, edit_field_list, edit_field_text, - generate_form_reducer, + config_field, config_field_bool, config_field_child, edit_field_bool, edit_field_list, edit_field_number_u32, + edit_field_text, generate_form_reducer, i18n::use_translation, }; use shared::model::{ LibraryConfigDto, LibraryContentType, LibraryMetadataConfigDto, LibraryMetadataFormat, - LibraryMetadataReadConfigDto, LibraryPlaylistConfigDto, LibraryScanDirectoryDto, + LibraryMetadataReadConfigDto, LibraryPlaylistConfigDto, LibraryScanDirectoryDto, ThumbnailConfigDto, }; use std::rc::Rc; use yew::prelude::*; @@ -45,7 +45,9 @@ const LABEL_MOVIE: &str = "LABEL.MOVIE"; const LABEL_SERIES: &str = "LABEL.SERIES"; const LABEL_FORMATS: &str = "LABEL.FORMATS"; const LABEL_ADD_FORMAT: &str = "LABEL.ADD_FORMAT"; - +const LABEL_THUMBNAILS: &str = "LABEL.THUMBNAILS"; +const LABEL_WIDTH: &str = "LABEL.WIDTH"; +const LABEL_HEIGHT: &str = "LABEL.HEIGHT"; const TYPE_AUTO: &str = "Auto"; const TYPE_MOVIE: &str = "Movie"; const TYPE_SERIES: &str = "Series"; @@ -88,6 +90,16 @@ generate_form_reducer!( } ); +generate_form_reducer!( + state: ThumbnailConfigFormState { form: ThumbnailConfigDto }, + action_name: ThumbnailConfigFormAction, + fields { + Enabled => enabled: bool, + Width => width: u32, + Height => height: u32, + } +); + #[component] pub fn LibraryConfigView() -> Html { let translate = use_translation(); @@ -106,12 +118,15 @@ pub fn LibraryConfigView() -> Html { let metadata_read_state: UseReducerHandle = use_reducer(|| { LibraryMetadataReadConfigFormState { form: LibraryMetadataReadConfigDto::default(), modified: false } }); + let thumbnail_state: UseReducerHandle = + use_reducer(|| ThumbnailConfigFormState { form: ThumbnailConfigDto::default(), modified: false }); { let form_state = form_state.clone(); let playlist_state = playlist_state.clone(); let metadata_state = metadata_state.clone(); let metadata_read_state = metadata_read_state.clone(); + let thumbnail_state = thumbnail_state.clone(); let library_cfg = config_ctx.config.as_ref().and_then(|c| c.config.library.clone()); use_effect_with((library_cfg, *config_view_ctx.edit_mode), move |(library_cfg, _mode)| { @@ -121,12 +136,14 @@ pub fn LibraryConfigView() -> Html { metadata_state.dispatch(LibraryMetadataConfigFormAction::SetAll(library.metadata.clone())); metadata_read_state .dispatch(LibraryMetadataReadConfigFormAction::SetAll(library.metadata.read_existing.clone())); + thumbnail_state.dispatch(ThumbnailConfigFormAction::SetAll(library.thumbnails.clone())); } else { form_state.dispatch(LibraryConfigFormAction::SetAll(LibraryConfigDto::default())); playlist_state.dispatch(LibraryPlaylistConfigFormAction::SetAll(LibraryPlaylistConfigDto::default())); metadata_state.dispatch(LibraryMetadataConfigFormAction::SetAll(LibraryMetadataConfigDto::default())); metadata_read_state .dispatch(LibraryMetadataReadConfigFormAction::SetAll(LibraryMetadataReadConfigDto::default())); + thumbnail_state.dispatch(ThumbnailConfigFormAction::SetAll(ThumbnailConfigDto::default())); } || () }); @@ -139,10 +156,12 @@ pub fn LibraryConfigView() -> Html { playlist_state.form.clone(), metadata_state.form.clone(), metadata_read_state.form.clone(), + thumbnail_state.form.clone(), form_state.modified, playlist_state.modified, metadata_state.modified, metadata_read_state.modified, + thumbnail_state.modified, ), config_view_ctx.on_form_change.clone(), |( @@ -150,16 +169,23 @@ pub fn LibraryConfigView() -> Html { playlist, metadata, metadata_read, + thumbnails, form_modified, playlist_modified, metadata_modified, metadata_read_modified, + thumbnail_modified, )| { let mut new_form = form; new_form.playlist = playlist; new_form.metadata = metadata; new_form.metadata.read_existing = metadata_read; - let modified = form_modified || playlist_modified || metadata_modified || metadata_read_modified; + new_form.thumbnails = thumbnails; + let modified = form_modified + || playlist_modified + || metadata_modified + || metadata_read_modified + || thumbnail_modified; ConfigForm::Library(modified, new_form) }, @@ -365,6 +391,7 @@ pub fn LibraryConfigView() -> Html { let metadata = &metadata_state.form; let playlist = &playlist_state.form; let read_existing = &metadata_read_state.form; + let thumbnails = &thumbnail_state.form; html! { <> @@ -398,6 +425,13 @@ pub fn LibraryConfigView() -> Html { { config_field!(playlist, translate.t(LABEL_MOVIE_CATEGORY), movie_category) } { config_field!(playlist, translate.t(LABEL_SERIES_CATEGORY), series_category) } + + +

{translate.t(LABEL_THUMBNAILS)}

+ { config_field_bool!(thumbnails, translate.t(LABEL_ENABLED), enabled) } + { config_field!(thumbnails, translate.t(LABEL_WIDTH), width) } + { config_field!(thumbnails, translate.t(LABEL_HEIGHT), height) } +
} @@ -462,6 +496,13 @@ pub fn LibraryConfigView() -> Html { { edit_field_text!(playlist_state, translate.t(LABEL_MOVIE_CATEGORY), movie_category, LibraryPlaylistConfigFormAction::MovieCategory) } { edit_field_text!(playlist_state, translate.t(LABEL_SERIES_CATEGORY), series_category, LibraryPlaylistConfigFormAction::SeriesCategory) } + + +

{translate.t(LABEL_THUMBNAILS)}

+ { edit_field_bool!(thumbnail_state, translate.t(LABEL_ENABLED), enabled, ThumbnailConfigFormAction::Enabled) } + { edit_field_number_u32!(thumbnail_state, translate.t(LABEL_WIDTH), width, ThumbnailConfigFormAction::Width) } + { edit_field_number_u32!(thumbnail_state, translate.t(LABEL_HEIGHT), height, ThumbnailConfigFormAction::Height) } +
} diff --git a/frontend/src/app/components/setup/setup_helpers.rs b/frontend/src/app/components/setup/setup_helpers.rs index b3ef899af..b74d8d024 100644 --- a/frontend/src/app/components/setup/setup_helpers.rs +++ b/frontend/src/app/components/setup/setup_helpers.rs @@ -8,9 +8,9 @@ use shared::{ ApiProxyConfigDto, AppConfigDto, ConfigInputDto, ConfigTargetDto, ContentSecurityPolicyConfigDto, HdHomeRunConfigDto, LibraryConfigDto, LibraryMetadataConfigDto, LibraryPlaylistConfigDto, LogConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, SourcesConfigDto, StreamConfigDto, TargetOutputDto, - TargetUserDto, WebAuthConfigDto, WebUiConfigDto, + TargetUserDto, ThumbnailConfigDto, WebAuthConfigDto, WebUiConfigDto, }, - utils::default_secret, + utils::{default_secret, is_default_supported_library_extensions}, }; use std::{ collections::HashMap, @@ -202,9 +202,10 @@ fn apply_setup_config_forms(config: &mut shared::model::ConfigDto, forms: Vec bool { cfg.scan_directories.is_empty() - && cfg.supported_extensions.is_empty() + && (cfg.supported_extensions.is_empty() || is_default_supported_library_extensions(&cfg.supported_extensions)) && cfg.metadata == LibraryMetadataConfigDto::default() && cfg.playlist == LibraryPlaylistConfigDto::default() + && cfg.thumbnails == ThumbnailConfigDto::default() } fn apply_setup_hdhomerun_form(config: &mut shared::model::ConfigDto, hdhr_cfg: HdHomeRunConfigDto) { @@ -587,6 +588,7 @@ mod tests { let app_cfg = build_setup_app_config(&config_ctx, &form_state, SourcesConfigDto::default()); let library = app_cfg.config.library.expect("library config should be present"); assert!(!library.enabled); + assert!(!library.thumbnails.enabled); assert_eq!(library.scan_directories.len(), 1); assert_eq!(library.scan_directories[0].path, "/media"); } diff --git a/shared/src/model/config/library.rs b/shared/src/model/config/library.rs index be715cfcd..4ee8b344e 100644 --- a/shared/src/model/config/library.rs +++ b/shared/src/model/config/library.rs @@ -3,12 +3,13 @@ use crate::{ info_err_res, utils::{ default_as_true, default_movie_category, default_series_category, default_storage_formats, - default_supported_library_extensions, is_default_supported_library_extensions, is_true, + default_supported_library_extensions, default_thumbnail_height, default_thumbnail_width, + is_default_supported_library_extensions, is_true, }, }; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct LibraryConfigDto { #[serde(default)] @@ -24,6 +25,8 @@ pub struct LibraryConfigDto { pub metadata: LibraryMetadataConfigDto, #[serde(default)] pub playlist: LibraryPlaylistConfigDto, + #[serde(default, skip_serializing_if = "ThumbnailConfigDto::is_empty")] + pub thumbnails: ThumbnailConfigDto, } impl LibraryConfigDto { @@ -33,6 +36,7 @@ impl LibraryConfigDto { && is_default_supported_library_extensions(&self.supported_extensions) && self.metadata.is_empty() && self.playlist.is_empty() + && self.thumbnails.is_empty() } pub fn clean(&mut self) { self.scan_directories.retain(|d| !d.path.trim().is_empty()); @@ -130,16 +134,57 @@ impl LibraryPlaylistConfigDto { pub fn clean(&mut self) { self.prepare(); } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ThumbnailConfigDto { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_thumbnail_width")] + pub width: u32, + #[serde(default = "default_thumbnail_height")] + pub height: u32, +} + +impl Default for ThumbnailConfigDto { + fn default() -> Self { + Self { enabled: false, width: default_thumbnail_width(), height: default_thumbnail_height() } + } +} + +impl ThumbnailConfigDto { + pub fn is_empty(&self) -> bool { + !self.enabled && self.width == default_thumbnail_width() && self.height == default_thumbnail_height() + } +} + impl Default for LibraryPlaylistConfigDto { fn default() -> Self { Self { movie_category: default_movie_category(), series_category: default_series_category() } } } +impl Default for LibraryConfigDto { + fn default() -> Self { + Self { + enabled: false, + scan_directories: Vec::new(), + supported_extensions: default_supported_library_extensions(), + metadata: LibraryMetadataConfigDto::default(), + playlist: LibraryPlaylistConfigDto::default(), + thumbnails: ThumbnailConfigDto::default(), + } + } +} + impl LibraryConfigDto { pub fn prepare(&mut self) -> Result<(), TuliproxError> { self.playlist.prepare(); + // Restore default extensions if none configured + if self.supported_extensions.is_empty() { + self.supported_extensions = default_supported_library_extensions(); + } + // Validate enabled state if self.enabled && self.scan_directories.is_empty() { return info_err_res!("Library enabled but no scan_directories configured"); diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 53af96f4c..a998736df 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -644,6 +644,11 @@ impl XtreamMappingOptions { #[inline] pub fn is_reverse(&self, item_type: PlaylistItemType) -> bool { self.reverse_item_types.is_set(item_type) } + fn is_trusted_web_ui_resource_path(resource_url: &str) -> bool { + const TRUSTED_WEB_UI_RESOURCE_PREFIXES: [&str; 1] = ["/api/v1/library/thumbnail/"]; + TRUSTED_WEB_UI_RESOURCE_PREFIXES.iter().any(|prefix| resource_url.contains(prefix)) + } + fn build_reverse_proxy_base_url( &self, xtream_cluster: XtreamCluster, @@ -677,6 +682,9 @@ impl XtreamMappingOptions { if resource_url.is_empty() { return resource_url.to_string(); } + if Self::is_trusted_web_ui_resource_path(resource_url) { + return resource_url.to_string(); + } let rewrite_url = concat_path(&self.base_url, &obfuscate_text(&self.encrypt_secret, resource_url)); return rewrite_url; } @@ -703,6 +711,9 @@ impl XtreamMappingOptions { if resource_url.is_empty() { return resource_url.to_string(); } + if Self::is_trusted_web_ui_resource_path(resource_url) { + return resource_url.to_string(); + } let rewrite_url = concat_path(&self.base_url, &obfuscate_text(&self.encrypt_secret, resource_url)); return rewrite_url; } @@ -718,6 +729,80 @@ impl XtreamMappingOptions { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{PlaylistItemType, XtreamCluster, XtreamMappingFlags}; + + fn sample_options() -> XtreamMappingOptions { + XtreamMappingOptions { + base_url: "/api/v1/playlist/resource".to_string(), + username: "user".to_string(), + password: "pass".to_string(), + force_redirect: None, + reverse_item_types: PlaylistItemTypeSet::from_item(PlaylistItemType::Live), + web_ui_request: true, + flags: XtreamMappingFlags::RewriteResourceUrl.into(), + encrypt_secret: [3u8; 16], + } + } + + #[test] + fn get_resource_url_keeps_internal_paths_for_web_ui_requests() { + let options = sample_options(); + + assert_eq!( + options.get_resource_url( + XtreamCluster::Series, + PlaylistItemType::Series, + 1, + "/api/v1/library/thumbnail/abc", + "logo", + ), + "/api/v1/library/thumbnail/abc", + ); + } + + #[test] + fn get_bd_path_resource_url_keeps_internal_thumbnail_paths_for_web_ui_requests() { + let options = sample_options(); + + assert_eq!( + options.get_bd_path_resource_url( + XtreamCluster::Series, + PlaylistItemType::Series, + 1, + "/api/v1/library/thumbnail/backdrop", + "backdrop_", + 0, + ), + "/api/v1/library/thumbnail/backdrop", + ); + } + + #[test] + fn get_resource_url_obfuscates_protocol_relative_urls_for_web_ui_requests() { + let options = sample_options(); + let resource_url = "//cdn.example.com/poster.jpg"; + + assert_eq!( + options.get_resource_url(XtreamCluster::Series, PlaylistItemType::Series, 1, resource_url, "logo",), + concat_path(&options.base_url, &obfuscate_text(&options.encrypt_secret, resource_url)), + ); + } + + #[test] + fn get_resource_url_does_not_bypass_untrusted_root_relative_paths_for_web_ui_requests() { + let options = sample_options(); + let resource_url = "/provider-controlled/poster.jpg"; + + assert_eq!( + options.get_resource_url(XtreamCluster::Series, PlaylistItemType::Series, 1, resource_url, "logo",), + concat_path(&options.base_url, &obfuscate_text(&options.encrypt_secret, resource_url)), + ); + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XtreamPlaylistItem { pub virtual_id: VirtualId, diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index 61475cde9..f9a079cff 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -130,6 +130,10 @@ pub fn default_storage_formats() -> Vec { vec![] } pub fn default_movie_category() -> String { String::from("Local Movies") } pub fn default_series_category() -> String { String::from("Local TV Shows") } +pub fn default_thumbnail_width() -> u32 { 320 } +pub fn default_thumbnail_height() -> u32 { 180 } +pub fn default_thumbnail_quality() -> u8 { 75 } + pub const DEFAULT_SUPPORTED_LIBRARY_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "ts", "m4v", "webm"]; pub fn default_supported_library_extensions() -> Vec {