Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
384 lines
9.8 KiB
Rust
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,
|
|
}
|