diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index 800f951..231bbb9 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -1,9 +1,39 @@ -use std::{collections::HashMap, path::PathBuf}; +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. +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.map_or(true, |b| root.components().count() > b.components().count()); + if is_longer { + best = Some(root); + } + } + } + if let Some(root) = best { + if 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, @@ -233,12 +263,18 @@ impl From } } -// Conversion helpers -impl From for MediaResponse { - fn from(item: pinakes_core::model::MediaItem) -> Self { +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. + pub fn new( + item: pinakes_core::model::MediaItem, + roots: &[PathBuf], + ) -> Self { Self { id: item.id.0.to_string(), - path: item.path.to_string_lossy().to_string(), + path: relativize_path(&item.path, roots), file_name: item.file_name, media_type: serde_json::to_value(item.media_type) .ok() @@ -282,6 +318,60 @@ impl From for MediaResponse { } } +// 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 { diff --git a/crates/pinakes-server/src/routes/analytics.rs b/crates/pinakes-server/src/routes/analytics.rs index 19a3ef0..1698061 100644 --- a/crates/pinakes-server/src/routes/analytics.rs +++ b/crates/pinakes-server/src/routes/analytics.rs @@ -30,12 +30,13 @@ pub async fn get_most_viewed( ) -> Result>, ApiError> { let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); let results = state.storage.get_most_viewed(limit).await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json( results .into_iter() .map(|(item, count)| { MostViewedResponse { - media: MediaResponse::from(item), + media: MediaResponse::new(item, &roots), view_count: count, } }) @@ -51,7 +52,13 @@ pub async fn get_recently_viewed( let user_id = resolve_user_id(&state.storage, &username).await?; let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); let items = state.storage.get_recently_viewed(user_id, limit).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn record_event( diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index f513d9c..7ae042f 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -194,8 +194,9 @@ pub async fn list_books( ) .await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } @@ -223,8 +224,9 @@ pub async fn get_series_books( Path(series_name): Path, ) -> Result { let items = state.storage.get_series_books(&series_name).await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } @@ -258,8 +260,9 @@ pub async fn get_author_books( .search_books(None, Some(&author_name), None, None, None, &pagination) .await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } @@ -317,8 +320,9 @@ pub async fn get_reading_list( .get_reading_list(user_id.0, params.status) .await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } diff --git a/crates/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs index 159d125..c746fa8 100644 --- a/crates/pinakes-server/src/routes/collections.rs +++ b/crates/pinakes-server/src/routes/collections.rs @@ -126,5 +126,11 @@ pub async fn get_members( let items = pinakes_core::collections::get_members(&state.storage, collection_id) .await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs index 4da2ac8..075b3cc 100644 --- a/crates/pinakes-server/src/routes/duplicates.rs +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -10,6 +10,7 @@ pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { let groups = state.storage.find_duplicates().await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = groups .into_iter() @@ -18,8 +19,10 @@ pub async fn list_duplicates( .first() .map(|i| i.content_hash.0.clone()) .unwrap_or_default(); - let media_items: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let media_items: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); DuplicateGroupResponse { content_hash, items: media_items, diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index a2b3a4a..358db29 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -120,7 +120,13 @@ pub async fn list_media( params.sort, ); let items = state.storage.list_media(&pagination).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn get_media( @@ -128,7 +134,8 @@ pub async fn get_media( Path(id): Path, ) -> Result, ApiError> { let item = state.storage.get_media(MediaId(id)).await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } /// Maximum length for short text fields (title, artist, album, genre). @@ -206,7 +213,8 @@ pub async fn update_media( &serde_json::json!({"media_id": item.id.to_string()}), ); - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn delete_media( @@ -574,12 +582,14 @@ pub async fn preview_directory( } } + let roots_for_walk = roots.clone(); let files: Vec = tokio::task::spawn_blocking(move || { let mut result = Vec::new(); fn walk_dir( dir: &std::path::Path, recursive: bool, + roots: &[std::path::PathBuf], result: &mut Vec, ) { let Ok(entries) = std::fs::read_dir(dir) else { @@ -596,7 +606,7 @@ pub async fn preview_directory( } if path.is_dir() { if recursive { - walk_dir(&path, recursive, result); + walk_dir(&path, recursive, roots, result); } } else if path.is_file() && let Some(mt) = @@ -612,7 +622,7 @@ pub async fn preview_directory( .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(); result.push(DirectoryPreviewFile { - path: path.to_string_lossy().to_string(), + path: crate::dto::relativize_path(&path, roots), file_name, media_type, file_size: size, @@ -620,7 +630,7 @@ pub async fn preview_directory( } } } - walk_dir(&dir, recursive, &mut result); + walk_dir(&dir, recursive, &roots_for_walk, &mut result); result }) .await @@ -948,7 +958,8 @@ pub async fn rename_media( ) .await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn move_media_endpoint( @@ -994,7 +1005,8 @@ pub async fn move_media_endpoint( ) .await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn batch_move_media( @@ -1144,7 +1156,8 @@ pub async fn restore_media( &serde_json::json!({"media_id": media_id.to_string(), "restored": true}), ); - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) } pub async fn list_trash( @@ -1159,9 +1172,13 @@ pub async fn list_trash( let items = state.storage.list_trash(&pagination).await?; let count = state.storage.count_trash().await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json(TrashResponse { - items: items.into_iter().map(MediaResponse::from).collect(), + items: items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), total_count: count, })) } diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs index edf04b6..4119774 100644 --- a/crates/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -121,13 +121,16 @@ pub async fn get_timeline( } // Convert to response format + let roots = state.config.read().await.directories.roots.clone(); let mut timeline: Vec = groups .into_iter() .map(|(date, items)| { let cover_id = items.first().map(|i| i.id.0.to_string()); let count = items.len(); - let items: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); TimelineGroup { date, diff --git a/crates/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs index 15df830..f341458 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -21,8 +21,10 @@ use crate::{ /// Check whether a user has access to a playlist. /// -/// * `require_write` – when `true` only the playlist owner is allowed (for -/// mutations such as update, delete, add/remove/reorder items). When `false` +/// # Arguments +/// +/// * `require_write` - when `true` only the playlist owner is allowed (for +/// mutations such as update, delete, add/remove/reorder items). When `false` /// the playlist must either be public or owned by the requesting user. async fn check_playlist_access( storage: &pinakes_core::storage::DynStorageBackend, @@ -185,7 +187,13 @@ pub async fn list_items( let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).await?; let items = state.storage.get_playlist_items(id).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn reorder_item( @@ -213,5 +221,11 @@ pub async fn shuffle_playlist( use rand::seq::SliceRandom; let mut items = state.storage.get_playlist_items(id).await?; items.shuffle(&mut rand::rng()); - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs index 3201047..7f0e6b1 100644 --- a/crates/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -51,9 +51,14 @@ pub async fn search( }; let results = state.storage.search(&request).await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json(SearchResponse { - items: results.items.into_iter().map(MediaResponse::from).collect(), + items: results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), total_count: results.total_count, })) } @@ -84,9 +89,14 @@ pub async fn search_post( }; let results = state.storage.search(&request).await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json(SearchResponse { - items: results.items.into_iter().map(MediaResponse::from).collect(), + items: results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), total_count: results.total_count, })) } diff --git a/crates/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs index 06b7e6d..76fea3c 100644 --- a/crates/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -506,6 +506,7 @@ pub async fn access_shared( let _ = state.storage.record_share_activity(&activity).await; // Return the shared content + let roots = state.config.read().await.directories.roots.clone(); match &share.target { ShareTarget::Media { media_id } => { let item = state @@ -514,8 +515,8 @@ pub async fn access_shared( .await .map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?; - Ok(Json(SharedContentResponse::Single(MediaResponse::from( - item, + Ok(Json(SharedContentResponse::Single(MediaResponse::new( + item, &roots, )))) }, ShareTarget::Collection { collection_id } => { @@ -527,8 +528,10 @@ pub async fn access_shared( ApiError::not_found(format!("Collection not found: {e}")) })?; - let items: Vec = - members.into_iter().map(MediaResponse::from).collect(); + let items: Vec = members + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, @@ -553,8 +556,11 @@ pub async fn access_shared( .await .map_err(|e| ApiError::internal(format!("Search failed: {e}")))?; - let items: Vec = - results.items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, @@ -585,8 +591,11 @@ pub async fn access_shared( .await .map_err(|e| ApiError::internal(format!("Search failed: {e}")))?; - let items: Vec = - results.items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, diff --git a/crates/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs index b270ae0..f5bc17a 100644 --- a/crates/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -125,7 +125,13 @@ pub async fn list_favorites( .storage .get_user_favorites(user_id, &Pagination::default()) .await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json( + items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(), + )) } pub async fn create_share_link( @@ -205,5 +211,6 @@ pub async fn access_shared_media( } state.storage.increment_share_views(&token).await?; let item = state.storage.get_media(link.media_id).await?; - Ok(Json(MediaResponse::from(item))) + let roots = state.config.read().await.directories.roots.clone(); + Ok(Json(MediaResponse::new(item, &roots))) }