pinakes/crates/pinakes-server/src/dto/media.rs
NotAShelf 9c67c81a79
pinakes-server: relativize media paths against configured root directories
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
2026-03-12 19:41:05 +03:00

384 lines
9.8 KiB
Rust

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::<Vec<_>>()
.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<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<i32>,
pub duration_secs: Option<f64>,
pub description: Option<String>,
pub has_thumbnail: bool,
pub custom_fields: HashMap<String, CustomFieldResponse>,
// Photo-specific metadata
pub date_taken: Option<DateTime<Utc>>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub rating: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
// Markdown links
pub links_extracted_at: Option<DateTime<Utc>>,
}
#[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<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<i32>,
pub description: Option<String>,
}
// 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<Uuid>,
pub destination: PathBuf,
}
#[derive(Debug, Serialize)]
pub struct TrashResponse {
pub items: Vec<MediaResponse>,
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<Vec<Uuid>>,
pub new_tags: Option<Vec<String>>,
pub collection_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct BatchImportRequest {
pub paths: Vec<PathBuf>,
pub tag_ids: Option<Vec<Uuid>>,
pub new_tags: Option<Vec<String>>,
pub collection_id: Option<Uuid>,
}
#[derive(Debug, Serialize)]
pub struct BatchImportResponse {
pub results: Vec<BatchImportItemResult>,
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<String>,
pub was_duplicate: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct DirectoryImportRequest {
pub path: PathBuf,
pub tag_ids: Option<Vec<Uuid>>,
pub new_tags: Option<Vec<String>>,
pub collection_id: Option<Uuid>,
}
#[derive(Debug, Serialize)]
pub struct DirectoryPreviewResponse {
pub files: Vec<DirectoryPreviewFile>,
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<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub year: Option<i32>,
pub description: Option<String>,
}
// 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<MediaResponse>,
}
// 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<pinakes_core::model::UploadResult> 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<pinakes_core::model::ManagedStorageStats>
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.
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<pinakes_core::model::MediaItem> 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,
}