Feature/local media (#671)

* local library default extensions
* series info generation
* local library series episodes
This commit is contained in:
euzu
2026-03-26 23:32:22 +01:00
committed by GitHub
parent f1e96cd4cd
commit 69e7ae8680
21 changed files with 1339 additions and 167 deletions
@@ -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<ConfigInput>>,
app_state: &Arc<AppState>,
+70 -6
View File
@@ -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<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
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<Arc<AppState>>,
@@ -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)),
}
}
}
+3 -1
View File
@@ -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<AppState>) -> Option<(Option<String>, Option<String>)> {
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)
+9 -1
View File
@@ -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<f64>,
#[serde(default)]
pub thumb: Option<String>,
#[serde(default)]
pub thumbnail_id: Option<String>,
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<String>,
#[serde(default)]
pub thumbnail_mtime: Option<i64>,
}
impl MetadataCacheEntry {
@@ -286,6 +292,8 @@ impl MetadataCacheEntry {
file_size,
file_modified,
metadata,
thumbnail_hash: None,
thumbnail_mtime: None,
}
}
+5
View File
@@ -246,6 +246,11 @@ mod tests {
movie_category: "Movies".intern(),
series_category: "Series".intern(),
},
thumbnails: crate::model::ThumbnailConfig {
enabled: false,
width: 320,
height: 180,
},
}
}
+240 -6
View File
@@ -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<Mutex<()>>,
}
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<MetadataCacheEntry> {
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<usize> {
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<String> = 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<PathBuf> {
// 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<String> {
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);
}
}
+2
View File
@@ -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::*;
+226 -25
View File
@@ -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<ThumbnailExtractor>,
app_config: Option<Arc<AppConfig>>, // 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<String, MetadataCacheEntry>, force_rescan: bool, _can_probe: bool) -> Result<ProcessAction, String> {
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<String>, 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<i64>,
) {
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<i64>,
) -> Option<String> {
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<u32> = 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);
}
}
+83 -6
View File
@@ -54,20 +54,17 @@ pub struct SeriesEpisodeFile {
pub file: ScannedMediaFile,
pub season: u32,
pub episode: u32,
pub auto_assigned_episode: bool,
pub metadata: Box<PttMetadata>,
}
pub struct MediaGrouper;
impl MediaGrouper {
pub fn group(mut files: Vec<ScannedMediaFile>) -> Vec<MediaGroup> {
pub fn group(files: Vec<ScannedMediaFile>) -> Vec<MediaGroup> {
let mut series_map: HashMap<SeriesKey, Vec<SeriesEpisodeFile>> = 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<usize> = 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);
}
}
+144
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<std::process::Output, String> {
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");
}
}
+1 -1
View File
@@ -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)
// }
}
+13
View File
@@ -34,6 +34,13 @@ pub struct LibraryPlaylistConfig {
pub series_category: Arc<str>,
}
#[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<String>,
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,
},
}
}
}
+260 -104
View File
@@ -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<String> {
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<String> {
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<AppConfig>, input: &ConfigInput) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
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<str>, group_name: &str, channels: &mut Vec<PlaylistItem>) {
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<str> = 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<str> = 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<str>,
group_name: &str,
api_base_path: &str,
channels: &mut Vec<PlaylistItem>,
) {
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<str>, group_nam
}
}
fn build_movie_playlist_item(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
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<str>,
group_name: &str,
api_base_path: &str,
) -> Option<(PlaylistItem, Vec<PlaylistItem>)> {
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<str>,
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<str>,
group_name: &str,
api_base_path: &str,
additional_properties: &StreamProperties,
) -> Vec<PlaylistItem> {
let metadata = &entry.metadata;
let group_arc: Arc<str> = 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<str>,
group_name: &Arc<str>,
api_base_path: &str,
episode: &SeriesStreamDetailEpisodeProperties,
) -> PlaylistItem {
let logo: Arc<str> = 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<StreamProperties> {
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<StreamProperties> {
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",
);
}
}
+31 -3
View File
@@ -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)
+4
View File
@@ -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",
@@ -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");
}
@@ -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<LibraryMetadataReadConfigFormState> = use_reducer(|| {
LibraryMetadataReadConfigFormState { form: LibraryMetadataReadConfigDto::default(), modified: false }
});
let thumbnail_state: UseReducerHandle<ThumbnailConfigFormState> =
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) }
</Card>
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_THUMBNAILS)}</h1>
{ 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) }
</Card>
</div>
</>
}
@@ -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) }
</Card>
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_THUMBNAILS)}</h1>
{ 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) }
</Card>
</div>
</>
}
@@ -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<Co
fn is_setup_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 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");
}
+47 -2
View File
@@ -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");
+85
View File
@@ -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,
+4
View File
@@ -130,6 +130,10 @@ pub fn default_storage_formats() -> Vec<LibraryMetadataFormat> { 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<String> {