use std::{ collections::HashMap, path::{Path, PathBuf}, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Strip the longest matching root prefix from `full_path`, returning a /// forward-slash-separated relative path string. Falls back to the full path /// string when no root matches. If `roots` is empty, returns the full path as a /// string so internal callers that have not yet migrated still work. #[must_use] pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String { let mut best: Option<&PathBuf> = None; for root in roots { if full_path.starts_with(root) { let is_longer = best .is_none_or(|b| root.components().count() > b.components().count()); if is_longer { best = Some(root); } } } if let Some(root) = best && let Ok(rel) = full_path.strip_prefix(root) { // Normalise to forward slashes on all platforms. return rel .components() .map(|c| c.as_os_str().to_string_lossy()) .collect::>() .join("/"); } full_path.to_string_lossy().into_owned() } #[derive(Debug, Serialize)] pub struct MediaResponse { pub id: String, pub path: String, pub file_name: String, pub media_type: String, pub content_hash: String, pub file_size: u64, pub title: Option, pub artist: Option, pub album: Option, pub genre: Option, pub year: Option, pub duration_secs: Option, pub description: Option, pub has_thumbnail: bool, pub custom_fields: HashMap, // Photo-specific metadata pub date_taken: Option>, pub latitude: Option, pub longitude: Option, pub camera_make: Option, pub camera_model: Option, pub rating: Option, pub created_at: DateTime, pub updated_at: DateTime, // Markdown links pub links_extracted_at: Option>, } #[derive(Debug, Serialize)] pub struct CustomFieldResponse { pub field_type: String, pub value: String, } #[derive(Debug, Deserialize)] pub struct ImportRequest { pub path: PathBuf, } #[derive(Debug, Serialize)] pub struct ImportResponse { pub media_id: String, pub was_duplicate: bool, } #[derive(Debug, Deserialize)] pub struct UpdateMediaRequest { pub title: Option, pub artist: Option, pub album: Option, pub genre: Option, pub year: Option, pub description: Option, } // File Management #[derive(Debug, Deserialize)] pub struct RenameMediaRequest { pub new_name: String, } #[derive(Debug, Deserialize)] pub struct MoveMediaRequest { pub destination: PathBuf, } #[derive(Debug, Deserialize)] pub struct BatchMoveRequest { pub media_ids: Vec, pub destination: PathBuf, } #[derive(Debug, Serialize)] pub struct TrashResponse { pub items: Vec, pub total_count: u64, } #[derive(Debug, Serialize)] pub struct TrashInfoResponse { pub count: u64, } #[derive(Debug, Serialize)] pub struct EmptyTrashResponse { pub deleted_count: u64, } // Enhanced Import #[derive(Debug, Deserialize)] pub struct ImportWithOptionsRequest { pub path: PathBuf, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } #[derive(Debug, Deserialize)] pub struct BatchImportRequest { pub paths: Vec, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } #[derive(Debug, Serialize)] pub struct BatchImportResponse { pub results: Vec, pub total: usize, pub imported: usize, pub duplicates: usize, pub errors: usize, } #[derive(Debug, Serialize)] pub struct BatchImportItemResult { pub path: String, pub media_id: Option, pub was_duplicate: bool, pub error: Option, } #[derive(Debug, Deserialize)] pub struct DirectoryImportRequest { pub path: PathBuf, pub tag_ids: Option>, pub new_tags: Option>, pub collection_id: Option, } #[derive(Debug, Serialize)] pub struct DirectoryPreviewResponse { pub files: Vec, pub total_count: usize, pub total_size: u64, } #[derive(Debug, Serialize)] pub struct DirectoryPreviewFile { pub path: String, pub file_name: String, pub media_type: String, pub file_size: u64, } // Custom Fields #[derive(Debug, Deserialize)] pub struct SetCustomFieldRequest { pub name: String, pub field_type: String, pub value: String, } // Media update extended #[derive(Debug, Deserialize)] pub struct UpdateMediaFullRequest { pub title: Option, pub artist: Option, pub album: Option, pub genre: Option, pub year: Option, pub description: Option, } // Search with sort #[derive(Debug, Serialize)] pub struct MediaCountResponse { pub count: u64, } // Duplicates #[derive(Debug, Serialize)] pub struct DuplicateGroupResponse { pub content_hash: String, pub items: Vec, } // Open #[derive(Debug, Deserialize)] pub struct OpenRequest { pub media_id: Uuid, } // Upload #[derive(Debug, Serialize)] pub struct UploadResponse { pub media_id: String, pub content_hash: String, pub was_duplicate: bool, pub file_size: u64, } impl From for UploadResponse { fn from(result: pinakes_core::model::UploadResult) -> Self { Self { media_id: result.media_id.0.to_string(), content_hash: result.content_hash.0, was_duplicate: result.was_duplicate, file_size: result.file_size, } } } #[derive(Debug, Serialize)] pub struct ManagedStorageStatsResponse { pub total_blobs: u64, pub total_size_bytes: u64, pub orphaned_blobs: u64, pub deduplication_ratio: f64, } impl From for ManagedStorageStatsResponse { fn from(stats: pinakes_core::model::ManagedStorageStats) -> Self { Self { total_blobs: stats.total_blobs, total_size_bytes: stats.total_size_bytes, orphaned_blobs: stats.orphaned_blobs, deduplication_ratio: stats.deduplication_ratio, } } } impl MediaResponse { /// Build a `MediaResponse` from a `MediaItem`, stripping the longest /// matching root prefix from the path before serialization. Pass the /// configured root directories so that clients receive a relative path /// (e.g. `"Music/song.mp3"`) rather than a full server filesystem path. #[must_use] pub fn new(item: pinakes_core::model::MediaItem, roots: &[PathBuf]) -> Self { Self { id: item.id.0.to_string(), path: relativize_path(&item.path, roots), file_name: item.file_name, media_type: serde_json::to_value(item.media_type) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(), content_hash: item.content_hash.0, file_size: item.file_size, title: item.title, artist: item.artist, album: item.album, genre: item.genre, year: item.year, duration_secs: item.duration_secs, description: item.description, has_thumbnail: item.thumbnail_path.is_some(), custom_fields: item .custom_fields .into_iter() .map(|(k, v)| { (k, CustomFieldResponse { field_type: v.field_type.to_string(), value: v.value, }) }) .collect(), // Photo-specific metadata date_taken: item.date_taken, latitude: item.latitude, longitude: item.longitude, camera_make: item.camera_make, camera_model: item.camera_model, rating: item.rating, created_at: item.created_at, updated_at: item.updated_at, // Markdown links links_extracted_at: item.links_extracted_at, } } } // Conversion helpers impl From for MediaResponse { /// Convert using no root stripping. Prefer `MediaResponse::new(item, roots)` /// at route-handler call sites where roots are available. fn from(item: pinakes_core::model::MediaItem) -> Self { Self::new(item, &[]) } } #[cfg(test)] mod tests { use super::*; #[test] fn relativize_path_strips_matching_root() { let roots = vec![PathBuf::from("/home/user/music")]; let path = Path::new("/home/user/music/artist/song.mp3"); assert_eq!(relativize_path(path, &roots), "artist/song.mp3"); } #[test] fn relativize_path_picks_longest_root() { let roots = vec![ PathBuf::from("/home/user"), PathBuf::from("/home/user/music"), ]; let path = Path::new("/home/user/music/song.mp3"); assert_eq!(relativize_path(path, &roots), "song.mp3"); } #[test] fn relativize_path_no_match_returns_full() { let roots = vec![PathBuf::from("/home/user/music")]; let path = Path::new("/srv/videos/movie.mkv"); assert_eq!(relativize_path(path, &roots), "/srv/videos/movie.mkv"); } #[test] fn relativize_path_empty_roots_returns_full() { let path = Path::new("/home/user/music/song.mp3"); assert_eq!(relativize_path(path, &[]), "/home/user/music/song.mp3"); } #[test] fn relativize_path_exact_root_match() { let roots = vec![PathBuf::from("/media/library")]; let path = Path::new("/media/library/file.mp3"); assert_eq!(relativize_path(path, &roots), "file.mp3"); } } // Watch progress #[derive(Debug, Deserialize)] pub struct WatchProgressRequest { pub progress_secs: f64, } #[derive(Debug, Serialize)] pub struct WatchProgressResponse { pub progress_secs: f64, }