mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-27 09:12:35 +01:00
Feature/local media (#671)
* local library default extensions * series info generation * local library series episodes
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,11 @@ mod tests {
|
||||
movie_category: "Movies".intern(),
|
||||
series_category: "Series".intern(),
|
||||
},
|
||||
thumbnails: crate::model::ThumbnailConfig {
|
||||
enabled: false,
|
||||
width: 320,
|
||||
height: 180,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user