diff --git a/Cargo.lock b/Cargo.lock index b625e885c..62972e8df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11746,6 +11746,7 @@ dependencies = [ "mime_guess", "rust-embed", "sd-core", + "sd-sidecar-path", "secstr", "serde", "serde_json", diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 45a415202..5d3e3d5fd 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -13,6 +13,7 @@ ai = ["sd-core/ai"] [dependencies] # Spacedrive core +sd-sidecar-path = { path = "../../crates/sidecar-path" } sd-core = { path = "../../core" } # HTTP server diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 12dd3df5b..c20de7ed3 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -163,12 +163,10 @@ async fn serve_sidecar( String, )>, ) -> Response { - use sd_core::ops::sidecar::{SidecarFormat, SidecarKind, SidecarPathBuilder, SidecarVariant}; - let Ok(content_uuid) = content_uuid.parse::() else { return plain_status(StatusCode::BAD_REQUEST, "invalid content uuid"); }; - let Ok(kind) = SidecarKind::try_from(kind.as_str()) else { + let Some(kind_dir) = sd_sidecar_path::kind_directory(&kind) else { return plain_status(StatusCode::BAD_REQUEST, "invalid sidecar kind"); }; let Some((variant, ext)) = variant_and_ext.rsplit_once('.') else { @@ -178,28 +176,27 @@ async fn serve_sidecar( if variant.contains(['/', '\\']) || variant.contains("..") { return plain_status(StatusCode::BAD_REQUEST, "invalid variant"); } - let Ok(format) = SidecarFormat::try_from(ext) else { - return plain_status(StatusCode::BAD_REQUEST, "invalid sidecar format"); - }; let Some(library_folder) = find_library_folder(&state.data_dir, &library_id).await else { return plain_status(StatusCode::NOT_FOUND, "unknown library"); }; - let path = SidecarPathBuilder::new(&library_folder) - .build(&content_uuid, &kind, &SidecarVariant::from(variant), &format) - .absolute_path; + let path = library_folder.join("sidecars").join( + sd_sidecar_path::relative_path(&content_uuid, kind_dir, variant, ext), + ); let Ok(file) = tokio::fs::File::open(&path).await else { return plain_status(StatusCode::NOT_FOUND, "sidecar not found"); }; let content_length = file.metadata().await.ok().map(|m| m.len()); - let content_type = match format { - SidecarFormat::Webp => "image/webp", - SidecarFormat::Mp4 => "video/mp4", - SidecarFormat::Json => "application/json", - SidecarFormat::Text => "text/plain; charset=utf-8", - SidecarFormat::MessagePack | SidecarFormat::Ply => "application/octet-stream", + let content_type = match ext { + "webp" => "image/webp", + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "mp4" => "video/mp4", + "json" => "application/json", + "txt" => "text/plain; charset=utf-8", + _ => "application/octet-stream", }; // Sidecar paths are content-addressed (uuid + variant), so clients may diff --git a/core/src/context.rs b/core/src/context.rs index e96257bef..ae17bd181 100644 --- a/core/src/context.rs +++ b/core/src/context.rs @@ -15,7 +15,6 @@ use crate::{ ops::processes::ProcessManager, service::network::{NetworkingService, RemoteJobCache}, service::session::SessionStateService, - service::sidecar_manager::SidecarManager, service::thumbs::ThumbService, service::watcher::FsWatcherService, volume::VolumeManager, @@ -31,7 +30,6 @@ pub struct CoreContext { pub volume_manager: Arc, pub key_manager: Arc, // This is wrapped in an RwLock to allow it to be set after initialization - pub sidecar_manager: Arc>>>, pub action_manager: Arc>>>, pub networking: Arc>>>, #[cfg(feature = "wasm")] @@ -87,7 +85,6 @@ impl CoreContext { library_manager: Arc::new(RwLock::new(library_manager)), volume_manager, key_manager, - sidecar_manager: Arc::new(RwLock::new(None)), action_manager: Arc::new(RwLock::new(None)), networking: Arc::new(RwLock::new(None)), #[cfg(feature = "wasm")] @@ -198,14 +195,4 @@ impl CoreContext { ) -> Option>> { self.plugin_manager.read().await.clone() } - - /// Helper method to get the sidecar manager - pub async fn get_sidecar_manager(&self) -> Option> { - self.sidecar_manager.read().await.clone() - } - - /// Method for Core to set sidecar manager after it's initialized - pub async fn set_sidecar_manager(&self, sidecar_manager: Arc) { - *self.sidecar_manager.write().await = Some(sidecar_manager); - } } diff --git a/core/src/domain/addressing.rs b/core/src/domain/addressing.rs index d3a3fa059..277425e01 100644 --- a/core/src/domain/addressing.rs +++ b/core/src/domain/addressing.rs @@ -5,7 +5,7 @@ //! file system. use crate::device::{get_current_device_id, get_current_device_slug}; -use crate::ops::sidecar::types::{SidecarFormat, SidecarKind, SidecarVariant}; +use crate::domain::sidecar::{SidecarFormat, SidecarKind, SidecarVariant}; use serde::{Deserialize, Serialize}; use specta::Type; use std::fmt; diff --git a/core/src/domain/file.rs b/core/src/domain/file.rs index a14fc3080..f30c31e82 100644 --- a/core/src/domain/file.rs +++ b/core/src/domain/file.rs @@ -5,13 +5,13 @@ //! The File struct is computed from pre-fetched data rather than fetching //! individual pieces on demand. +use crate::domain::sidecar::{SidecarFormat, SidecarKind, SidecarStatus, SidecarVariant}; use crate::domain::{ addressing::SdPath, content_identity::{ContentIdentity, ContentKind}, media_data::{AudioMediaData, ImageMediaData, VideoMediaData}, tag::Tag, }; -use crate::ops::sidecar::types::{SidecarFormat, SidecarKind, SidecarStatus, SidecarVariant}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use specta::Type; diff --git a/core/src/domain/location.rs b/core/src/domain/location.rs index cf35694d0..e174c9db4 100644 --- a/core/src/domain/location.rs +++ b/core/src/domain/location.rs @@ -393,32 +393,6 @@ impl Default for ThumbnailPolicy { } } -impl ThumbnailPolicy { - /// Convert this policy to a ThumbnailJobConfig for job dispatch - pub fn to_job_config(&self) -> crate::ops::media::thumbnail::ThumbnailJobConfig { - use crate::ops::media::thumbnail::{ThumbnailJobConfig, ThumbnailVariants}; - - let variants = if self.sizes.is_empty() { - // Use defaults if no specific sizes configured - ThumbnailVariants::defaults() - } else { - // Map configured sizes to variants - self.sizes - .iter() - .filter_map(|&size| ThumbnailVariants::from_size(size)) - .collect() - }; - - ThumbnailJobConfig { - variants, - regenerate: self.regenerate, - batch_size: 50, - max_concurrent: 4, - run_in_background: false, - } - } -} - /// Thumbstrip generation policy #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct ThumbstripPolicy { @@ -438,18 +412,6 @@ impl Default for ThumbstripPolicy { } } -impl ThumbstripPolicy { - /// Convert this policy to a ThumbstripJobConfig for job dispatch - #[cfg(feature = "ffmpeg")] - pub fn to_job_config(&self) -> crate::ops::media::thumbstrip::ThumbstripJobConfig { - crate::ops::media::thumbstrip::ThumbstripJobConfig { - variants: crate::ops::media::thumbstrip::ThumbstripVariants::defaults(), - regenerate: self.regenerate, - batch_size: 10, - } - } -} - /// Proxy/sidecar generation policy (video scrubbing) #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct ProxyPolicy { @@ -496,19 +458,6 @@ impl Default for OcrPolicy { } } -impl OcrPolicy { - /// Convert this policy to an OcrJobConfig for job dispatch - pub fn to_job_config(&self, location_id: Option) -> crate::ops::media::ocr::OcrJobConfig { - crate::ops::media::ocr::OcrJobConfig { - location_id, - entry_uuid: None, - languages: self.languages.clone(), - min_confidence: self.min_confidence, - reprocess: self.reprocess, - } - } -} - /// Speech-to-text transcription policy #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct SpeechPolicy { @@ -536,23 +485,6 @@ impl Default for SpeechPolicy { } } -impl SpeechPolicy { - /// Convert this policy to a SpeechToTextJobConfig for job dispatch - #[cfg(feature = "speech-to-text")] - pub fn to_job_config( - &self, - location_id: Option, - ) -> crate::ops::media::speech::SpeechToTextJobConfig { - crate::ops::media::speech::SpeechToTextJobConfig { - location_id, - entry_uuid: None, - language: self.language.clone(), - model: self.model.clone(), - reprocess: self.reprocess, - } - } -} - /// Object detection policy (for future AI features) #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct ObjectDetectionPolicy { diff --git a/core/src/domain/mod.rs b/core/src/domain/mod.rs index 7428a547c..577151513 100644 --- a/core/src/domain/mod.rs +++ b/core/src/domain/mod.rs @@ -15,6 +15,7 @@ pub mod media_data; pub mod resource; pub mod resource_manager; pub mod resource_registry; +pub mod sidecar; pub mod space; pub mod tag; pub mod user_metadata; @@ -30,6 +31,7 @@ pub use location::{IndexMode, Location, ScanState}; pub use media_data::{AudioMediaData, ImageMediaData, VideoMediaData}; pub use resource::{EventEmitter, Identifiable}; pub use resource_manager::ResourceManager; +pub use sidecar::{SidecarFormat, SidecarKind, SidecarStatus, SidecarVariant}; pub use space::{ GroupType, ItemType, Space, SpaceGroup, SpaceGroupWithItems, SpaceItem, SpaceLayout, }; diff --git a/core/src/ops/sidecar/types.rs b/core/src/domain/sidecar.rs similarity index 93% rename from core/src/ops/sidecar/types.rs rename to core/src/domain/sidecar.rs index 06c99328e..f5586c74f 100644 --- a/core/src/ops/sidecar/types.rs +++ b/core/src/domain/sidecar.rs @@ -1,3 +1,10 @@ +//! What a derived artifact is, and how it is named. +//! +//! Kind, variant and format are the vocabulary a sidecar is addressed by, and +//! they are what `SdPath::Sidecar` parses. They sit in the domain because they +//! outlive whatever writes them: the same three name a thumbnail in the hot +//! tier and a transcript in the durable one. + use serde::{Deserialize, Serialize}; use specta::Type; use std::fmt; diff --git a/core/src/lib.rs b/core/src/lib.rs index 3358c0701..516688597 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -182,11 +182,6 @@ impl Core { // Initialize services first, passing them the context let mut services = Services::new(context.clone()); - // Set sidecar manager in context so it can be accessed by jobs - context - .set_sidecar_manager(services.sidecar_manager.clone()) - .await; - // Set filesystem watcher in context so it can be accessed by jobs (for ephemeral watch registration) context.set_fs_watcher(services.fs_watcher.clone()).await; @@ -247,31 +242,6 @@ impl Core { info!("Library filesystem watcher started"); } - // Initialize sidecar manager for each loaded library - for library in &loaded_libraries { - info!("Initializing sidecar manager for library {}", library.id()); - if let Err(e) = services.sidecar_manager.init_library(library).await { - error!( - "Failed to initialize sidecar manager for library {}: {}", - library.id(), - e - ); - } else { - // // Run bootstrap scan in background to avoid blocking startup - // let sidecar_manager = services.sidecar_manager.clone(); - // let library = Arc::clone(library); - // tokio::spawn(async move { - // if let Err(e) = sidecar_manager.bootstrap_scan(&library).await { - // error!( - // "Failed to run sidecar bootstrap scan for library {}: {}", - // library.id(), - // e - // ); - // } - // }); - } - } - // Set library manager reference in volume manager so it can query tracked volumes volumes .set_library_manager(Arc::downgrade(&libraries)) diff --git a/core/src/library/manager.rs b/core/src/library/manager.rs index 54372a253..5378d6e96 100644 --- a/core/src/library/manager.rs +++ b/core/src/library/manager.rs @@ -574,19 +574,7 @@ impl LibraryManager { libraries.insert(config.id, library.clone()); } - // Initialize sidecar manager before resuming jobs - if let Some(sidecar_manager) = context.get_sidecar_manager().await { - if let Err(e) = sidecar_manager.init_library(&library).await { - error!( - "Failed to initialize sidecar manager for library {}: {}", - config.id, e - ); - } - } else { - warn!("Sidecar manager not available during library open"); - } - - // Now that the library is registered and sidecar manager is initialized, resume interrupted jobs + // Now that the library is registered, resume interrupted jobs // DISABLED: Jobs will remain paused on startup instead of auto-resuming // if let Err(e) = library.jobs.resume_interrupted_jobs_after_load().await { // warn!( @@ -878,7 +866,6 @@ impl LibraryManager { tokio::fs::create_dir_all(path.join("previews")).await?; tokio::fs::create_dir_all(path.join("exports")).await?; // Virtual Sidecar root (for derivative data linked by Entry/Content IDs) - tokio::fs::create_dir_all(path.join("sidecars")).await?; // Create configuration let config = LibraryConfig { @@ -935,7 +922,6 @@ impl LibraryManager { // Create subdirectories tokio::fs::create_dir_all(path.join("previews")).await?; tokio::fs::create_dir_all(path.join("exports")).await?; - tokio::fs::create_dir_all(path.join("sidecars")).await?; // Create configuration with provided UUID let config = LibraryConfig { diff --git a/core/src/library/mod.rs b/core/src/library/mod.rs index 2677d6f3f..50f96127f 100644 --- a/core/src/library/mod.rs +++ b/core/src/library/mod.rs @@ -537,27 +537,6 @@ impl Library { Ok(true) } - /// Start thumbnail generation job - pub async fn generate_thumbnails( - &self, - entry_ids: Option>, - ) -> Result { - use crate::ops::media::thumbnail::{ThumbnailJob, ThumbnailJobConfig}; - - let config = ThumbnailJobConfig::default(); - - let job = if let Some(ids) = entry_ids { - ThumbnailJob::for_entries(ids, config) - } else { - ThumbnailJob::new(config) - }; - - self.jobs() - .dispatch(job) - .await - .map_err(|e| LibraryError::JobError(e)) - } - /// Update library statistics pub async fn update_statistics(&self, f: F) -> Result<()> where diff --git a/core/src/ops/addressing.rs b/core/src/ops/addressing.rs index 286425cc9..84e799a7c 100644 --- a/core/src/ops/addressing.rs +++ b/core/src/ops/addressing.rs @@ -203,9 +203,9 @@ impl PathResolver { &self, _context: &CoreContext, content_id: Uuid, - kind: &crate::ops::sidecar::types::SidecarKind, - variant: &crate::ops::sidecar::types::SidecarVariant, - _format: &crate::ops::sidecar::types::SidecarFormat, + kind: &crate::domain::sidecar::SidecarKind, + variant: &crate::domain::sidecar::SidecarVariant, + _format: &crate::domain::sidecar::SidecarFormat, ) -> Result { // TODO: Implement full sidecar resolution // For now, return not found to avoid breaking existing code diff --git a/core/src/ops/indexing/mod.rs b/core/src/ops/indexing/mod.rs index 607ba4d3a..c7711a4ae 100644 --- a/core/src/ops/indexing/mod.rs +++ b/core/src/ops/indexing/mod.rs @@ -33,7 +33,6 @@ pub mod metadata; pub mod metrics; pub mod path_resolver; pub mod phases; -pub mod processor; pub mod progress; pub mod rules; pub mod state; diff --git a/core/src/ops/indexing/processor.rs b/core/src/ops/indexing/processor.rs deleted file mode 100644 index ccee60e94..000000000 --- a/core/src/ops/indexing/processor.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! # Processor Inputs and Results -//! -//! The shape a media processor is handed and the shape it answers with. Every -//! processor in `ops::media` speaks these types, so a caller can drive -//! thumbnails, proxies, OCR and transcripts without knowing which is which. - -use super::state::EntryKind; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use uuid::Uuid; - -/// Minimal entry snapshot required for content processing without full database models. -#[derive(Debug, Clone)] -pub struct ProcessorEntry { - pub id: i32, - pub uuid: Option, - pub path: PathBuf, - pub kind: EntryKind, - pub size: u64, - pub content_id: Option, - pub mime_type: Option, -} - -/// Outcome of a single processor run: success/failure, artifacts created, and bytes processed. -#[derive(Debug, Clone)] -pub struct ProcessorResult { - pub success: bool, - pub artifacts_created: usize, - pub bytes_processed: u64, - pub error: Option, -} - -impl ProcessorResult { - pub fn success(artifacts: usize, bytes: u64) -> Self { - Self { - success: true, - artifacts_created: artifacts, - bytes_processed: bytes, - error: None, - } - } - - pub fn failure(error: String) -> Self { - Self { - success: false, - artifacts_created: 0, - bytes_processed: 0, - error: Some(error), - } - } -} - -/// Per-processor settings: type, enabled flag, and arbitrary JSON config. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProcessorConfig { - pub processor_type: String, - pub enabled: bool, - #[serde(default)] - pub settings: serde_json::Value, -} - -/// Collection of processors that run automatically on watcher events for a location. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocationProcessorConfig { - #[serde(default)] - pub watcher_processors: Vec, -} - -impl Default for LocationProcessorConfig { - fn default() -> Self { - Self { - watcher_processors: vec![ - ProcessorConfig { - processor_type: "content_hash".to_string(), - enabled: true, - settings: serde_json::json!({}), - }, - ProcessorConfig { - processor_type: "thumbnail".to_string(), - enabled: true, - settings: serde_json::json!({ - "variants": ["grid@1x", "grid@2x"], - "quality": 80 - }), - }, - ProcessorConfig { - processor_type: "thumbstrip".to_string(), - enabled: true, // ~6s per video, acceptable for auto-generation. - settings: serde_json::json!({ - "variants": ["thumbstrip_preview"], - "regenerate": false - }), - }, - ProcessorConfig { - processor_type: "proxy".to_string(), - enabled: false, // User opt-in required (~8s per video). - settings: serde_json::json!({ - "enabled": false, - "max_file_size_gb": 5, - "use_hardware_accel": true - }), - }, - ProcessorConfig { - processor_type: "ocr".to_string(), - enabled: false, // Expensive, user opt-in. - settings: serde_json::json!({ - "languages": ["eng"], - "min_confidence": 0.6 - }), - }, - ProcessorConfig { - processor_type: "speech_to_text".to_string(), - enabled: false, // Very expensive, user opt-in. - settings: serde_json::json!({ - "model": "base", - "language": null - }), - }, - ], - } - } -} diff --git a/core/src/ops/libraries/create/action.rs b/core/src/ops/libraries/create/action.rs index eb89c1463..c65bdcf8a 100644 --- a/core/src/ops/libraries/create/action.rs +++ b/core/src/ops/libraries/create/action.rs @@ -40,21 +40,6 @@ impl CoreAction for LibraryCreateAction { ) .await?; - // Initialize sidecar manager for the new library - if let Err(e) = context - .get_sidecar_manager() - .await - .ok_or_else(|| ActionError::Internal("Sidecar manager not available".to_string()))? - .init_library(&library) - .await - { - tracing::error!( - "Failed to initialize sidecar manager for library {}: {}", - library.id(), - e - ); - } - // Get the library details let library_id = library.id(); let name = library.name().await; diff --git a/core/src/ops/libraries/open/action.rs b/core/src/ops/libraries/open/action.rs index e7c61ac85..7785b3252 100644 --- a/core/src/ops/libraries/open/action.rs +++ b/core/src/ops/libraries/open/action.rs @@ -50,21 +50,6 @@ impl CoreAction for LibraryOpenAction { other => ActionError::Internal(other.to_string()), })?; - // Initialize sidecar manager for the opened library - if let Err(e) = context - .get_sidecar_manager() - .await - .ok_or_else(|| ActionError::Internal("Sidecar manager not available".to_string()))? - .init_library(&library) - .await - { - tracing::error!( - "Failed to initialize sidecar manager for library {}: {}", - library.id(), - e - ); - } - info!("Opened library {} from {:?}", library.id(), self.input.path); // Get the library details diff --git a/core/src/ops/media/blurhash.rs b/core/src/ops/media/blurhash.rs deleted file mode 100644 index 14834b587..000000000 --- a/core/src/ops/media/blurhash.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Blurhash generation utilities for images and videos -//! -//! Blurhash is a compact representation of an image that can be decoded into a -//! low-resolution placeholder. Perfect for showing while full images/thumbnails load. - -use image::{DynamicImage, GenericImageView}; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum BlurhashError { - #[error("Image is too small for blurhash generation")] - ImageTooSmall, - - #[error("Blurhash encoding failed: {0}")] - EncodingFailed(String), - - #[error("Invalid image dimensions")] - InvalidDimensions, -} - -/// Generate a blurhash from a DynamicImage -/// -/// The blurhash will be generated using 4x3 components for good quality -/// while keeping the hash compact (~20-30 chars). -/// -/// # Arguments -/// -/// * `image` - The image to generate a blurhash from -/// -/// # Returns -/// -/// A blurhash string that can be decoded into a placeholder image -pub fn generate_blurhash(image: &DynamicImage) -> Result { - let (width, height) = image.dimensions(); - - if width == 0 || height == 0 { - return Err(BlurhashError::InvalidDimensions); - } - - // Blurhash works best with reasonable dimensions - // If image is too large, resize to max 256px for blurhash calculation - let working_image = if width > 256 || height > 256 { - let scale = 256.0 / width.max(height) as f64; - let new_width = (width as f64 * scale) as u32; - let new_height = (height as f64 * scale) as u32; - - image.resize_exact( - new_width.max(1), - new_height.max(1), - image::imageops::FilterType::Lanczos3, - ) - } else { - image.clone() - }; - - let (w, h) = working_image.dimensions(); - - // Convert to RGB8 for blurhash encoding - let rgb_image = working_image.to_rgb8(); - let pixels = rgb_image.as_raw(); - - // Generate blurhash with 4x3 components (good balance of quality and size) - // Results in ~20-30 character hash - let hash = blurhash::encode(4, 3, w, h, pixels) - .map_err(|e| BlurhashError::EncodingFailed(e.to_string()))?; - - Ok(hash) -} - -/// Generate a blurhash from a video frame -/// -/// This is a convenience wrapper around `generate_blurhash` for video frames. -pub fn generate_blurhash_from_video_frame(frame: &DynamicImage) -> Result { - generate_blurhash(frame) -} - -#[cfg(test)] -mod tests { - use super::*; - use image::RgbImage; - - #[test] - fn test_zero_dimensions() { - let img = DynamicImage::new_rgb8(0, 0); - let result = generate_blurhash(&img); - assert!(result.is_err()); - } -} diff --git a/core/src/ops/media/metadata_extractor.rs b/core/src/ops/media/metadata_extractor.rs deleted file mode 100644 index 7e5ee2e87..000000000 --- a/core/src/ops/media/metadata_extractor.rs +++ /dev/null @@ -1,437 +0,0 @@ -//! Media metadata extraction utilities - -use crate::infra::db::entities::{audio_media_data, image_media_data, video_media_data}; -use chrono::{DateTime, Utc}; -use sea_orm::ActiveValue::Set; -use std::path::Path; -use uuid::Uuid; - -/// Content kind ids as stored on `content_identity` rows. -pub const KIND_IMAGE: i32 = 1; -pub const KIND_VIDEO: i32 = 2; -pub const KIND_AUDIO: i32 = 3; - -/// Whether the content's media-data row for this kind still needs work: -/// the row is absent, or (for image/video) present without a blurhash. Lets -/// callers skip the extraction and blurhash cost on already-complete files. -pub async fn media_metadata_missing( - db: &sea_orm::DatabaseConnection, - content_uuid: &Uuid, - content_kind_id: i32, -) -> bool { - use crate::infra::db::entities::content_identity; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let Ok(Some(content)) = content_identity::Entity::find() - .filter(content_identity::Column::Uuid.eq(*content_uuid)) - .one(db) - .await - else { - return false; - }; - - match content_kind_id { - KIND_IMAGE => match content.image_media_data_id { - None => true, - Some(id) => matches!( - image_media_data::Entity::find_by_id(id).one(db).await, - Ok(Some(row)) if row.blurhash.is_none() - ), - }, - KIND_VIDEO => match content.video_media_data_id { - None => true, - Some(id) => matches!( - video_media_data::Entity::find_by_id(id).one(db).await, - Ok(Some(row)) if row.blurhash.is_none() - ), - }, - KIND_AUDIO => content.audio_media_data_id.is_none(), - _ => false, - } -} - -/// Extract and store the media-data row for a content identity, linking it -/// via the kind's FK. A row that already exists only receives the blurhash -/// when it lacks one; nothing is re-extracted. -pub async fn backfill_media_metadata( - db: &sea_orm::DatabaseConnection, - content_uuid: &Uuid, - source_path: &Path, - content_kind_id: i32, - blurhash: Option, -) -> Result<(), Box> { - use crate::infra::db::entities::content_identity; - use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; - - let Some(content) = content_identity::Entity::find() - .filter(content_identity::Column::Uuid.eq(*content_uuid)) - .one(db) - .await? - else { - return Ok(()); - }; - - match content_kind_id { - KIND_IMAGE => { - if let Some(id) = content.image_media_data_id { - if let (Some(hash), Ok(Some(row))) = ( - blurhash, - image_media_data::Entity::find_by_id(id).one(db).await, - ) { - if row.blurhash.is_none() { - let mut active: image_media_data::ActiveModel = row.into(); - active.blurhash = Set(Some(hash)); - active.updated_at = Set(Utc::now().into()); - active.update(db).await?; - } - } - return Ok(()); - } - - let uuid = Uuid::new_v5(content_uuid, b"image"); - let model = extract_image_metadata_with_blurhash(source_path, uuid, blurhash).await?; - let inserted = model.insert(db).await?; - let mut active: content_identity::ActiveModel = content.into(); - active.image_media_data_id = Set(Some(inserted.id)); - active.update(db).await?; - } - KIND_VIDEO => { - if let Some(id) = content.video_media_data_id { - if let (Some(hash), Ok(Some(row))) = ( - blurhash, - video_media_data::Entity::find_by_id(id).one(db).await, - ) { - if row.blurhash.is_none() { - let mut active: video_media_data::ActiveModel = row.into(); - active.blurhash = Set(Some(hash)); - active.updated_at = Set(Utc::now().into()); - active.update(db).await?; - } - } - return Ok(()); - } - - // Probing a fresh video row needs FFmpeg; without it the row waits - // for a build that has the feature. - #[cfg(feature = "ffmpeg")] - { - let uuid = Uuid::new_v5(content_uuid, b"video"); - let model = - extract_video_metadata_with_blurhash(source_path, uuid, blurhash).await?; - let inserted = model.insert(db).await?; - let mut active: content_identity::ActiveModel = content.into(); - active.video_media_data_id = Set(Some(inserted.id)); - active.update(db).await?; - } - } - KIND_AUDIO => { - if content.audio_media_data_id.is_some() { - return Ok(()); - } - - #[cfg(feature = "ffmpeg")] - { - let uuid = Uuid::new_v5(content_uuid, b"audio"); - let model = extract_audio_metadata(source_path, uuid).await?; - let inserted = model.insert(db).await?; - let mut active: content_identity::ActiveModel = content.into(); - active.audio_media_data_id = Set(Some(inserted.id)); - active.update(db).await?; - } - } - _ => {} - } - - Ok(()) -} - -/// Extract image metadata from EXIF -/// -/// Optionally pass a blurhash if it has already been generated elsewhere -/// (e.g., during thumbnail generation). -pub async fn extract_image_metadata( - path: &Path, - uuid: Uuid, -) -> Result> { - extract_image_metadata_with_blurhash(path, uuid, None).await -} - -/// Extract image metadata from EXIF with optional blurhash -pub async fn extract_image_metadata_with_blurhash( - path: &Path, - uuid: Uuid, - blurhash: Option, -) -> Result> { - // Extract EXIF metadata - let exif = sd_media_metadata::exif::ExifMetadata::from_path(path) - .await? - .ok_or("No EXIF data found")?; - - // Convert MediaDate to DateTime - let date_taken = exif.date_taken.map(|d| match d { - sd_media_metadata::exif::MediaDate::Utc(dt) => dt.with_timezone(&chrono::Utc), - sd_media_metadata::exif::MediaDate::Naive(dt) => dt.and_utc(), - }); - - // Extract GPS coordinates from location data - let (latitude, longitude) = exif - .location - .as_ref() - .map(|loc| { - let (lat, lon) = loc.coordinates(); - (Some(lat), Some(lon)) - }) - .unwrap_or((None, None)); - - Ok(image_media_data::ActiveModel { - id: sea_orm::ActiveValue::NotSet, - uuid: Set(uuid), - width: Set(exif.resolution.width as i32), - height: Set(exif.resolution.height as i32), - blurhash: Set(blurhash), - date_taken: Set(date_taken.map(Into::into)), - latitude: Set(latitude), - longitude: Set(longitude), - camera_make: Set(exif.camera_data.device_make), - camera_model: Set(exif.camera_data.device_model), - lens_model: Set(exif.camera_data.lens_model), - focal_length: Set(exif.camera_data.focal_length.map(|f| f.to_string())), - aperture: Set(None), // TODO: Extract from EXIF - shutter_speed: Set(exif.camera_data.shutter_speed.map(|s| s.to_string())), - iso: Set(exif.camera_data.iso), - orientation: Set(Some(exif.camera_data.orientation as i16)), - color_space: Set(exif.camera_data.color_space), - color_profile: Set(exif.camera_data.color_profile.map(|p| format!("{:?}", p))), - bit_depth: Set(exif.camera_data.bit_depth.map(|b| b.to_string())), - artist: Set(exif.artist), - copyright: Set(exif.copyright), - description: Set(exif.description), - created_at: Set(chrono::Utc::now().into()), - updated_at: Set(chrono::Utc::now().into()), - }) -} - -/// Extract video metadata from FFmpeg -/// -/// Optionally pass a blurhash if it has already been generated elsewhere -/// (e.g., during thumbnail generation). -#[cfg(feature = "ffmpeg")] -pub async fn extract_video_metadata( - path: &Path, - uuid: Uuid, -) -> Result> { - extract_video_metadata_with_blurhash(path, uuid, None).await -} - -/// Extract video metadata from FFmpeg with optional blurhash -#[cfg(feature = "ffmpeg")] -pub async fn extract_video_metadata_with_blurhash( - path: &Path, - uuid: Uuid, - blurhash: Option, -) -> Result> { - // Probe with FFmpeg - let metadata = sd_ffmpeg::probe(path).await?; - - // Get first video stream for dimensions - let video_stream = metadata - .programs - .iter() - .flat_map(|p| &p.streams) - .find(|s| s.codec.as_ref().map(|c| c.kind.as_deref()) == Some(Some("video"))); - - use sd_ffmpeg::model::FFmpegProps; - - let (width, height) = video_stream - .and_then(|s| s.codec.as_ref()) - .and_then(|c| match &c.props { - Some(FFmpegProps::Video(v)) => Some((v.width, v.height)), - _ => None, - }) - .unwrap_or((0, 0)); - - // Get first audio stream - let audio_stream = metadata - .programs - .iter() - .flat_map(|p| &p.streams) - .find(|s| s.codec.as_ref().map(|c| c.kind.as_deref()) == Some(Some("audio"))); - - let (audio_codec, audio_channels, audio_sample_rate, audio_bit_rate) = audio_stream - .and_then(|s| s.codec.as_ref()) - .and_then(|c| match &c.props { - Some(FFmpegProps::Audio(a)) => Some(( - c.name.clone(), - a.channel_layout.clone(), - a.sample_rate, - Some(c.bit_rate), - )), - _ => None, - }) - .unwrap_or((None, None, None, None)); - - // Extract Apple QuickTime capture date or fall back to standard creation time - tracing::debug!( - "Available custom metadata keys: {:?}", - metadata.metadata.custom.keys().collect::>() - ); - - let date_captured = metadata - .metadata - .custom - .get("com.apple.quicktime.creationdate") - .and_then(|date_str| { - tracing::debug!("Found Apple QuickTime creation date: {}", date_str); - // Try parsing as RFC3339 - if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) { - tracing::debug!("Successfully parsed Apple QuickTime date via RFC3339"); - Some(dt.with_timezone(&Utc)) - } else { - tracing::debug!("RFC3339 parsing failed, trying custom format"); - // Try parsing with custom format for Apple's variant - // Format: "2025-10-18T09:21:58-0700" - if let Ok(dt) = DateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S%z") { - tracing::debug!("Successfully parsed Apple QuickTime date via custom format"); - Some(dt.with_timezone(&Utc)) - } else { - tracing::warn!("Failed to parse Apple QuickTime date: {}", date_str); - None - } - } - }) - .or_else(|| { - tracing::debug!( - "No Apple QuickTime date found, using creation_time: {:?}", - metadata.metadata.creation_time - ); - metadata.metadata.creation_time - }); - - // Get video codec info - let ( - codec, - pixel_format, - color_space, - color_range, - color_primaries, - color_transfer, - fps_num, - fps_den, - ) = video_stream - .and_then(|s| s.codec.as_ref()) - .map(|c| { - let ( - pixel_format, - color_space, - color_range, - color_primaries, - color_transfer, - fps_num, - fps_den, - ) = match &c.props { - Some(FFmpegProps::Video(v)) => ( - v.pixel_format.clone(), - v.color_space.clone(), - v.color_range.clone(), - v.color_primaries.clone(), - v.color_transfer.clone(), - Some(0), // TODO: Extract from stream - Some(1), - ), - _ => (None, None, None, None, None, None, None), - }; - ( - c.name.clone(), - pixel_format, - color_space, - color_range, - color_primaries, - color_transfer, - fps_num, - fps_den, - ) - }) - .unwrap_or((None, None, None, None, None, None, None, None)); - - Ok(video_media_data::ActiveModel { - id: sea_orm::ActiveValue::NotSet, - uuid: Set(uuid), - width: Set(width), - height: Set(height), - blurhash: Set(blurhash), - duration_seconds: Set(metadata.duration.map(|d| d as f64 / 1_000_000.0)), - bit_rate: Set(Some(metadata.bit_rate)), - codec: Set(codec), - pixel_format: Set(pixel_format), - color_space: Set(color_space), - color_range: Set(color_range), - color_primaries: Set(color_primaries), - color_transfer: Set(color_transfer), - fps_num: Set(fps_num), - fps_den: Set(fps_den), - audio_codec: Set(audio_codec), - audio_channels: Set(audio_channels), - audio_sample_rate: Set(audio_sample_rate), - audio_bit_rate: Set(audio_bit_rate), - title: Set(metadata.metadata.title), - artist: Set(metadata.metadata.artist), - album: Set(metadata.metadata.album), - creation_time: Set(metadata.metadata.creation_time), - date_captured: Set(date_captured), - created_at: Set(chrono::Utc::now().into()), - updated_at: Set(chrono::Utc::now().into()), - }) -} - -/// Extract audio metadata from FFmpeg -#[cfg(feature = "ffmpeg")] -pub async fn extract_audio_metadata( - path: &Path, - uuid: Uuid, -) -> Result> { - use sd_ffmpeg::model::FFmpegProps; - - // Probe with FFmpeg - let metadata = sd_ffmpeg::probe(path).await?; - - // Get first audio stream - let audio_stream = metadata - .programs - .iter() - .flat_map(|p| &p.streams) - .find(|s| s.codec.as_ref().map(|c| c.kind.as_deref()) == Some(Some("audio"))); - - let (codec, sample_rate, channels) = audio_stream - .and_then(|s| s.codec.as_ref()) - .map(|c| { - let (sample_rate, channels) = match &c.props { - Some(FFmpegProps::Audio(a)) => (a.sample_rate, a.channel_layout.clone()), - _ => (None, None), - }; - (c.name.clone(), sample_rate, channels) - }) - .unwrap_or((None, None, None)); - - Ok(audio_media_data::ActiveModel { - id: sea_orm::ActiveValue::NotSet, - uuid: Set(uuid), - duration_seconds: Set(metadata.duration.map(|d| d as f64 / 1_000_000.0)), - bit_rate: Set(Some(metadata.bit_rate)), - sample_rate: Set(sample_rate), - channels: Set(channels), - codec: Set(codec), - title: Set(metadata.metadata.title), - artist: Set(metadata.metadata.artist), - album: Set(metadata.metadata.album), - album_artist: Set(metadata.metadata.album_artist), - genre: Set(metadata.metadata.genre), - year: Set(None), // TODO: Parse from date field - track_number: Set(metadata.metadata.track.map(|t| t as i32)), - disc_number: Set(metadata.metadata.disc.map(|d| d as i32)), - composer: Set(metadata.metadata.composer), - publisher: Set(metadata.metadata.publisher), - copyright: Set(metadata.metadata.copyright), - created_at: Set(chrono::Utc::now().into()), - updated_at: Set(chrono::Utc::now().into()), - }) -} diff --git a/core/src/ops/media/mod.rs b/core/src/ops/media/mod.rs deleted file mode 100644 index e3b7a74ad..000000000 --- a/core/src/ops/media/mod.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Media processing operations -//! -//! This module contains jobs for processing media files including: -//! - Thumbnail generation -//! - OCR (text extraction from images/PDFs) -//! - Speech-to-text (audio/video transcription) -//! - Gaussian splat generation (3D view synthesis from images) -//! - Video transcoding -//! - Audio metadata extraction -//! - Image optimization -//! - Blurhash generation for image placeholders - -pub mod blurhash; -pub mod metadata_extractor; -pub mod ocr; -pub mod proxy; -pub mod splat; - -pub mod speech; -pub mod thumbnail; -pub mod thumbstrip; - -pub use metadata_extractor::{extract_image_metadata, extract_image_metadata_with_blurhash}; - -#[cfg(feature = "ffmpeg")] -pub use metadata_extractor::{ - extract_audio_metadata, extract_video_metadata, extract_video_metadata_with_blurhash, -}; -pub use ocr::{OcrJob, OcrProcessor}; -pub use proxy::{ProxyJob, ProxyProcessor}; -pub use splat::{GaussianSplatJob, GaussianSplatProcessor}; - -#[cfg(feature = "speech-to-text")] -pub use speech::{SpeechToTextJob, SpeechToTextProcessor}; -pub use thumbnail::ThumbnailJob; -#[cfg(feature = "ffmpeg")] -pub use thumbstrip::{ThumbstripJob, ThumbstripProcessor}; diff --git a/core/src/ops/media/ocr/action.rs b/core/src/ops/media/ocr/action.rs deleted file mode 100644 index 52c092c42..000000000 --- a/core/src/ops/media/ocr/action.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! OCR action handlers - -use super::{ - job::{OcrJob, OcrJobConfig}, - processor::OcrProcessor, -}; -use crate::{ - context::CoreContext, - infra::action::{error::ActionError, LibraryAction}, - ops::indexing::{path_resolver::PathResolver, processor::ProcessorEntry}, -}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use uuid::Uuid; - -// ============================================================================ -// Extract Text Action (for single file UI triggering) -// ============================================================================ - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct ExtractTextInput { - /// UUID of the entry to extract text from - pub entry_uuid: Uuid, - /// Languages to use for OCR (e.g., ["eng", "spa"]) - pub languages: Option>, - /// Force re-extraction even if text exists - pub force: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct ExtractTextOutput { - /// Job ID for tracking OCR progress - pub job_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExtractTextAction { - input: ExtractTextInput, -} - -impl ExtractTextAction { - pub fn new(input: ExtractTextInput) -> Self { - Self { input } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct OcrJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl LibraryAction for ExtractTextAction { - type Input = ExtractTextInput; - type Output = ExtractTextOutput; - - fn from_input(input: ExtractTextInput) -> Result { - Ok(Self::new(input)) - } - - async fn execute( - self, - library: Arc, - _context: Arc, - ) -> Result { - tracing::info!("Dispatching OCR job for entry: {}", self.input.entry_uuid); - - // Create job config for single file - let languages = self - .input - .languages - .unwrap_or_else(|| vec!["eng".to_string()]); - - let job_config = super::job::OcrJobConfig { - location_id: None, - entry_uuid: Some(self.input.entry_uuid), - languages, - min_confidence: 0.6, - reprocess: self.input.force, - }; - - // Create and dispatch job - let job = super::job::OcrJob::new(job_config); - - let job_handle = library - .jobs() - .dispatch(job) - .await - .map_err(|e| ActionError::Internal(format!("Failed to dispatch job: {}", e)))?; - - tracing::info!("OCR job dispatched: {}", job_handle.id()); - - Ok(ExtractTextOutput { - job_id: job_handle.id().to_string(), - }) - } - - fn action_kind(&self) -> &'static str { - "media.ocr.extract" - } -} - -crate::register_library_action!(ExtractTextAction, "media.ocr.extract"); diff --git a/core/src/ops/media/ocr/job.rs b/core/src/ops/media/ocr/job.rs deleted file mode 100644 index 962f0e23b..000000000 --- a/core/src/ops/media/ocr/job.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! OCR job for batch text extraction - -use super::processor::OcrProcessor; -use crate::{ - infra::{ - db::entities::entry, - job::{prelude::*, traits::DynJob}, - }, - ops::indexing::processor::ProcessorEntry, -}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use tracing::{info, warn}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct OcrJobConfig { - /// Location ID to process (None = all entries in library) - pub location_id: Option, - /// Single entry UUID to process (for UI-triggered single file) - pub entry_uuid: Option, - /// Languages for OCR (e.g., ["eng", "spa"]) - pub languages: Vec, - /// Minimum confidence threshold (0.0 - 1.0) - pub min_confidence: f32, - /// Reprocess files that already have text - pub reprocess: bool, -} - -impl Default for OcrJobConfig { - fn default() -> Self { - Self { - location_id: None, - entry_uuid: None, - languages: vec!["eng".to_string()], - min_confidence: 0.6, - reprocess: false, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct OcrJobState { - phase: OcrPhase, - entries: Vec<(i32, std::path::PathBuf, Option)>, // (entry_id, path, mime_type) - processed: usize, - success_count: usize, - error_count: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -enum OcrPhase { - Discovery, - Processing, - Complete, -} - -#[derive(Serialize, Deserialize)] -pub struct OcrJob { - config: OcrJobConfig, - state: OcrJobState, -} - -impl OcrJob { - pub fn new(config: OcrJobConfig) -> Self { - Self { - config, - state: OcrJobState { - phase: OcrPhase::Discovery, - entries: Vec::new(), - processed: 0, - success_count: 0, - error_count: 0, - }, - } - } - - pub fn from_location(location_id: Uuid) -> Self { - Self::new(OcrJobConfig { - location_id: Some(location_id), - ..Default::default() - }) - } -} - -impl Job for OcrJob { - const NAME: &'static str = "ocr"; - const RESUMABLE: bool = true; - const DESCRIPTION: Option<&'static str> = Some("Extract text from images and PDFs using OCR"); -} - -#[async_trait::async_trait] -impl JobHandler for OcrJob { - type Output = OcrJobOutput; - - async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { - match self.state.phase { - OcrPhase::Discovery => { - ctx.log("Starting OCR discovery phase"); - self.run_discovery(&ctx).await?; - self.state.phase = OcrPhase::Processing; - } - OcrPhase::Processing => {} - OcrPhase::Complete => { - return Ok(OcrJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - }); - } - } - - // Processing phase - ctx.log(format!( - "OCR processing phase starting with {} entries", - self.state.entries.len() - )); - - let processor = OcrProcessor::new(ctx.library_arc()) - .with_languages(self.config.languages.clone()) - .with_min_confidence(self.config.min_confidence); - - let total = self.state.entries.len(); - - while self.state.processed < total { - ctx.check_interrupt().await?; - - let (entry_id, path, mime_type) = &self.state.entries[self.state.processed]; - - // Load entry to get content_id - let entry_model = entry::Entity::find_by_id(*entry_id) - .one(ctx.library_db()) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - // Build processor entry - let proc_entry = ProcessorEntry { - id: *entry_id, - uuid: entry_model.uuid, - path: path.clone(), - kind: crate::ops::indexing::state::EntryKind::File, - size: entry_model.size as u64, - content_id: entry_model.content_id, - mime_type: mime_type.clone(), - }; - - if !processor.should_process(&proc_entry) { - self.state.processed += 1; - continue; - } - - // Process entry - match processor.process(ctx.library_db(), &proc_entry).await { - Ok(result) if result.success => { - ctx.log(format!( - "Extracted text from {}: {} chars", - path.display(), - result.bytes_processed - )); - self.state.success_count += 1; - } - Ok(_) => { - warn!("OCR failed for {}", path.display()); - self.state.error_count += 1; - } - Err(e) => { - ctx.log(format!("ERROR: OCR error for {}: {}", path.display(), e)); - self.state.error_count += 1; - } - } - - self.state.processed += 1; - - // Report progress - ctx.progress(Progress::Count { - current: self.state.processed, - total, - }); - - // Checkpoint every 10 files - if self.state.processed % 10 == 0 { - ctx.checkpoint().await?; - } - } - - self.state.phase = OcrPhase::Complete; - ctx.log(format!( - "OCR complete: {} success, {} errors", - self.state.success_count, self.state.error_count - )); - - Ok(OcrJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - }) - } -} - -impl OcrJob { - async fn run_discovery(&mut self, ctx: &JobContext<'_>) -> JobResult<()> { - use crate::infra::db::entities::{content_identity, entry, mime_type}; - - ctx.log("Starting OCR discovery"); - - // Note: Tesseract uses system-installed language data - // Future: Implement tesseract language data download similar to whisper - ctx.log(format!( - "Using tesseract with languages: {} (system installation)", - self.config.languages.join(", ") - )); - - let db = ctx.library_db(); - - // Check if this is single-file mode (from UI action) - if let Some(entry_uuid) = self.config.entry_uuid { - ctx.log(format!("Single file mode: processing entry {}", entry_uuid)); - - // Load the specific entry - let entry_model = entry::Entity::find() - .filter(entry::Column::Uuid.eq(entry_uuid)) - .one(db) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - if let Some(content_id) = entry_model.content_id { - if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - // Skip if already has text (unless forcing) - if !self.config.reprocess - && ci.text_content.is_some() - && !ci.text_content.as_ref().unwrap().is_empty() - { - ctx.log("Entry already has extracted text, skipping"); - return Ok(()); - } - - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = mime_type::Entity::find_by_id(mime_id).one(db).await - { - if super::is_ocr_supported( - &mime.mime_type, - ctx.library().core_context().file_type_registry(), - ) { - if let Ok(path) = crate::ops::indexing::PathResolver::get_full_path( - db, - entry_model.id, - ) - .await - { - self.state.entries.push(( - entry_model.id, - path, - Some(mime.mime_type), - )); - } - } - } - } - } - } - - ctx.log(format!( - "Single file discovered: {} entries", - self.state.entries.len() - )); - return Ok(()); - } - - // Batch mode - build query for entries that support OCR - let mut query = entry::Entity::find().filter(entry::Column::ContentId.is_not_null()); - - // Filter by location if specified - if let Some(location_id) = self.config.location_id { - // TODO: Add location scoping via entry_closure - info!("Location filtering not yet implemented, processing all entries"); - } - - let entries = query.all(db).await?; - - ctx.log(format!("Found {} entries with content", entries.len())); - - // Load MIME types for filtering - for entry_model in entries { - if let Some(content_id) = entry_model.content_id { - // Check if already has text (unless reprocessing) - if !self.config.reprocess { - if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - if ci.text_content.is_some() - && !ci.text_content.as_ref().unwrap().is_empty() - { - continue; // Skip - already has text - } - - // Get MIME type - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = - mime_type::Entity::find_by_id(mime_id).one(db).await - { - if super::is_ocr_supported( - &mime.mime_type, - ctx.library().core_context().file_type_registry(), - ) { - // Get full path - if let Ok(path) = - crate::ops::indexing::PathResolver::get_full_path( - db, - entry_model.id, - ) - .await - { - self.state.entries.push(( - entry_model.id, - path, - Some(mime.mime_type), - )); - } - } - } - } - } - } - } - } - - ctx.log(format!( - "Discovery complete: {} entries ready for OCR", - self.state.entries.len() - )); - - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct OcrJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl From for JobOutput { - fn from(output: OcrJobOutput) -> Self { - JobOutput::OcrExtraction { - total_processed: output.total_processed, - success_count: output.success_count, - error_count: output.error_count, - } - } -} - -impl DynJob for OcrJob { - fn job_name(&self) -> &'static str { - "OCR Text Extraction" - } -} - -impl From for Box { - fn from(job: OcrJob) -> Self { - Box::new(job) - } -} diff --git a/core/src/ops/media/ocr/mod.rs b/core/src/ops/media/ocr/mod.rs deleted file mode 100644 index 09fecd310..000000000 --- a/core/src/ops/media/ocr/mod.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! OCR (Optical Character Recognition) system -//! -//! Extracts text from images and PDFs using tesseract-rs. -//! Text is stored in content_identity.text_content field. - -pub mod action; -pub mod job; -pub mod processor; - -pub use action::{ExtractTextAction, ExtractTextInput, ExtractTextOutput}; -pub use job::{OcrJob, OcrJobConfig}; -pub use processor::OcrProcessor; - -use anyhow::Result; -use std::path::Path; - -/// Extract text from an image or PDF using tesseract -pub async fn extract_text_from_file(source_path: &Path, languages: &[String]) -> Result { - use tokio::task::spawn_blocking; - - let source = source_path.to_path_buf(); - let langs = languages.to_vec(); - - // Run OCR in blocking task (CPU intensive) - spawn_blocking(move || { - // TODO: Integrate tesseract-rs - // Add to Cargo.toml: - // tesseract = { version = "0.14", optional = true } - // - // Then implement: - // #[cfg(feature = "tesseract")] - // { - // let tesseract = tesseract::Tesseract::new(None, Some(&langs.join("+")))?; - // tesseract.set_image(&source.to_string_lossy())?; - // let text = tesseract.get_text()?; - // Ok(text) - // } - // #[cfg(not(feature = "tesseract"))] - // { - // Err(anyhow::anyhow!("Tesseract feature not enabled")) - // } - - // Placeholder implementation - Ok(format!( - "[OCR placeholder - tesseract integration needed for {}]", - source.file_name().unwrap().to_string_lossy() - )) - }) - .await? -} - -/// Check if a file type supports OCR based on content kind -pub fn is_ocr_supported(mime_type: &str, registry: &crate::filetype::FileTypeRegistry) -> bool { - use crate::domain::ContentKind; - - // Get file type by MIME - if let Some(file_type) = registry.get_by_mime(mime_type) { - // OCR supported for images and documents - matches!( - file_type.category, - ContentKind::Image | ContentKind::Document - ) - } else { - // Fallback to direct MIME check for unknown types - mime_type.starts_with("image/") || mime_type == "application/pdf" - } -} diff --git a/core/src/ops/media/ocr/processor.rs b/core/src/ops/media/ocr/processor.rs deleted file mode 100644 index fde014a57..000000000 --- a/core/src/ops/media/ocr/processor.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! OCR processor - atomic operation for text extraction - -use crate::library::Library; -use crate::ops::indexing::processor::{ProcessorEntry, ProcessorResult}; -use crate::ops::indexing::state::EntryKind; -use anyhow::Result; -use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait}; -use serde_json::Value; -use std::sync::Arc; -use tracing::{debug, warn}; - -pub struct OcrProcessor { - library: Arc, - languages: Vec, - min_confidence: f32, -} - -impl OcrProcessor { - pub fn new(library: Arc) -> Self { - Self { - library, - languages: vec!["eng".to_string()], - min_confidence: 0.6, - } - } - - pub fn with_languages(mut self, languages: Vec) -> Self { - self.languages = languages; - self - } - - pub fn with_min_confidence(mut self, confidence: f32) -> Self { - self.min_confidence = confidence; - self - } - - pub fn with_settings(mut self, settings: &Value) -> Result { - // Parse languages - if let Some(langs) = settings.get("languages").and_then(|v| v.as_array()) { - self.languages = langs - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect(); - } - - // Parse min_confidence - if let Some(conf) = settings.get("min_confidence").and_then(|v| v.as_f64()) { - self.min_confidence = conf as f32; - } - - Ok(self) - } - - pub fn should_process(&self, entry: &ProcessorEntry) -> bool { - if !matches!(entry.kind, EntryKind::File) { - return false; - } - - if entry.content_id.is_none() { - return false; - } - - entry.mime_type.as_ref().map_or(false, |m| { - super::is_ocr_supported(m, self.library.core_context().file_type_registry()) - }) - } - - pub async fn process( - &self, - db: &sea_orm::DatabaseConnection, - entry: &ProcessorEntry, - ) -> Result { - let content_id = entry - .content_id - .ok_or_else(|| anyhow::anyhow!("Entry has no content_id"))?; - - debug!("→ Extracting text via OCR for: {}", entry.path.display()); - - // Extract text - let extracted_text = super::extract_text_from_file(&entry.path, &self.languages).await?; - - if extracted_text.is_empty() { - debug!("No text extracted from: {}", entry.path.display()); - return Ok(ProcessorResult::success(0, 0)); - } - - debug!("✓ Extracted {} characters of text", extracted_text.len()); - - // Update content_identity with extracted text - use crate::infra::db::entities::content_identity; - - let ci = content_identity::Entity::find_by_id(content_id) - .one(db) - .await? - .ok_or_else(|| anyhow::anyhow!("ContentIdentity not found"))?; - - let mut ci_active: content_identity::ActiveModel = ci.into(); - ci_active.text_content = Set(Some(extracted_text.clone())); - - ci_active.update(db).await?; - - debug!("✓ Stored extracted text in content_identity"); - - Ok(ProcessorResult::success(1, extracted_text.len() as u64)) - } - - pub fn name(&self) -> &'static str { - "ocr" - } -} diff --git a/core/src/ops/media/proxy/action.rs b/core/src/ops/media/proxy/action.rs deleted file mode 100644 index 1a935187c..000000000 --- a/core/src/ops/media/proxy/action.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Proxy generation action handlers - -use super::processor::ProxyProcessor; -use crate::{ - context::CoreContext, - infra::action::{error::ActionError, LibraryAction}, - ops::indexing::{path_resolver::PathResolver, processor::ProcessorEntry}, -}; -use specta::Type; -use std::sync::Arc; -use uuid::Uuid; - -/// Generate proxy for a single video file -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)] -pub struct GenerateProxyInput { - /// UUID of the entry to generate proxy for - pub entry_uuid: Uuid, - /// Proxy resolution (scrubbing, ultra_low, quick, editing) - pub resolution: Option, - /// Force regeneration even if proxy exists - pub force: bool, - /// Use hardware acceleration if available - pub use_hardware_accel: Option, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)] -pub struct GenerateProxyOutput { - /// Number of proxies generated - pub generated_count: usize, - /// Variant names that were generated - pub variants: Vec, - /// Total encoding time in seconds - pub encoding_time_secs: u64, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GenerateProxyAction { - input: GenerateProxyInput, -} - -impl GenerateProxyAction { - pub fn new(input: GenerateProxyInput) -> Self { - Self { input } - } -} - -impl LibraryAction for GenerateProxyAction { - type Input = GenerateProxyInput; - type Output = GenerateProxyOutput; - - fn from_input(input: GenerateProxyInput) -> Result { - Ok(Self::new(input)) - } - - async fn execute( - self, - library: Arc, - _context: Arc, - ) -> Result { - use crate::infra::db::entities; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let db = library.db().conn(); - - // Load entry by UUID - let entry = entities::entry::Entity::find() - .filter(entities::entry::Column::Uuid.eq(self.input.entry_uuid)) - .one(db) - .await - .map_err(|e| ActionError::Internal(format!("Failed to load entry: {}", e)))? - .ok_or_else(|| ActionError::Internal("Entry not found".to_string()))?; - - // Get full path - let path = PathResolver::get_full_path(db, entry.id) - .await - .map_err(|e| ActionError::Internal(format!("Failed to resolve path: {}", e)))?; - - // Get MIME type - let mime_type = if let Some(content_id) = entry.content_id { - if let Ok(Some(ci)) = entities::content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = entities::mime_type::Entity::find_by_id(mime_id) - .one(db) - .await - { - Some(mime.mime_type) - } else { - None - } - } else { - None - } - } else { - None - } - } else { - return Err(ActionError::Internal( - "Entry has no content identity".to_string(), - )); - }; - - // Build processor entry - let kind = match entry.kind { - 0 => crate::ops::indexing::state::EntryKind::File, - 1 => crate::ops::indexing::state::EntryKind::Directory, - 2 => crate::ops::indexing::state::EntryKind::Symlink, - _ => crate::ops::indexing::state::EntryKind::File, - }; - - let proc_entry = ProcessorEntry { - id: entry.id, - uuid: entry.uuid, - path: path.clone(), - kind, - size: entry.size as u64, - content_id: entry.content_id, - mime_type: mime_type.clone(), - }; - - // Create proxy processor with custom settings - let use_hw = self.input.use_hardware_accel.unwrap_or(true); - - let mut processor = ProxyProcessor::new(library.clone()) - .with_enabled(true) // Enable for manual triggering - .with_hardware_accel(use_hw); - - // Parse resolution if provided - if let Some(resolution_str) = &self.input.resolution { - let settings = serde_json::json!({ - "enabled": true, - "resolution": resolution_str, - }); - processor = processor - .with_settings(&settings) - .map_err(|e| ActionError::Internal(format!("Invalid settings: {}", e)))?; - } - - // Check if processor should run - if !processor.should_process(&proc_entry) { - return Err(ActionError::Internal( - "File type does not support proxies (not a video) or file too large".to_string(), - )); - } - - // Time the operation - let start = std::time::Instant::now(); - - // Process the file - let result = processor - .process(db, &proc_entry) - .await - .map_err(|e| ActionError::Internal(format!("Proxy generation failed: {}", e)))?; - - let encoding_time = start.elapsed().as_secs(); - - if !result.success { - return Err(ActionError::Internal( - result.error.unwrap_or_else(|| "Unknown error".to_string()), - )); - } - - // Get variant name - let variant_names = vec![processor.variant.variant.as_str().to_string()]; - - Ok(GenerateProxyOutput { - generated_count: result.artifacts_created, - variants: variant_names, - encoding_time_secs: encoding_time, - }) - } - - fn action_kind(&self) -> &'static str { - "media.proxy.generate" - } -} - -crate::register_library_action!(GenerateProxyAction, "media.proxy.generate"); diff --git a/core/src/ops/media/proxy/config.rs b/core/src/ops/media/proxy/config.rs deleted file mode 100644 index c7820dac3..000000000 --- a/core/src/ops/media/proxy/config.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Proxy variant configuration and presets - -use crate::ops::sidecar::types::{SidecarFormat, SidecarVariant}; -use serde::{Deserialize, Serialize}; - -/// Proxy resolution preset -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum ProxyResolution { - /// 180p @ 15fps - Ultra-minimal for scrubbing - Scrubbing, - /// 240p - Ultra-low for instant preview - UltraLow, - /// 480p - Quick preview - Quick, - /// 720p - Editing workflow - Editing, -} - -impl ProxyResolution { - pub fn height(&self) -> u32 { - match self { - Self::Scrubbing => 180, - Self::UltraLow => 240, - Self::Quick => 480, - Self::Editing => 720, - } - } - - pub fn framerate(&self) -> Option { - match self { - Self::Scrubbing => Some(15), // Reduced framerate for scrubbing - _ => None, // Use original framerate - } - } - - pub fn crf(&self) -> u32 { - match self { - Self::Scrubbing => 33, - Self::UltraLow => 30, - Self::Quick => 26, - Self::Editing => 23, - } - } - - pub fn audio_bitrate(&self) -> u32 { - match self { - Self::Scrubbing => 32, // kbps - Self::UltraLow => 48, - Self::Quick => 96, - Self::Editing => 128, - } - } - - pub fn audio_sample_rate(&self) -> u32 { - match self { - Self::Scrubbing | Self::UltraLow => 22050, // Reduced for smaller size - Self::Quick | Self::Editing => 44100, // Standard - } - } - - pub fn variant_name(&self) -> &'static str { - match self { - Self::Scrubbing => "proxy_scrub", - Self::UltraLow => "proxy_ultra", - Self::Quick => "proxy_quick", - Self::Editing => "proxy_edit", - } - } - - pub fn as_str(&self) -> &'static str { - match self { - Self::Scrubbing => "scrubbing", - Self::UltraLow => "ultra_low", - Self::Quick => "quick", - Self::Editing => "editing", - } - } -} - -/// Configuration for a single proxy variant -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxyVariantConfig { - pub resolution: ProxyResolution, - pub variant: SidecarVariant, -} - -impl ProxyVariantConfig { - pub fn new(resolution: ProxyResolution) -> Self { - Self { - resolution, - variant: SidecarVariant::new(resolution.variant_name()), - } - } - - pub fn format(&self) -> SidecarFormat { - SidecarFormat::Mp4 - } -} - -/// Standard proxy variants -pub struct ProxyVariants; - -impl ProxyVariants { - /// Scrubbing proxy - 180p @ 15fps (fast generation, tiny size) - pub fn scrubbing() -> ProxyVariantConfig { - ProxyVariantConfig::new(ProxyResolution::Scrubbing) - } - - /// Ultra-low proxy - 240p (instant preview) - pub fn ultra_low() -> ProxyVariantConfig { - ProxyVariantConfig::new(ProxyResolution::UltraLow) - } - - /// Quick proxy - 480p (desktop preview) - pub fn quick() -> ProxyVariantConfig { - ProxyVariantConfig::new(ProxyResolution::Quick) - } - - /// Editing proxy - 720p (professional workflow) - pub fn editing() -> ProxyVariantConfig { - ProxyVariantConfig::new(ProxyResolution::Editing) - } - - /// Default variants for auto-generation - pub fn defaults() -> Vec { - vec![Self::scrubbing()] // Only scrubbing proxy by default - } - - /// All standard variants - pub fn all() -> Vec { - vec![ - Self::scrubbing(), - Self::ultra_low(), - Self::quick(), - Self::editing(), - ] - } -} - -/// Configuration for proxy generation job -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxyJobConfig { - /// Proxy variants to generate - pub variants: Vec, - - /// Whether to regenerate existing proxies - pub regenerate: bool, - - /// Use hardware acceleration if available - pub use_hardware_accel: bool, - - /// FFmpeg preset (ultrafast, veryfast, fast, medium, slow) - pub preset: String, -} - -impl Default for ProxyJobConfig { - fn default() -> Self { - Self { - variants: ProxyVariants::defaults(), - regenerate: false, - use_hardware_accel: true, - preset: "veryfast".to_string(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_proxy_resolutions() { - let scrub = ProxyResolution::Scrubbing; - assert_eq!(scrub.height(), 180); - assert_eq!(scrub.framerate(), Some(15)); - assert_eq!(scrub.crf(), 33); - assert_eq!(scrub.audio_bitrate(), 32); - - let quick = ProxyResolution::Quick; - assert_eq!(quick.height(), 480); - assert_eq!(quick.framerate(), None); // Use original - assert_eq!(quick.crf(), 26); - } - - #[test] - fn test_defaults() { - let defaults = ProxyVariants::defaults(); - assert_eq!(defaults.len(), 1); - assert_eq!(defaults[0].resolution, ProxyResolution::Scrubbing); - } -} diff --git a/core/src/ops/media/proxy/error.rs b/core/src/ops/media/proxy/error.rs deleted file mode 100644 index efc4b102e..000000000 --- a/core/src/ops/media/proxy/error.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Proxy generation error types - -use thiserror::Error; - -pub type ProxyResult = Result; - -#[derive(Error, Debug)] -pub enum ProxyError { - #[error("File not found: {0}")] - FileNotFound(String), - - #[error("Unsupported format: {0}")] - UnsupportedFormat(String), - - #[error("Video encoding failed: {0}")] - EncodingFailed(String), - - #[error("FFmpeg not found in PATH")] - FFmpegNotFound, - - #[error("FFmpeg process failed with status: {0}")] - FFmpegProcessFailed(i32), - - #[error("Invalid preset: {0}")] - InvalidPreset(String), - - #[error("No video duration")] - NoVideoDuration, - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("Other error: {0}")] - Other(String), -} - -impl ProxyError { - pub fn other(msg: impl Into) -> Self { - Self::Other(msg.into()) - } - - pub fn encoding_failed(msg: impl Into) -> Self { - Self::EncodingFailed(msg.into()) - } -} diff --git a/core/src/ops/media/proxy/generator.rs b/core/src/ops/media/proxy/generator.rs deleted file mode 100644 index cd88bf788..000000000 --- a/core/src/ops/media/proxy/generator.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Proxy generation engine using FFmpeg command wrapper - -use super::{ - error::{ProxyError, ProxyResult}, - hardware::{detect_hardware_accel, HardwareAccel}, - ProxyVariantConfig, -}; -use serde::{Deserialize, Serialize}; -use std::{ - ffi::OsString, - path::Path, - process::Stdio, - time::{Duration, Instant}, -}; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::Command, -}; -use tracing::{debug, info, warn}; - -/// Information about a generated proxy -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxyInfo { - pub size_bytes: u64, - pub encoding_time_secs: u64, - pub average_speed_multiplier: f32, -} - -/// Progress information during proxy generation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProxyProgress { - pub frame: u32, - pub fps: f32, - pub speed_multiplier: f32, - pub time_secs: f32, - pub percent: f32, -} - -/// Proxy generator using FFmpeg -pub struct ProxyGenerator { - config: ProxyVariantConfig, - preset: String, - use_hardware_accel: bool, - hardware_accel: Option, -} - -impl ProxyGenerator { - /// Create a new proxy generator - pub fn new(config: ProxyVariantConfig, preset: String, use_hardware_accel: bool) -> Self { - let hardware_accel = if use_hardware_accel { - detect_hardware_accel() - } else { - None - }; - - if let Some(ref hw) = hardware_accel { - info!("Using hardware acceleration: {:?}", hw); - } else { - info!("Using software encoding (libx264)"); - } - - Self { - config, - preset, - use_hardware_accel, - hardware_accel, - } - } - - /// Generate a proxy video - pub async fn generate( - &self, - input: impl AsRef, - output: impl AsRef, - ) -> ProxyResult { - let input = input.as_ref(); - let output = output.as_ref(); - - debug!( - "Generating {} proxy: {} -> {}", - self.config.resolution.as_str(), - input.display(), - output.display() - ); - - // Ensure output directory exists - if let Some(parent) = output.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Build FFmpeg command - let args = self.build_ffmpeg_args(input, output)?; - - // Spawn FFmpeg process - let mut cmd = Command::new("ffmpeg"); - cmd.args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - - debug!("Executing FFmpeg command: {:?}", cmd); - - let mut child = cmd.spawn().map_err(|e| ProxyError::FFmpegNotFound)?; - - let start_time = Instant::now(); - let mut last_speed = 0.0f32; - - // Monitor progress from stderr - if let Some(stderr) = child.stderr.take() { - let reader = BufReader::new(stderr); - let mut lines = reader.lines(); - - while let Ok(Some(line)) = lines.next_line().await { - if let Some(speed) = self.parse_speed_from_line(&line) { - last_speed = speed; - debug!("Encoding speed: {:.1}× realtime", speed); - } - } - } - - // Wait for completion - let status = child - .wait() - .await - .map_err(|e| ProxyError::Other(format!("Failed to wait for ffmpeg: {}", e)))?; - - if !status.success() { - let code = status.code().unwrap_or(-1); - return Err(ProxyError::FFmpegProcessFailed(code)); - } - - let encoding_time = start_time.elapsed(); - - // Get output file size - let metadata = tokio::fs::metadata(output).await?; - let size_bytes = metadata.len(); - - info!( - "✓ Generated proxy: {} MB in {:.1}s ({:.1}× realtime)", - size_bytes / (1024 * 1024), - encoding_time.as_secs_f32(), - last_speed - ); - - Ok(ProxyInfo { - size_bytes, - encoding_time_secs: encoding_time.as_secs(), - average_speed_multiplier: last_speed, - }) - } - - /// Build FFmpeg command arguments safely - fn build_ffmpeg_args(&self, input: &Path, output: &Path) -> ProxyResult> { - let mut args = Vec::new(); - - // Overwrite output file without prompting - args.push(OsString::from("-y")); - - // Input file - args.push(OsString::from("-i")); - args.push(input.as_os_str().to_owned()); - - // Build video filter - let mut vf_parts = Vec::new(); - - // Scale to target resolution - vf_parts.push(format!("scale=-2:{}", self.config.resolution.height())); - - // Add framerate reduction if needed - if let Some(fps) = self.config.resolution.framerate() { - vf_parts.push(format!("fps={}", fps)); - } - - let vf = vf_parts.join(","); - args.push(OsString::from("-vf")); - args.push(OsString::from(vf)); - - // Video codec settings - if let Some(hw) = &self.hardware_accel { - // Hardware acceleration - args.push(OsString::from("-c:v")); - args.push(OsString::from(hw.encoder_name())); - - // Add hardware-specific preset if supported - if let Some(preset) = hw.preset() { - args.push(OsString::from("-preset")); - args.push(OsString::from(preset)); - } - - // Add extra hardware-specific args - for arg in hw.extra_args() { - args.push(OsString::from(arg)); - } - - // Use bitrate mode for hardware encoders (not CRF) - let target_bitrate = self.calculate_target_bitrate(); - args.push(OsString::from("-b:v")); - args.push(OsString::from(format!("{}k", target_bitrate))); - } else { - // Software encoding (libx264) - args.push(OsString::from("-c:v")); - args.push(OsString::from("libx264")); - - args.push(OsString::from("-preset")); - args.push(OsString::from(&self.preset)); - - // CRF mode for software - args.push(OsString::from("-crf")); - args.push(OsString::from(self.config.resolution.crf().to_string())); - } - - // Audio codec settings - args.push(OsString::from("-c:a")); - args.push(OsString::from("aac")); - args.push(OsString::from("-b:a")); - args.push(OsString::from(format!( - "{}k", - self.config.resolution.audio_bitrate() - ))); - args.push(OsString::from("-ar")); - args.push(OsString::from( - self.config.resolution.audio_sample_rate().to_string(), - )); - - // Optimize for streaming (moov atom at start) - args.push(OsString::from("-movflags")); - args.push(OsString::from("+faststart")); - - // Output file - args.push(output.as_os_str().to_owned()); - - Ok(args) - } - - /// Calculate target bitrate for hardware encoders - fn calculate_target_bitrate(&self) -> u32 { - // Approximate bitrate based on resolution - match self.config.resolution.height() { - 180 => 300, // kbps - 240 => 500, - 480 => 1000, - 720 => 2000, - 1080 => 4000, - _ => 2000, - } - } - - /// Parse encoding speed from FFmpeg output line - fn parse_speed_from_line(&self, line: &str) -> Option { - // FFmpeg progress format: "... speed=51.2x ..." - if let Some(speed_idx) = line.find("speed=") { - let speed_str = &line[speed_idx + 6..]; - if let Some(x_idx) = speed_str.find('x') { - let speed_num = &speed_str[..x_idx]; - return speed_num.trim().parse::().ok(); - } - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_speed_parsing() { - let gen = ProxyGenerator::new( - ProxyVariantConfig::new(super::super::ProxyResolution::Quick), - "veryfast".to_string(), - false, - ); - - let line = "frame= 1234 fps=42 q=28.0 size= 1024kB time=00:00:10.00 bitrate= 839.7kbits/s speed=51.2x"; - assert_eq!(gen.parse_speed_from_line(line), Some(51.2)); - - let line2 = "frame= 100 fps=99 speed= 155x"; - assert_eq!(gen.parse_speed_from_line(line2), Some(155.0)); - } - - #[test] - fn test_bitrate_calculation() { - let gen = ProxyGenerator::new( - ProxyVariantConfig::new(super::super::ProxyResolution::Quick), - "veryfast".to_string(), - false, - ); - - assert_eq!(gen.calculate_target_bitrate(), 1000); // 480p - } -} diff --git a/core/src/ops/media/proxy/hardware.rs b/core/src/ops/media/proxy/hardware.rs deleted file mode 100644 index 78d4881b8..000000000 --- a/core/src/ops/media/proxy/hardware.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Hardware acceleration detection for video encoding - -use serde::{Deserialize, Serialize}; -use std::process::Command; -use tracing::{debug, warn}; - -/// Supported hardware acceleration platforms -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] -pub enum HardwareAccel { - /// Apple VideoToolbox (macOS/iOS) - VideoToolbox, - /// NVIDIA NVENC - NVENC, - /// Intel QuickSync - QuickSync, - /// AMD AMF - AMF, - /// VA-API (Linux) - VAAPI, -} - -impl HardwareAccel { - /// Get the FFmpeg encoder name for this acceleration platform - pub fn encoder_name(&self) -> &'static str { - match self { - Self::VideoToolbox => "h264_videotoolbox", - Self::NVENC => "h264_nvenc", - Self::QuickSync => "h264_qsv", - Self::AMF => "h264_amf", - Self::VAAPI => "h264_vaapi", - } - } - - /// Get recommended preset for this encoder - pub fn preset(&self) -> Option<&'static str> { - match self { - Self::VideoToolbox => None, // VideoToolbox doesn't use presets - Self::NVENC | Self::QuickSync | Self::AMF => Some("fast"), - Self::VAAPI => None, - } - } - - /// Additional arguments for this encoder - pub fn extra_args(&self) -> Vec<&'static str> { - match self { - Self::VideoToolbox => vec![], - Self::NVENC => vec!["-rc", "vbr"], - Self::QuickSync => vec!["-look_ahead", "0"], - Self::AMF => vec![], - Self::VAAPI => vec![], - } - } -} - -/// Detect available hardware acceleration -pub fn detect_hardware_accel() -> Option { - // Try to run ffmpeg -encoders and check which hardware encoders are available - let output = match Command::new("ffmpeg") - .args(["-hide_banner", "-encoders"]) - .output() - { - Ok(out) => out, - Err(e) => { - warn!("Failed to run ffmpeg to detect hardware encoders: {}", e); - return None; - } - }; - - if !output.status.success() { - warn!("ffmpeg -encoders command failed"); - return None; - } - - let encoders = String::from_utf8_lossy(&output.stdout); - - // Platform-specific detection order (prefer native first) - #[cfg(target_os = "macos")] - { - if encoders.contains("h264_videotoolbox") { - debug!("Detected VideoToolbox hardware acceleration"); - return Some(HardwareAccel::VideoToolbox); - } - } - - // NVENC (NVIDIA) - if encoders.contains("h264_nvenc") { - debug!("Detected NVENC hardware acceleration"); - return Some(HardwareAccel::NVENC); - } - - // QuickSync (Intel) - if encoders.contains("h264_qsv") { - debug!("Detected QuickSync hardware acceleration"); - return Some(HardwareAccel::QuickSync); - } - - // AMD - if encoders.contains("h264_amf") { - debug!("Detected AMF hardware acceleration"); - return Some(HardwareAccel::AMF); - } - - // VA-API (Linux) - #[cfg(target_os = "linux")] - { - if encoders.contains("h264_vaapi") { - debug!("Detected VA-API hardware acceleration"); - return Some(HardwareAccel::VAAPI); - } - } - - debug!("No hardware acceleration detected, will use software encoding"); - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encoder_names() { - assert_eq!( - HardwareAccel::VideoToolbox.encoder_name(), - "h264_videotoolbox" - ); - assert_eq!(HardwareAccel::NVENC.encoder_name(), "h264_nvenc"); - assert_eq!(HardwareAccel::QuickSync.encoder_name(), "h264_qsv"); - } - - #[test] - fn test_detection() { - // This will actually detect hardware on the test system - let hw = detect_hardware_accel(); - println!("Detected hardware: {:?}", hw); - } -} diff --git a/core/src/ops/media/proxy/job.rs b/core/src/ops/media/proxy/job.rs deleted file mode 100644 index 708078780..000000000 --- a/core/src/ops/media/proxy/job.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Proxy generation job for batch operations - -use super::processor::ProxyProcessor; -use super::{ProxyJobConfig, ProxyPhase, ProxyState}; -use crate::{ - infra::{ - db::entities::entry, - job::{prelude::*, traits::DynJob}, - }, - ops::indexing::processor::ProcessorEntry, -}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use tracing::{info, warn}; -use uuid::Uuid; - -/// Proxy generation job -#[derive(Serialize, Deserialize)] -pub struct ProxyJob { - config: ProxyJobConfig, - state: ProxyState, -} - -impl ProxyJob { - pub fn new(config: ProxyJobConfig) -> Self { - Self { - config, - state: ProxyState::new(), - } - } - - pub fn with_defaults() -> Self { - Self::new(ProxyJobConfig::default()) - } - - async fn run_discovery(&mut self, ctx: &JobContext<'_>) -> JobResult<()> { - use crate::infra::db::entities::{content_identity, entry}; - use sea_orm::{ - ColumnTrait, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait, - }; - - ctx.log("Starting proxy discovery phase"); - - let db = ctx.library_db(); - - // Query for video entries with content - let results = entry::Entity::find() - .filter(entry::Column::Kind.eq(0)) // Files only - .join(JoinType::InnerJoin, entry::Relation::ContentIdentity.def()) - .filter(content_identity::Column::KindId.eq(2)) // Video kind - .filter(content_identity::Column::Uuid.is_not_null()) - .all(db) - .await - .map_err(|e| JobError::execution(format!("Database query failed: {}", e)))?; - - ctx.log(format!("Found {} video entries", results.len())); - - // Build entry list - for entry_model in results { - let path = crate::ops::indexing::PathResolver::get_full_path(db, entry_model.id) - .await - .map_err(|e| JobError::execution(format!("Failed to resolve path: {}", e)))?; - - self.state - .entries - .push((entry_model.id, path, entry_model.extension)); - } - - ctx.log(format!( - "Discovery complete: {} entries to process", - self.state.entries.len() - )); - - Ok(()) - } -} - -impl Job for ProxyJob { - const NAME: &'static str = "proxy_generation"; - const RESUMABLE: bool = true; - const DESCRIPTION: Option<&'static str> = Some("Generate video proxies for smooth playback"); -} - -impl DynJob for ProxyJob { - fn job_name(&self) -> &'static str { - Self::NAME - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct ProxyJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, - pub total_encoding_time_secs: u64, -} - -impl From for JobOutput { - fn from(output: ProxyJobOutput) -> Self { - JobOutput::Custom(serde_json::json!({ - "type": "proxy_generation", - "total_processed": output.total_processed, - "success_count": output.success_count, - "error_count": output.error_count, - "encoding_time_secs": output.total_encoding_time_secs, - })) - } -} - -#[async_trait::async_trait] -impl JobHandler for ProxyJob { - type Output = ProxyJobOutput; - - async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { - // Discovery phase - if self.state.phase == ProxyPhase::Discovery { - ctx.log("Starting proxy discovery phase"); - self.run_discovery(&ctx).await?; - self.state.phase = ProxyPhase::Processing; - } - - // Processing phase - ctx.log(format!( - "Proxy processing phase starting with {} entries", - self.state.entries.len() - )); - - // Create processor instance - let processor = ProxyProcessor::new(ctx.library_arc()) - .with_enabled(true) // Enable for job execution - .with_hardware_accel(self.config.use_hardware_accel); - - let total = self.state.entries.len(); - - // Process each entry ONE AT A TIME (proxies are long operations) - while self.state.processed < total { - ctx.check_interrupt().await?; - - let (entry_id, path, mime_type) = &self.state.entries[self.state.processed]; - - ctx.log(format!( - "Processing {}/{}: {}", - self.state.processed + 1, - total, - path.display() - )); - - // Load entry model - let entry_model = entry::Entity::find_by_id(*entry_id) - .one(ctx.library_db()) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - // Build processor entry - let proc_entry = ProcessorEntry { - id: *entry_id, - uuid: entry_model.uuid, - path: path.clone(), - kind: crate::ops::indexing::state::EntryKind::File, - size: entry_model.size as u64, - content_id: entry_model.content_id, - mime_type: mime_type.clone(), - }; - - // Check if processor should run - if !processor.should_process(&proc_entry) { - self.state.processed += 1; - continue; - } - - // Time the encoding - let start = std::time::Instant::now(); - - // Process entry using processor - match processor.process(ctx.library_db(), &proc_entry).await { - Ok(result) if result.success => { - let encoding_time = start.elapsed().as_secs(); - self.state.total_encoding_time_secs += encoding_time; - - ctx.log(format!( - "Generated {} proxy variants for {} in {}s", - result.artifacts_created, - path.display(), - encoding_time - )); - self.state.success_count += 1; - } - Ok(_) => { - warn!("Proxy generation failed for {}", path.display()); - self.state.error_count += 1; - } - Err(e) => { - ctx.log(format!("ERROR: Proxy error for {}: {}", path.display(), e)); - self.state.error_count += 1; - } - } - - self.state.processed += 1; - - // Report progress - ctx.progress(Progress::Count { - current: self.state.processed, - total, - }); - - // Checkpoint after EACH video (proxies are long operations) - ctx.checkpoint().await?; - } - - self.state.phase = ProxyPhase::Complete; - ctx.log(format!( - "Proxy generation complete: {} success, {} errors, total encoding time: {}s", - self.state.success_count, self.state.error_count, self.state.total_encoding_time_secs - )); - - Ok(ProxyJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - total_encoding_time_secs: self.state.total_encoding_time_secs, - }) - } - - async fn on_resume(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log(format!( - "Resuming proxy job at {}/{} ({}s total encoding time)", - self.state.processed, - self.state.entries.len(), - self.state.total_encoding_time_secs - )); - Ok(()) - } - - async fn on_pause(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log("Pausing proxy generation job - state will be preserved"); - Ok(()) - } - - async fn on_cancel(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log(format!( - "Cancelling proxy job - generated {} proxies", - self.state.success_count - )); - Ok(()) - } -} diff --git a/core/src/ops/media/proxy/mod.rs b/core/src/ops/media/proxy/mod.rs deleted file mode 100644 index 7acd97474..000000000 --- a/core/src/ops/media/proxy/mod.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Proxy generation system -//! -//! Generates lower-resolution proxy videos for smooth playback, timeline scrubbing, -//! and editing workflows. Operates as both a processor (for responder) and a job -//! (for batch operations). - -pub mod action; -mod config; -mod error; -mod generator; -mod hardware; -pub mod job; -pub mod processor; -mod state; - -pub use action::GenerateProxyAction; -pub use config::{ProxyJobConfig, ProxyResolution, ProxyVariantConfig, ProxyVariants}; -pub use error::{ProxyError, ProxyResult}; -pub use generator::{ProxyGenerator, ProxyInfo}; -pub use hardware::{detect_hardware_accel, HardwareAccel}; -pub use job::ProxyJob; -pub use processor::ProxyProcessor; -pub use state::{ProxyPhase, ProxyState}; - -use crate::library::Library; -use crate::ops::sidecar::types::SidecarKind; -use std::path::Path; -use std::sync::Arc; -use tracing::{debug, warn}; -use uuid::Uuid; - -/// Generate proxy for a single video file -/// -/// This function generates proxy variants for a single file and registers -/// them as sidecars. Used by both ProxyProcessor (responder) and -/// ProxyJob (batch operations). -/// -/// # Arguments -/// * `library` - The library context -/// * `content_uuid` - The UUID of the content identity -/// * `video_path` - The filesystem path to the video file -/// * `variants` - The proxy variants to generate -/// * `use_hardware_accel` - Whether to use hardware acceleration if available -/// * `preset` - FFmpeg preset (ultrafast, veryfast, fast, medium, slow) -/// * `regenerate` - Whether to regenerate existing proxies -/// -/// # Returns -/// Number of proxies successfully generated -pub async fn generate_proxy_for_file( - library: &Arc, - content_uuid: &Uuid, - video_path: &Path, - variants: &[ProxyVariantConfig], - use_hardware_accel: bool, - preset: &str, - regenerate: bool, -) -> ProxyResult { - // Get sidecar manager - let sidecar_manager = library - .core_context() - .get_sidecar_manager() - .await - .ok_or_else(|| ProxyError::other("SidecarManager not available"))?; - - let mut generated_count = 0; - - for variant_config in variants { - // Check if already exists - if !regenerate - && sidecar_manager - .exists( - &library.id(), - content_uuid, - &SidecarKind::Proxy, - &variant_config.variant, - &variant_config.format(), - ) - .await - .unwrap_or(false) - { - debug!( - "Proxy already exists for {}: {}", - content_uuid, - variant_config.variant.as_str() - ); - continue; - } - - // Compute output path - let output_path = sidecar_manager - .compute_path( - &library.id(), - content_uuid, - &SidecarKind::Proxy, - &variant_config.variant, - &variant_config.format(), - ) - .await - .map_err(|e| ProxyError::other(format!("Failed to compute path: {}", e)))?; - - // Ensure parent directory exists - if let Some(parent) = output_path.absolute_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Generate proxy using generator - let generator = ProxyGenerator::new( - variant_config.clone(), - preset.to_string(), - use_hardware_accel, - ); - - match generator - .generate(video_path, &output_path.absolute_path) - .await - { - Ok(proxy_info) => { - // Record the sidecar - if let Err(e) = sidecar_manager - .record_sidecar( - library, - content_uuid, - &SidecarKind::Proxy, - &variant_config.variant, - &variant_config.format(), - proxy_info.size_bytes, - None, - ) - .await - { - warn!( - "Failed to record sidecar for {}: {}", - variant_config.variant.as_str(), - e - ); - } else { - debug!( - "✓ Generated proxy {}: {} MB in {}s ({:.1}× realtime)", - variant_config.variant.as_str(), - proxy_info.size_bytes / (1024 * 1024), - proxy_info.encoding_time_secs, - proxy_info.average_speed_multiplier - ); - generated_count += 1; - } - } - Err(e) => { - warn!( - "Failed to generate proxy {} for {}: {}", - variant_config.variant.as_str(), - video_path.display(), - e - ); - } - } - } - - Ok(generated_count) -} diff --git a/core/src/ops/media/proxy/processor.rs b/core/src/ops/media/proxy/processor.rs deleted file mode 100644 index a484107a2..000000000 --- a/core/src/ops/media/proxy/processor.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Proxy processor - atomic operation for generating video proxies - -use crate::library::Library; -use crate::ops::indexing::processor::{ProcessorEntry, ProcessorResult}; -use crate::ops::indexing::state::EntryKind; -use anyhow::Result; -use serde_json::Value; -use std::sync::Arc; -use tracing::{debug, warn}; - -use super::{ProxyVariantConfig, ProxyVariants}; - -pub struct ProxyProcessor { - library: Arc, - pub variant: ProxyVariantConfig, - enabled: bool, - max_file_size_gb: u64, - use_hardware_accel: bool, - preset: String, -} - -impl ProxyProcessor { - pub fn new(library: Arc) -> Self { - Self { - library, - variant: ProxyVariants::scrubbing(), // Only scrubbing proxy - enabled: false, // Disabled by default - max_file_size_gb: 5, - use_hardware_accel: true, - preset: "ultrafast".to_string(), - } - } - - pub fn with_enabled(mut self, enabled: bool) -> Self { - self.enabled = enabled; - self - } - - pub fn with_max_file_size_gb(mut self, max_gb: u64) -> Self { - self.max_file_size_gb = max_gb; - self - } - - pub fn with_hardware_accel(mut self, use_hw: bool) -> Self { - self.use_hardware_accel = use_hw; - self - } - - pub fn with_settings(mut self, settings: &Value) -> Result { - if let Some(enabled) = settings.get("enabled").and_then(|v| v.as_bool()) { - self.enabled = enabled; - } - - if let Some(max_size) = settings.get("max_file_size_gb").and_then(|v| v.as_u64()) { - self.max_file_size_gb = max_size; - } - - if let Some(use_hw) = settings.get("use_hardware_accel").and_then(|v| v.as_bool()) { - self.use_hardware_accel = use_hw; - } - - Ok(self) - } - - pub fn should_process(&self, entry: &ProcessorEntry) -> bool { - if !self.enabled { - return false; - } - - if !matches!(entry.kind, EntryKind::File) { - return false; - } - - if entry.content_id.is_none() { - return false; - } - - // Check file size limit (skip huge files) - let size_gb = entry.size / (1024 * 1024 * 1024); - if size_gb > self.max_file_size_gb { - debug!( - "Skipping proxy generation for {}: file too large ({} GB)", - entry.path.display(), - size_gb - ); - return false; - } - - // Only video files - check both MIME type and extension - entry.mime_type.as_ref().map_or(false, |m| { - m.starts_with("video/") - || matches!( - m.to_lowercase().as_str(), - "mp4" | "mov" | "avi" | "mkv" | "webm" | "flv" | "wmv" | "m4v" - ) - }) - } - - pub async fn process( - &self, - db: &sea_orm::DatabaseConnection, - entry: &ProcessorEntry, - ) -> Result { - if !self.enabled { - return Ok(ProcessorResult::success(0, 0)); - } - - // Get content UUID - let content_uuid = if let Some(content_id) = entry.content_id { - use crate::infra::db::entities::content_identity; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let ci = content_identity::Entity::find() - .filter(content_identity::Column::Id.eq(content_id)) - .one(db) - .await? - .ok_or_else(|| anyhow::anyhow!("ContentIdentity not found"))?; - - ci.uuid - .ok_or_else(|| anyhow::anyhow!("ContentIdentity missing UUID"))? - } else { - return Ok(ProcessorResult::failure( - "Entry has no content_id".to_string(), - )); - }; - - debug!("→ Generating scrubbing proxy for: {}", entry.path.display()); - - // Call shared generation function - let count = super::generate_proxy_for_file( - &self.library, - &content_uuid, - &entry.path, - &[self.variant.clone()], - self.use_hardware_accel, - &self.preset, - false, // Don't regenerate in processor - ) - .await - .map_err(|e| anyhow::anyhow!("Proxy generation failed: {}", e))?; - - if count > 0 { - debug!("✓ Generated scrubbing proxy for: {}", entry.path.display()); - } - - Ok(ProcessorResult::success(count, 0)) - } - - pub fn name(&self) -> &'static str { - "proxy" - } -} diff --git a/core/src/ops/media/proxy/state.rs b/core/src/ops/media/proxy/state.rs deleted file mode 100644 index ca78a2b80..000000000 --- a/core/src/ops/media/proxy/state.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Proxy job state structures - -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -/// Job execution phase -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum ProxyPhase { - Discovery, - Processing, - Complete, -} - -/// Job state for batch proxy generation -#[derive(Debug, Serialize, Deserialize)] -pub struct ProxyState { - pub phase: ProxyPhase, - /// Entries to process: (entry_id, path, mime_type) - pub entries: Vec<(i32, PathBuf, Option)>, - pub processed: usize, - pub success_count: usize, - pub error_count: usize, - pub total_encoding_time_secs: u64, -} - -impl ProxyState { - pub fn new() -> Self { - Self { - phase: ProxyPhase::Discovery, - entries: Vec::new(), - processed: 0, - success_count: 0, - error_count: 0, - total_encoding_time_secs: 0, - } - } -} - -impl Default for ProxyState { - fn default() -> Self { - Self::new() - } -} diff --git a/core/src/ops/media/speech/action.rs b/core/src/ops/media/speech/action.rs deleted file mode 100644 index cfa104b77..000000000 --- a/core/src/ops/media/speech/action.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Speech-to-text action handlers - -use crate::{ - context::CoreContext, - infra::action::{error::ActionError, LibraryAction}, -}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use uuid::Uuid; - -// Types are always available regardless of feature flags -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct TranscribeAudioInput { - pub entry_uuid: Uuid, - pub model: Option, // whisper model (tiny, base, small, medium, large) - pub language: Option, // Language code or None for auto-detect -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct TranscribeAudioOutput { - /// Job ID for tracking transcription progress - pub job_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TranscribeAudioAction { - input: TranscribeAudioInput, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct SpeechToTextJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl TranscribeAudioAction { - pub fn new(input: TranscribeAudioInput) -> Self { - Self { input } - } -} - -impl LibraryAction for TranscribeAudioAction { - type Input = TranscribeAudioInput; - type Output = TranscribeAudioOutput; - - fn from_input(input: TranscribeAudioInput) -> Result { - Ok(Self::new(input)) - } - - async fn execute( - self, - library: Arc, - _context: Arc, - ) -> Result { - #[cfg(feature = "speech-to-text")] - { - tracing::info!( - "Dispatching speech-to-text job for entry: {}", - self.input.entry_uuid - ); - - // Create job config for single file - let model_name = self.input.model.unwrap_or_else(|| "base".to_string()); - - let job_config = super::job::SpeechToTextJobConfig { - location_id: None, - entry_uuid: Some(self.input.entry_uuid), // Single file mode - model: model_name, - language: self.input.language, - reprocess: false, - }; - - // Create job - let job = super::job::SpeechToTextJob::new(job_config); - - // Dispatch job - it will handle model download in discovery phase - let job_handle = library - .jobs() - .dispatch(job) - .await - .map_err(|e| ActionError::Internal(format!("Failed to dispatch job: {}", e)))?; - - tracing::info!("Speech-to-text job dispatched: {}", job_handle.id()); - - Ok(TranscribeAudioOutput { - job_id: job_handle.id().to_string(), - }) - } - - #[cfg(not(feature = "speech-to-text"))] - { - Err(ActionError::InvalidInput( - "Speech-to-text feature is not enabled. Please rebuild with --features ffmpeg,whisper".to_string() - )) - } - } - - fn action_kind(&self) -> &'static str { - "media.speech.transcribe" - } -} - -// Registration always happens regardless of feature flags -crate::register_library_action!(TranscribeAudioAction, "media.speech.transcribe"); diff --git a/core/src/ops/media/speech/job.rs b/core/src/ops/media/speech/job.rs deleted file mode 100644 index b6a0208a4..000000000 --- a/core/src/ops/media/speech/job.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Speech-to-text job for batch audio transcription - -use super::processor::SpeechToTextProcessor; -use crate::{ - infra::{ - db::entities::entry, - job::{prelude::*, traits::DynJob}, - }, - ops::indexing::processor::ProcessorEntry, -}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use tracing::{info, warn}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct SpeechToTextJobConfig { - /// Location ID to process (None = all entries in library) - pub location_id: Option, - /// Single entry UUID to process (for UI-triggered single file) - pub entry_uuid: Option, - /// Whisper model to use (tiny, base, small, medium, large) - pub model: String, - /// Language code (None = auto-detect) - pub language: Option, - /// Reprocess files that already have subtitles - pub reprocess: bool, -} - -impl Default for SpeechToTextJobConfig { - fn default() -> Self { - Self { - location_id: None, - entry_uuid: None, - model: "base".to_string(), - language: None, - reprocess: false, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SpeechJobState { - phase: SpeechPhase, - entries: Vec<(i32, std::path::PathBuf, Option)>, - processed: usize, - success_count: usize, - error_count: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -enum SpeechPhase { - Discovery, - Processing, - Complete, -} - -#[derive(Serialize, Deserialize)] -pub struct SpeechToTextJob { - config: SpeechToTextJobConfig, - state: SpeechJobState, -} - -impl SpeechToTextJob { - pub fn new(config: SpeechToTextJobConfig) -> Self { - Self { - config, - state: SpeechJobState { - phase: SpeechPhase::Discovery, - entries: Vec::new(), - processed: 0, - success_count: 0, - error_count: 0, - }, - } - } - - pub fn from_location(location_id: Uuid) -> Self { - Self::new(SpeechToTextJobConfig { - location_id: Some(location_id), - ..Default::default() - }) - } -} - -impl Job for SpeechToTextJob { - const NAME: &'static str = "speech_to_text"; - const RESUMABLE: bool = true; - const DESCRIPTION: Option<&'static str> = Some("Transcribe audio/video to text subtitles"); -} - -#[async_trait::async_trait] -impl JobHandler for SpeechToTextJob { - type Output = SpeechToTextJobOutput; - - async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { - match self.state.phase { - SpeechPhase::Discovery => { - ctx.log("Starting speech-to-text discovery phase"); - self.run_discovery(&ctx).await?; - self.state.phase = SpeechPhase::Processing; - } - SpeechPhase::Processing => {} - SpeechPhase::Complete => { - return Ok(SpeechToTextJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - }); - } - } - - ctx.log(format!( - "Speech-to-text processing {} entries", - self.state.entries.len() - )); - - let processor = SpeechToTextProcessor::new(ctx.library_arc()) - .with_model(self.config.model.clone()) - .with_language(self.config.language.clone()); - - let total = self.state.entries.len(); - - while self.state.processed < total { - ctx.check_interrupt().await?; - - let (entry_id, path, mime_type) = &self.state.entries[self.state.processed]; - - // Load entry to get content_id - let entry_model = entry::Entity::find_by_id(*entry_id) - .one(ctx.library_db()) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - let proc_entry = ProcessorEntry { - id: *entry_id, - uuid: entry_model.uuid, - path: path.clone(), - kind: crate::ops::indexing::state::EntryKind::File, - size: entry_model.size as u64, - content_id: entry_model.content_id, - mime_type: mime_type.clone(), - }; - - if !processor.should_process(&proc_entry) { - self.state.processed += 1; - continue; - } - - // Get audio duration for progress estimation - let audio_duration = super::get_audio_duration_public(path).await.unwrap_or(1.0); - - // Report that we're starting to process this file - ctx.progress(Progress::Indeterminate(format!( - "Transcribing {}...", - path.file_name().and_then(|n| n.to_str()).unwrap_or("file") - ))); - - // Spawn progress simulation task - // Note: We can't easily share ctx across tasks, so we'll just use indeterminate progress - // The percentage will be updated after completion based on files processed - let progress_task = tokio::spawn(async move { - // Estimate transcription takes ~2x the audio duration - let transcription_duration = audio_duration * 2.0; - - // Just sleep for the estimated duration to keep the indeterminate progress active - tokio::time::sleep(std::time::Duration::from_secs_f32(transcription_duration)) - .await; - }); - - let result = processor.process(ctx.library_db(), &proc_entry).await; - - // Cancel progress simulation - progress_task.abort(); - - match result { - Ok(result) if result.success => { - ctx.log(format!( - "Transcribed {}: {} bytes", - path.display(), - result.bytes_processed - )); - self.state.success_count += 1; - } - Ok(_) => { - warn!("Transcription failed for {}", path.display()); - self.state.error_count += 1; - } - Err(e) => { - ctx.log(format!( - "ERROR: Transcription error for {}: {}", - path.display(), - e - )); - self.state.error_count += 1; - } - } - - self.state.processed += 1; - - // Report progress with count - ctx.progress(Progress::Count { - current: self.state.processed, - total, - }); - - if self.state.processed % 5 == 0 { - ctx.checkpoint().await?; - } - } - - self.state.phase = SpeechPhase::Complete; - ctx.log(format!( - "Speech-to-text complete: {} success, {} errors", - self.state.success_count, self.state.error_count - )); - - Ok(SpeechToTextJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - }) - } -} - -impl SpeechToTextJob { - async fn run_discovery(&mut self, ctx: &JobContext<'_>) -> JobResult<()> { - use crate::infra::db::entities::{content_identity, entry, mime_type}; - - ctx.log("Starting speech-to-text discovery"); - - // Ensure whisper model is downloaded FIRST - let data_dir = crate::config::default_data_dir() - .map_err(|e| JobError::execution(format!("Failed to get data dir: {}", e)))?; - - let model = crate::ops::models::WhisperModel::from_str(&self.config.model) - .ok_or_else(|| JobError::execution(format!("Invalid model: {}", self.config.model)))?; - - let _model_path = crate::ops::models::ensure_whisper_model(ctx, model, &data_dir).await?; - - ctx.log("Model ready, discovering files..."); - - let db = ctx.library_db(); - - // Check if this is single-file mode (from UI action) - if let Some(entry_uuid) = self.config.entry_uuid { - ctx.log(format!("Single file mode: processing entry {}", entry_uuid)); - - // Load the specific entry - let entry_model = entry::Entity::find() - .filter(entry::Column::Uuid.eq(entry_uuid)) - .one(db) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - if let Some(content_id) = entry_model.content_id { - if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = mime_type::Entity::find_by_id(mime_id).one(db).await - { - if super::is_speech_supported( - &mime.mime_type, - ctx.library().core_context().file_type_registry(), - ) { - if let Ok(path) = crate::ops::indexing::PathResolver::get_full_path( - db, - entry_model.id, - ) - .await - { - self.state.entries.push(( - entry_model.id, - path, - Some(mime.mime_type), - )); - } - } - } - } - } - } - - ctx.log(format!( - "Single file discovered: {} entries", - self.state.entries.len() - )); - return Ok(()); - } - - // Batch mode - discover all eligible entries - let entries = entry::Entity::find() - .filter(entry::Column::ContentId.is_not_null()) - .all(db) - .await?; - - ctx.log(format!("Found {} entries with content", entries.len())); - - for entry_model in entries { - if let Some(content_id) = entry_model.content_id { - if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = mime_type::Entity::find_by_id(mime_id).one(db).await - { - if super::is_speech_supported( - &mime.mime_type, - ctx.library().core_context().file_type_registry(), - ) { - if let Ok(path) = crate::ops::indexing::PathResolver::get_full_path( - db, - entry_model.id, - ) - .await - { - self.state.entries.push(( - entry_model.id, - path, - Some(mime.mime_type), - )); - } - } - } - } - } - } - } - - ctx.log(format!( - "Discovery complete: {} audio/video files", - self.state.entries.len() - )); - - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct SpeechToTextJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl From for JobOutput { - fn from(output: SpeechToTextJobOutput) -> Self { - JobOutput::SpeechToText { - total_processed: output.total_processed, - success_count: output.success_count, - error_count: output.error_count, - } - } -} - -impl DynJob for SpeechToTextJob { - fn job_name(&self) -> &'static str { - "Speech Transcription" - } -} - -impl From for Box { - fn from(job: SpeechToTextJob) -> Self { - Box::new(job) - } -} diff --git a/core/src/ops/media/speech/mod.rs b/core/src/ops/media/speech/mod.rs deleted file mode 100644 index 8a6367203..000000000 --- a/core/src/ops/media/speech/mod.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Speech-to-Text system -//! -//! Transcribes audio/video to text using whisper.rs. -//! Generates .srt subtitle files as sidecars. -//! -//! Requires the `speech-to-text` feature (enables `ffmpeg` + `whisper`). - -pub mod action; - -#[cfg(feature = "speech-to-text")] -pub mod job; -#[cfg(feature = "speech-to-text")] -pub mod processor; - -pub use action::{TranscribeAudioAction, TranscribeAudioInput, TranscribeAudioOutput}; - -#[cfg(feature = "speech-to-text")] -pub use job::{SpeechToTextJob, SpeechToTextJobConfig}; -#[cfg(feature = "speech-to-text")] -pub use processor::SpeechToTextProcessor; - -use anyhow::{Context, Result}; -use std::path::Path; -use std::sync::Arc; - -/// Transcribe audio/video to text using whisper -#[cfg(feature = "speech-to-text")] -pub async fn transcribe_audio_file( - source_path: &Path, - model: &str, - language: Option<&str>, - data_dir: &Path, -) -> Result { - use tokio::task::spawn_blocking; - - let source = source_path.to_path_buf(); - let model_name = model.to_string(); - let lang = language.map(|s| s.to_string()); - - // Get model path from data directory - let model_path = crate::ops::models::get_whisper_models_dir(data_dir) - .join(format!("ggml-{}.bin", model_name)); - - if !model_path.exists() { - anyhow::bail!( - "Whisper model not found: {}. Please download it first.", - model_path.display() - ); - } - - // Run whisper in blocking task (CPU/GPU intensive) - spawn_blocking(move || { - use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; - - // Load model - let ctx = WhisperContext::new_with_params( - model_path.to_str().context("Invalid model path")?, - WhisperContextParameters::default(), - ) - .context("Failed to load Whisper model")?; - - // Load and convert audio to 16kHz mono f32 samples - let audio_data = load_audio_samples(&source)?; - - // Set up transcription params - let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); - - if let Some(ref l) = lang { - params.set_language(Some(l)); - } - params.set_print_progress(false); - params.set_print_special(false); - params.set_print_realtime(false); - params.set_print_timestamps(true); - - // Run transcription - let mut state = ctx - .create_state() - .context("Failed to create whisper state")?; - state - .full(params, &audio_data) - .context("Transcription failed")?; - - // Extract text as SRT format using iterator - let mut srt = String::new(); - - for (index, segment) in state.as_iter().enumerate() { - let start_ts = segment.start_timestamp() as f64 / 100.0; // centiseconds to seconds - let end_ts = segment.end_timestamp() as f64 / 100.0; - let text = segment.to_str().context("Failed to get segment text")?; - - srt.push_str(&format_srt_segment(index + 1, start_ts, end_ts, text)); - } - - Ok(srt) - }) - .await? -} - -/// Load audio file and convert to 16kHz mono f32 samples required by Whisper -/// Uses FFmpeg libraries directly (no subprocess) -#[cfg(feature = "speech-to-text")] -fn load_audio_samples(path: &Path) -> Result> { - // Use sd-ffmpeg to extract audio samples directly - // This returns 16kHz mono f32 PCM samples, exactly what Whisper needs - Ok(sd_ffmpeg::extract_audio_samples(path)?) -} - -/// Format a single SRT subtitle segment -#[cfg(feature = "speech-to-text")] -fn format_srt_segment(index: usize, start: f64, end: f64, text: &str) -> String { - let start_time = format_srt_timestamp(start); - let end_time = format_srt_timestamp(end); - - format!( - "{}\n{} --> {}\n{}\n\n", - index, - start_time, - end_time, - text.trim() - ) -} - -/// Format timestamp in SRT format (HH:MM:SS,mmm) -#[cfg(feature = "speech-to-text")] -fn format_srt_timestamp(seconds: f64) -> String { - let hours = (seconds / 3600.0).floor() as u32; - let minutes = ((seconds % 3600.0) / 60.0).floor() as u32; - let secs = (seconds % 60.0).floor() as u32; - let millis = ((seconds % 1.0) * 1000.0).floor() as u32; - - format!("{:02}:{:02}:{:02},{:03}", hours, minutes, secs, millis) -} - -/// Check if a file type supports speech-to-text based on content kind -pub fn is_speech_supported(mime_type: &str, registry: &crate::filetype::FileTypeRegistry) -> bool { - use crate::domain::ContentKind; - - if let Some(file_type) = registry.get_by_mime(mime_type) { - // Speech-to-text supported for audio and video - matches!(file_type.category, ContentKind::Audio | ContentKind::Video) - } else { - // Fallback to direct MIME check - mime_type.starts_with("audio/") || mime_type.starts_with("video/") - } -} - -/// Get audio duration in seconds using ffprobe (public for job progress estimation) -#[cfg(feature = "speech-to-text")] -pub async fn get_audio_duration_public(path: &Path) -> Result { - use std::process::Command; - - let output = tokio::task::spawn_blocking({ - let path = path.to_path_buf(); - move || { - Command::new("ffprobe") - .args([ - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - path.to_str().context("Invalid path")?, - ]) - .output() - .context("Failed to run ffprobe") - } - }) - .await??; - - if !output.status.success() { - anyhow::bail!("ffprobe failed"); - } - - let duration_str = String::from_utf8_lossy(&output.stdout); - let duration: f32 = duration_str.trim().parse().context("Invalid duration")?; - - Ok(duration) -} diff --git a/core/src/ops/media/speech/processor.rs b/core/src/ops/media/speech/processor.rs deleted file mode 100644 index f3ecb30da..000000000 --- a/core/src/ops/media/speech/processor.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Speech-to-text processor - generates subtitle files from audio/video - -use crate::library::Library; -use crate::ops::indexing::processor::{ProcessorEntry, ProcessorResult}; -use crate::ops::indexing::state::EntryKind; -use crate::ops::sidecar::types::{SidecarFormat, SidecarKind, SidecarVariant}; -use anyhow::Result; -use serde_json::Value; -use std::sync::Arc; -use tracing::{debug, warn}; - -pub struct SpeechToTextProcessor { - library: Arc, - model: String, - language: Option, -} - -impl SpeechToTextProcessor { - pub fn new(library: Arc) -> Self { - Self { - library, - model: "base".to_string(), // whisper base model - language: None, // Auto-detect - } - } - - pub fn with_model(mut self, model: String) -> Self { - self.model = model; - self - } - - pub fn with_language(mut self, language: Option) -> Self { - self.language = language; - self - } - - pub fn with_settings(mut self, settings: &Value) -> Result { - if let Some(model) = settings.get("model").and_then(|v| v.as_str()) { - self.model = model.to_string(); - } - - if let Some(lang) = settings.get("language").and_then(|v| v.as_str()) { - self.language = Some(lang.to_string()); - } - - Ok(self) - } - - pub fn should_process(&self, entry: &ProcessorEntry) -> bool { - if !matches!(entry.kind, EntryKind::File) { - return false; - } - - if entry.content_id.is_none() { - return false; - } - - entry.mime_type.as_ref().map_or(false, |m| { - super::is_speech_supported(m, self.library.core_context().file_type_registry()) - }) - } - - pub async fn process( - &self, - db: &sea_orm::DatabaseConnection, - entry: &ProcessorEntry, - ) -> Result { - let content_uuid = if let Some(content_id) = entry.content_id { - use crate::infra::db::entities::content_identity; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let ci = content_identity::Entity::find() - .filter(content_identity::Column::Id.eq(content_id)) - .one(db) - .await? - .ok_or_else(|| anyhow::anyhow!("ContentIdentity not found"))?; - - ci.uuid - .ok_or_else(|| anyhow::anyhow!("ContentIdentity missing UUID"))? - } else { - return Ok(ProcessorResult::failure( - "Entry has no content_id".to_string(), - )); - }; - - debug!("→ Transcribing audio for: {}", entry.path.display()); - - // Get sidecar manager - let sidecar_manager = self - .library - .core_context() - .get_sidecar_manager() - .await - .ok_or_else(|| anyhow::anyhow!("SidecarManager not available"))?; - - // Check if transcript already exists - if sidecar_manager - .exists( - &self.library.id(), - &content_uuid, - &SidecarKind::Transcript, - &SidecarVariant::new("srt"), - &SidecarFormat::Text, - ) - .await - .unwrap_or(false) - { - debug!("Transcript already exists for {}", content_uuid); - return Ok(ProcessorResult::success(0, 0)); - } - - // Compute sidecar path - let sidecar_path = sidecar_manager - .compute_path( - &self.library.id(), - &content_uuid, - &SidecarKind::Transcript, - &SidecarVariant::new("srt"), - &SidecarFormat::Text, - ) - .await - .map_err(|e| anyhow::anyhow!("Failed to compute path: {}", e))?; - - // Transcribe audio - let data_dir = crate::config::default_data_dir() - .map_err(|e| anyhow::anyhow!("Failed to get data dir: {}", e))?; - - let srt_content = super::transcribe_audio_file( - &entry.path, - &self.model, - self.language.as_deref(), - &data_dir, - ) - .await?; - - debug!("Transcription complete: {} bytes", srt_content.len()); - - // Ensure sidecar directory exists - if let Some(parent) = sidecar_path.absolute_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Write SRT file - tokio::fs::write(&sidecar_path.absolute_path, &srt_content).await?; - - debug!( - "✓ Generated subtitle file: {} ({} bytes)", - sidecar_path.relative_path.display(), - srt_content.len() - ); - - // Register sidecar in database - sidecar_manager - .record_sidecar( - &self.library, - &content_uuid, - &SidecarKind::Transcript, - &SidecarVariant::new("srt"), - &SidecarFormat::Text, - srt_content.len() as u64, - None, - ) - .await - .map_err(|e| anyhow::anyhow!("Failed to record sidecar: {}", e))?; - - Ok(ProcessorResult::success(1, srt_content.len() as u64)) - } - - pub fn name(&self) -> &'static str { - "speech_to_text" - } -} diff --git a/core/src/ops/media/splat/action.rs b/core/src/ops/media/splat/action.rs deleted file mode 100644 index d13672eac..000000000 --- a/core/src/ops/media/splat/action.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Gaussian splat action handlers - -use super::{ - job::{GaussianSplatJob, GaussianSplatJobConfig}, - processor::GaussianSplatProcessor, -}; -use crate::{ - context::CoreContext, - infra::action::{error::ActionError, LibraryAction}, - ops::indexing::{path_resolver::PathResolver, processor::ProcessorEntry}, -}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct GenerateSplatInput { - pub entry_uuid: Uuid, - pub model_path: Option, // Path to SHARP model checkpoint or None for auto-download -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct GenerateSplatOutput { - /// Job ID for tracking splat generation progress - pub job_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GenerateSplatAction { - input: GenerateSplatInput, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct GaussianSplatJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl GenerateSplatAction { - pub fn new(input: GenerateSplatInput) -> Self { - Self { input } - } -} - -impl LibraryAction for GenerateSplatAction { - type Input = GenerateSplatInput; - type Output = GenerateSplatOutput; - - fn from_input(input: GenerateSplatInput) -> Result { - Ok(Self::new(input)) - } - - async fn execute( - self, - library: Arc, - _context: Arc, - ) -> Result { - tracing::info!( - "Dispatching Gaussian splat job for entry: {}", - self.input.entry_uuid - ); - - // Create job config for single file - let job_config = super::job::GaussianSplatJobConfig { - location_id: None, - entry_uuid: Some(self.input.entry_uuid), // Single file mode - model_path: self.input.model_path, - reprocess: false, - }; - - // Create job - let job = super::job::GaussianSplatJob::new(job_config); - - tracing::info!("Gaussian splat job created: {:?}", self.input.entry_uuid); - - // Dispatch job - let job_handle = library - .jobs() - .dispatch(job) - .await - .map_err(|e| ActionError::Internal(format!("Failed to dispatch job: {}", e)))?; - - tracing::info!("Gaussian splat job dispatched: {}", job_handle.id()); - - Ok(GenerateSplatOutput { - job_id: job_handle.id().to_string(), - }) - } - - fn action_kind(&self) -> &'static str { - "media.splat.generate" - } -} - -crate::register_library_action!(GenerateSplatAction, "media.splat.generate"); diff --git a/core/src/ops/media/splat/job.rs b/core/src/ops/media/splat/job.rs deleted file mode 100644 index 897a729d3..000000000 --- a/core/src/ops/media/splat/job.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! Gaussian splat generation job for batch image processing - -use super::processor::GaussianSplatProcessor; -use crate::{ - infra::{ - db::entities::entry, - job::{prelude::*, traits::DynJob}, - }, - ops::indexing::processor::ProcessorEntry, -}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use tracing::{info, warn}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct GaussianSplatJobConfig { - /// Location ID to process (None = all entries in library) - pub location_id: Option, - /// Single entry UUID to process (for UI-triggered single file) - pub entry_uuid: Option, - /// Path to SHARP model checkpoint (None = auto-download) - pub model_path: Option, - /// Reprocess files that already have splats - pub reprocess: bool, -} - -impl Default for GaussianSplatJobConfig { - fn default() -> Self { - Self { - location_id: None, - entry_uuid: None, - model_path: None, - reprocess: false, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SplatJobState { - phase: SplatPhase, - entries: Vec<(i32, std::path::PathBuf, Option)>, - processed: usize, - success_count: usize, - error_count: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -enum SplatPhase { - Discovery, - Processing, - Complete, -} - -#[derive(Serialize, Deserialize)] -pub struct GaussianSplatJob { - config: GaussianSplatJobConfig, - state: SplatJobState, -} - -impl GaussianSplatJob { - pub fn new(config: GaussianSplatJobConfig) -> Self { - Self { - config, - state: SplatJobState { - phase: SplatPhase::Discovery, - entries: Vec::new(), - processed: 0, - success_count: 0, - error_count: 0, - }, - } - } - - pub fn from_location(location_id: Uuid) -> Self { - Self::new(GaussianSplatJobConfig { - location_id: Some(location_id), - ..Default::default() - }) - } -} - -impl Job for GaussianSplatJob { - const NAME: &'static str = "gaussian_splat"; - const RESUMABLE: bool = true; - const DESCRIPTION: Option<&'static str> = - Some("Generate 3D Gaussian splats from images for view synthesis"); -} - -#[async_trait::async_trait] -impl JobHandler for GaussianSplatJob { - type Output = GaussianSplatJobOutput; - - async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { - match self.state.phase { - SplatPhase::Discovery => { - ctx.log("Starting Gaussian splat discovery phase"); - self.run_discovery(&ctx).await?; - self.state.phase = SplatPhase::Processing; - } - SplatPhase::Processing => {} - SplatPhase::Complete => { - return Ok(GaussianSplatJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - }); - } - } - - ctx.log(format!( - "Gaussian splat processing {} images", - self.state.entries.len() - )); - - let processor = GaussianSplatProcessor::new(ctx.library_arc()); - let processor = if let Some(ref model_path) = self.config.model_path { - processor.with_model_path(model_path.clone()) - } else { - processor - }; - - let total = self.state.entries.len(); - - while self.state.processed < total { - ctx.check_interrupt().await?; - - let (entry_id, path, mime_type) = &self.state.entries[self.state.processed]; - - // Load entry to get content_id - let entry_model = entry::Entity::find_by_id(*entry_id) - .one(ctx.library_db()) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - let proc_entry = ProcessorEntry { - id: *entry_id, - uuid: entry_model.uuid, - path: path.clone(), - kind: crate::ops::indexing::state::EntryKind::File, - size: entry_model.size as u64, - content_id: entry_model.content_id, - mime_type: mime_type.clone(), - }; - - if !processor.should_process(&proc_entry) { - self.state.processed += 1; - continue; - } - - // Report progress - ctx.progress(Progress::Indeterminate(format!( - "Generating splat for {}...", - path.file_name().and_then(|n| n.to_str()).unwrap_or("file") - ))); - - let result = processor.process(ctx.library_db(), &proc_entry).await; - - match result { - Ok(result) if result.success => { - ctx.log(format!( - "Generated splat for {}: {} bytes", - path.display(), - result.bytes_processed - )); - self.state.success_count += 1; - } - Ok(_) => { - warn!("Splat generation failed for {}", path.display()); - self.state.error_count += 1; - } - Err(e) => { - ctx.log(format!( - "ERROR: Splat generation error for {}: {}", - path.display(), - e - )); - self.state.error_count += 1; - } - } - - self.state.processed += 1; - - // Report progress with count - ctx.progress(Progress::Count { - current: self.state.processed, - total, - }); - - if self.state.processed % 5 == 0 { - ctx.checkpoint().await?; - } - } - - self.state.phase = SplatPhase::Complete; - ctx.log(format!( - "Gaussian splat generation complete: {} success, {} errors", - self.state.success_count, self.state.error_count - )); - - Ok(GaussianSplatJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - }) - } -} - -impl GaussianSplatJob { - async fn run_discovery(&mut self, ctx: &JobContext<'_>) -> JobResult<()> { - use crate::infra::db::entities::{content_identity, entry, mime_type}; - - ctx.log("Starting Gaussian splat discovery"); - - // Check if SHARP CLI is available - if !super::check_sharp_available().await.unwrap_or(false) { - return Err(JobError::execution( - "SHARP CLI not found. Please install ml-sharp (pip install -e /path/to/ml-sharp)", - )); - } - - ctx.log("SHARP CLI available"); - - let db = ctx.library_db(); - - // Check if this is single-file mode (from UI action) - if let Some(entry_uuid) = self.config.entry_uuid { - ctx.log(format!("Single file mode: processing entry {}", entry_uuid)); - - // Load the specific entry - let entry_model = entry::Entity::find() - .filter(entry::Column::Uuid.eq(entry_uuid)) - .one(db) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - if let Some(content_id) = entry_model.content_id { - if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = mime_type::Entity::find_by_id(mime_id).one(db).await - { - if super::is_splat_supported(&mime.mime_type) { - if let Ok(path) = crate::ops::indexing::PathResolver::get_full_path( - db, - entry_model.id, - ) - .await - { - self.state.entries.push(( - entry_model.id, - path, - Some(mime.mime_type), - )); - } - } - } - } - } - } - - ctx.log(format!( - "Single file discovered: {} entries", - self.state.entries.len() - )); - return Ok(()); - } - - // Batch mode - discover all eligible entries - let entries = entry::Entity::find() - .filter(entry::Column::ContentId.is_not_null()) - .all(db) - .await?; - - ctx.log(format!("Found {} entries with content", entries.len())); - - for entry_model in entries { - if let Some(content_id) = entry_model.content_id { - if let Ok(Some(ci)) = content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = mime_type::Entity::find_by_id(mime_id).one(db).await - { - if super::is_splat_supported(&mime.mime_type) { - if let Ok(path) = crate::ops::indexing::PathResolver::get_full_path( - db, - entry_model.id, - ) - .await - { - self.state.entries.push(( - entry_model.id, - path, - Some(mime.mime_type), - )); - } - } - } - } - } - } - } - - ctx.log(format!( - "Discovery complete: {} image files", - self.state.entries.len() - )); - - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct GaussianSplatJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl From for JobOutput { - fn from(output: GaussianSplatJobOutput) -> Self { - JobOutput::GaussianSplat { - total_processed: output.total_processed, - success_count: output.success_count, - error_count: output.error_count, - } - } -} - -impl DynJob for GaussianSplatJob { - fn job_name(&self) -> &'static str { - "Gaussian Splat Generation" - } -} - -impl From for Box { - fn from(job: GaussianSplatJob) -> Self { - Box::new(job) - } -} diff --git a/core/src/ops/media/splat/mod.rs b/core/src/ops/media/splat/mod.rs deleted file mode 100644 index 9ffa8b1db..000000000 --- a/core/src/ops/media/splat/mod.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Gaussian Splat generation system -//! -//! Generates 3D Gaussian splats from images using Apple's SHARP model. -//! Generates .ply sidecar files for photorealistic view synthesis. - -pub mod action; -pub mod job; -pub mod processor; - -pub use action::{GenerateSplatAction, GenerateSplatInput, GenerateSplatOutput}; -pub use job::{GaussianSplatJob, GaussianSplatJobConfig}; -pub use processor::GaussianSplatProcessor; - -use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; - -/// Generate a 3D Gaussian splat from an image using SHARP -/// -/// This calls the `sharp` CLI tool as a subprocess. -/// The sharp tool must be installed (e.g., via `pip install -r requirements.txt` in ml-sharp repo) -/// -/// # Arguments -/// * `source_path` - Path to the input image -/// * `output_dir` - Directory where the .ply file will be generated -/// * `model_path` - Optional path to the SHARP model checkpoint -/// -/// # Returns -/// Path to the generated .ply file -pub async fn generate_splat_from_image( - source_path: &Path, - output_dir: &Path, - model_path: Option<&Path>, -) -> Result { - use tokio::process::Command; - - // Ensure output directory exists - tokio::fs::create_dir_all(output_dir).await?; - - // Build command - let mut cmd = Command::new("sharp"); - cmd.arg("predict") - .arg("-i") - .arg(source_path) - .arg("-o") - .arg(output_dir); - - // Add model path if provided - if let Some(model) = model_path { - cmd.arg("-c").arg(model); - } - - // Execute - let output = cmd - .output() - .await - .context("Failed to execute 'sharp' command. Is it installed?")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("SHARP failed: {}", stderr); - } - - // The output file will be named based on input filename with .ply extension - let ply_filename = source_path - .file_stem() - .context("Invalid source filename")? - .to_str() - .context("Non-UTF8 filename")?; - - let ply_path = output_dir.join(format!("{}.ply", ply_filename)); - - if !ply_path.exists() { - anyhow::bail!( - "SHARP did not generate expected output file: {:?}", - ply_path - ); - } - - Ok(ply_path) -} - -/// Check if SHARP CLI is available in PATH -pub async fn check_sharp_available() -> Result { - let output = tokio::process::Command::new("sharp") - .arg("--help") - .output() - .await; - - Ok(output.is_ok()) -} - -/// Check if an image type is supported for splat generation -pub fn is_splat_supported(mime_type: &str) -> bool { - // SHARP supports common image formats - matches!( - mime_type, - "image/jpeg" | "image/png" | "image/webp" | "image/bmp" | "image/tiff" - ) -} diff --git a/core/src/ops/media/splat/processor.rs b/core/src/ops/media/splat/processor.rs deleted file mode 100644 index 0ea15ebe7..000000000 --- a/core/src/ops/media/splat/processor.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Gaussian splat processor - generates 3D splats from images - -use crate::library::Library; -use crate::ops::indexing::processor::{ProcessorEntry, ProcessorResult}; -use crate::ops::indexing::state::EntryKind; -use crate::ops::sidecar::types::{SidecarFormat, SidecarKind, SidecarVariant}; -use anyhow::Result; -use serde_json::Value; -use std::sync::Arc; -use tracing::{debug, warn}; - -pub struct GaussianSplatProcessor { - library: Arc, - model_path: Option, -} - -impl GaussianSplatProcessor { - pub fn new(library: Arc) -> Self { - Self { - library, - model_path: None, - } - } - - pub fn with_model_path(mut self, path: String) -> Self { - self.model_path = Some(path); - self - } - - pub fn with_settings(mut self, settings: &Value) -> Result { - if let Some(path) = settings.get("model_path").and_then(|v| v.as_str()) { - self.model_path = Some(path.to_string()); - } - - Ok(self) - } - - pub fn should_process(&self, entry: &ProcessorEntry) -> bool { - if !matches!(entry.kind, EntryKind::File) { - return false; - } - - if entry.content_id.is_none() { - return false; - } - - entry - .mime_type - .as_ref() - .map_or(false, |m| super::is_splat_supported(m)) - } - - pub async fn process( - &self, - db: &sea_orm::DatabaseConnection, - entry: &ProcessorEntry, - ) -> Result { - let content_uuid = if let Some(content_id) = entry.content_id { - use crate::infra::db::entities::content_identity; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let ci = content_identity::Entity::find() - .filter(content_identity::Column::Id.eq(content_id)) - .one(db) - .await? - .ok_or_else(|| anyhow::anyhow!("ContentIdentity not found"))?; - - ci.uuid - .ok_or_else(|| anyhow::anyhow!("ContentIdentity missing UUID"))? - } else { - return Ok(ProcessorResult::failure( - "Entry has no content_id".to_string(), - )); - }; - - debug!("→ Generating Gaussian splat for: {}", entry.path.display()); - - // Get sidecar manager - let sidecar_manager = self - .library - .core_context() - .get_sidecar_manager() - .await - .ok_or_else(|| anyhow::anyhow!("SidecarManager not available"))?; - - // Check if splat already exists - if sidecar_manager - .exists( - &self.library.id(), - &content_uuid, - &SidecarKind::GaussianSplat, - &SidecarVariant::new("ply"), - &SidecarFormat::Ply, - ) - .await - .unwrap_or(false) - { - debug!("Gaussian splat already exists for {}", content_uuid); - return Ok(ProcessorResult::success(0, 0)); - } - - // Compute sidecar path - let sidecar_path = sidecar_manager - .compute_path( - &self.library.id(), - &content_uuid, - &SidecarKind::GaussianSplat, - &SidecarVariant::new("ply"), - &SidecarFormat::Ply, - ) - .await - .map_err(|e| anyhow::anyhow!("Failed to compute path: {}", e))?; - - // Create temporary output directory - let temp_dir = std::env::temp_dir().join(format!("sd_splat_{}", content_uuid)); - tokio::fs::create_dir_all(&temp_dir).await?; - - // Generate splat using SHARP - let model_path_ref = self.model_path.as_ref().map(|s| std::path::Path::new(s)); - let ply_path = super::generate_splat_from_image(&entry.path, &temp_dir, model_path_ref) - .await - .map_err(|e| anyhow::anyhow!("Failed to generate Gaussian splat: {}", e))?; - - // Read generated PLY file - let ply_data = tokio::fs::read(&ply_path).await?; - let ply_size = ply_data.len(); - - debug!("Generated splat: {} bytes", ply_size); - - // Ensure sidecar directory exists - if let Some(parent) = sidecar_path.absolute_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Copy PLY to sidecar location - tokio::fs::copy(&ply_path, &sidecar_path.absolute_path).await?; - - // Clean up temp directory - let _ = tokio::fs::remove_dir_all(&temp_dir).await; - - debug!( - "✓ Generated Gaussian splat: {} ({} bytes)", - sidecar_path.relative_path.display(), - ply_size - ); - - // Register sidecar in database - sidecar_manager - .record_sidecar( - &self.library, - &content_uuid, - &SidecarKind::GaussianSplat, - &SidecarVariant::new("ply"), - &SidecarFormat::Ply, - ply_size as u64, - None, - ) - .await - .map_err(|e| anyhow::anyhow!("Failed to record sidecar: {}", e))?; - - Ok(ProcessorResult::success(1, ply_size as u64)) - } - - pub fn name(&self) -> &'static str { - "gaussian_splat" - } -} diff --git a/core/src/ops/media/thumbnail/action.rs b/core/src/ops/media/thumbnail/action.rs deleted file mode 100644 index 25a7e21f3..000000000 --- a/core/src/ops/media/thumbnail/action.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Thumbnail generation action handlers - -use super::{ - job::{ThumbnailJob, ThumbnailJobConfig}, - processor::ThumbnailProcessor, -}; -use crate::{ - context::CoreContext, - infra::action::{error::ActionError, LibraryAction}, - ops::indexing::{path_resolver::PathResolver, processor::ProcessorEntry}, -}; -use specta::Type; -use std::path::PathBuf; -use std::sync::Arc; -use uuid::Uuid; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)] -pub struct ThumbnailInput { - pub paths: Vec, - pub size: u32, - pub quality: u8, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ThumbnailAction { - input: ThumbnailInput, -} - -impl ThumbnailAction { - pub fn new(input: ThumbnailInput) -> Self { - Self { input } - } -} - -impl LibraryAction for ThumbnailAction { - type Input = ThumbnailInput; - type Output = crate::infra::job::handle::JobReceipt; - - fn from_input(input: ThumbnailInput) -> Result { - Ok(ThumbnailAction::new(input)) - } - - async fn execute( - self, - library: std::sync::Arc, - _context: Arc, - ) -> Result { - let variants = super::ThumbnailVariants::from_size(self.input.size) - .map(|v| vec![v]) - .unwrap_or_else(super::ThumbnailVariants::defaults); - let config = ThumbnailJobConfig::with_variants(variants); - let job = ThumbnailJob::new(config); - let job_handle = library - .jobs() - .dispatch(job) - .await - .map_err(ActionError::Job)?; - Ok(job_handle.into()) - } - - fn action_kind(&self) -> &'static str { - "media.thumbnail" - } -} - -crate::register_library_action!(ThumbnailAction, "media.thumbnail"); - -// ============================================================================ -// Regenerate Thumbnail Action (for single file UI triggering) -// ============================================================================ - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)] -pub struct RegenerateThumbnailInput { - /// UUID of the entry to regenerate thumbnails for - pub entry_uuid: Uuid, - /// Optional variant names (defaults to grid@1x, grid@2x, detail@1x) - pub variants: Option>, - /// Force regeneration even if thumbnails exist - pub force: bool, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)] -pub struct RegenerateThumbnailOutput { - pub generated_count: usize, - pub variants: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct RegenerateThumbnailAction { - input: RegenerateThumbnailInput, -} - -impl RegenerateThumbnailAction { - pub fn new(input: RegenerateThumbnailInput) -> Self { - Self { input } - } -} - -impl LibraryAction for RegenerateThumbnailAction { - type Input = RegenerateThumbnailInput; - type Output = RegenerateThumbnailOutput; - - fn from_input(input: RegenerateThumbnailInput) -> Result { - Ok(Self::new(input)) - } - - async fn execute( - self, - library: Arc, - _context: Arc, - ) -> Result { - use crate::infra::db::entities; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let db = library.db().conn(); - - // Load entry by UUID - let entry = entities::entry::Entity::find() - .filter(entities::entry::Column::Uuid.eq(self.input.entry_uuid)) - .one(db) - .await - .map_err(|e| ActionError::Internal(format!("Failed to load entry: {}", e)))? - .ok_or_else(|| ActionError::Internal("Entry not found".to_string()))?; - - // Get full path - let path = PathResolver::get_full_path(db, entry.id) - .await - .map_err(|e| ActionError::Internal(format!("Failed to resolve path: {}", e)))?; - - // Get MIME type: try content_identity first, fall back to extension - let mime_type = if let Some(content_id) = entry.content_id { - match entities::content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - Ok(Some(ci)) => { - if let Some(mime_id) = ci.mime_type_id { - match entities::mime_type::Entity::find_by_id(mime_id) - .one(db) - .await - { - Ok(Some(m)) => Some(m.mime_type), - Ok(None) => mime_from_extension(&path), - Err(e) => { - return Err(ActionError::Internal(format!( - "Failed to load MIME type {mime_id}: {e}" - ))) - } - } - } else { - mime_from_extension(&path) - } - } - Ok(None) => mime_from_extension(&path), - Err(e) => { - return Err(ActionError::Internal(format!( - "Failed to load content identity {content_id}: {e}" - ))) - } - } - } else { - mime_from_extension(&path) - }; - - // Build processor entry - let kind = match entry.kind { - 0 => crate::ops::indexing::state::EntryKind::File, - 1 => crate::ops::indexing::state::EntryKind::Directory, - 2 => crate::ops::indexing::state::EntryKind::Symlink, - _ => crate::ops::indexing::state::EntryKind::File, - }; - - let proc_entry = ProcessorEntry { - id: entry.id, - uuid: entry.uuid, - path: path.clone(), - kind, - size: entry.size as u64, - content_id: entry.content_id, - mime_type: mime_type.clone(), - }; - - // Create thumbnail processor - let mut processor = - ThumbnailProcessor::new(library.clone()).with_regenerate(self.input.force); - - if let Some(variant_names) = &self.input.variants { - let settings = serde_json::json!({ "variants": variant_names }); - processor = processor - .with_settings(&settings) - .map_err(|e| ActionError::Internal(format!("Invalid settings: {}", e)))?; - } - - if !processor.should_process(&proc_entry) { - return Err(ActionError::Internal( - "File type does not support thumbnails".to_string(), - )); - } - - let result = processor - .process(db, &proc_entry) - .await - .map_err(|e| ActionError::Internal(format!("Thumbnail generation failed: {}", e)))?; - - if !result.success { - return Err(ActionError::Internal( - result.error.unwrap_or_else(|| "Unknown error".to_string()), - )); - } - - let variant_names: Vec = processor - .variants - .iter() - .map(|v| v.variant.as_str().to_string()) - .collect(); - - Ok(RegenerateThumbnailOutput { - generated_count: result.artifacts_created, - variants: variant_names, - }) - } - - fn action_kind(&self) -> &'static str { - "media.thumbnail.regenerate" - } -} - -crate::register_library_action!(RegenerateThumbnailAction, "media.thumbnail.regenerate"); - -/// Infer MIME type from file extension -fn mime_from_extension(path: &std::path::Path) -> Option { - path.extension() - .and_then(|ext| ext.to_str()) - .and_then(|ext| match ext.to_lowercase().as_str() { - "jpg" | "jpeg" => Some("image/jpeg"), - "png" => Some("image/png"), - "gif" => Some("image/gif"), - "webp" => Some("image/webp"), - "bmp" => Some("image/bmp"), - "svg" => Some("image/svg+xml"), - "tiff" | "tif" => Some("image/tiff"), - "avif" => Some("image/avif"), - "heic" | "heif" => Some("image/heif"), - "mp4" => Some("video/mp4"), - "mkv" => Some("video/x-matroska"), - "avi" => Some("video/x-msvideo"), - "mov" => Some("video/quicktime"), - "webm" => Some("video/webm"), - "pdf" => Some("application/pdf"), - _ => None, - }) - .map(|s| s.to_string()) -} diff --git a/core/src/ops/media/thumbnail/config.rs b/core/src/ops/media/thumbnail/config.rs deleted file mode 100644 index 59073aa72..000000000 --- a/core/src/ops/media/thumbnail/config.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Thumbnail variant configuration -//! -//! Defines the standard thumbnail sizes and their variant names for the sidecar system. - -use crate::ops::sidecar::types::{SidecarFormat, SidecarVariant}; - -/// Standard thumbnail configuration -#[derive(Debug, Clone)] -pub struct ThumbnailVariantConfig { - pub size: u32, - pub variant: SidecarVariant, - pub quality: u8, -} - -impl ThumbnailVariantConfig { - pub fn new(size: u32, variant: impl Into, quality: u8) -> Self { - Self { - size, - variant: SidecarVariant::new(variant), - quality, - } - } - - /// Get the format for thumbnails (always WebP) - pub fn format(&self) -> SidecarFormat { - SidecarFormat::Webp - } -} - -/// Standard thumbnail variants used across Spacedrive -pub struct ThumbnailVariants; - -impl ThumbnailVariants { - /// Grid view thumbnail - 1x resolution (256px) - pub fn grid_1x() -> ThumbnailVariantConfig { - ThumbnailVariantConfig::new(256, "grid@1x", 85) - } - - /// Grid view thumbnail - 2x resolution (512px) for retina displays - pub fn grid_2x() -> ThumbnailVariantConfig { - ThumbnailVariantConfig::new(512, "grid@2x", 85) - } - - /// Detail view thumbnail - 1x resolution (1024px) - pub fn detail_1x() -> ThumbnailVariantConfig { - ThumbnailVariantConfig::new(1024, "detail@1x", 90) - } - - /// Detail view thumbnail - 2x resolution (2048px) for retina displays - pub fn detail_2x() -> ThumbnailVariantConfig { - ThumbnailVariantConfig::new(2048, "detail@2x", 90) - } - - /// Small icon thumbnail - 1x resolution (128px) - pub fn icon_1x() -> ThumbnailVariantConfig { - ThumbnailVariantConfig::new(128, "icon@1x", 80) - } - - /// Small icon thumbnail - 2x resolution (256px) for retina displays - pub fn icon_2x() -> ThumbnailVariantConfig { - ThumbnailVariantConfig::new(256, "icon@2x", 80) - } - - /// Get all standard variants - pub fn all() -> Vec { - vec![ - Self::icon_1x(), - Self::icon_2x(), - Self::grid_1x(), - Self::grid_2x(), - Self::detail_1x(), - Self::detail_2x(), - ] - } - - /// Get default variants (commonly used subset) - pub fn defaults() -> Vec { - vec![Self::grid_1x(), Self::grid_2x(), Self::detail_1x()] - } - - /// Map a size to the closest standard variant - pub fn from_size(size: u32) -> Option { - match size { - 128 => Some(Self::icon_1x()), - 256 => Some(Self::grid_1x()), - 512 => Some(Self::grid_2x()), - 1024 => Some(Self::detail_1x()), - 2048 => Some(Self::detail_2x()), - _ => None, - } - } - - /// Get variant name for a given size (fallback for custom sizes) - pub fn variant_name_for_size(size: u32) -> String { - if let Some(config) = Self::from_size(size) { - config.variant.as_str().to_string() - } else { - format!("custom@{}px", size) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_standard_variants() { - let grid_1x = ThumbnailVariants::grid_1x(); - assert_eq!(grid_1x.size, 256); - assert_eq!(grid_1x.variant.as_str(), "grid@1x"); - assert_eq!(grid_1x.quality, 85); - - let detail_2x = ThumbnailVariants::detail_2x(); - assert_eq!(detail_2x.size, 2048); - assert_eq!(detail_2x.variant.as_str(), "detail@2x"); - } - - #[test] - fn test_from_size() { - assert!(ThumbnailVariants::from_size(256).is_some()); - assert!(ThumbnailVariants::from_size(512).is_some()); - assert!(ThumbnailVariants::from_size(999).is_none()); - } - - #[test] - fn test_defaults() { - let defaults = ThumbnailVariants::defaults(); - assert_eq!(defaults.len(), 3); - assert_eq!(defaults[0].size, 256); - assert_eq!(defaults[1].size, 512); - assert_eq!(defaults[2].size, 1024); - } - - #[test] - fn test_all_variants() { - let all = ThumbnailVariants::all(); - assert_eq!(all.len(), 6); - } - - #[test] - fn test_custom_variant_name() { - assert_eq!(ThumbnailVariants::variant_name_for_size(256), "grid@1x"); - assert_eq!( - ThumbnailVariants::variant_name_for_size(999), - "custom@999px" - ); - } -} diff --git a/core/src/ops/media/thumbnail/error.rs b/core/src/ops/media/thumbnail/error.rs deleted file mode 100644 index 94d8e4826..000000000 --- a/core/src/ops/media/thumbnail/error.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Thumbnail generation errors - -use thiserror::Error; - -pub type ThumbnailResult = Result; - -#[derive(Error, Debug)] -pub enum ThumbnailError { - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - - #[error("Image processing error: {0}")] - Image(#[from] image::ImageError), - - #[error("Video processing error: {0}")] - VideoProcessing(String), - - #[error("Unsupported format: {0}")] - UnsupportedFormat(String), - - #[error("Invalid thumbnail size: {0}")] - InvalidSize(u32), - - #[error("Invalid quality setting: {0} (must be 0-100)")] - InvalidQuality(u8), - - #[error("File not found: {0}")] - FileNotFound(String), - - #[error("Permission denied: {0}")] - PermissionDenied(String), - - #[error("Thumbnail already exists: {0}")] - AlreadyExists(String), - - #[error("Database error: {0}")] - Database(String), - - #[error("Serialization error: {0}")] - Serialization(String), - - #[error("Other error: {0}")] - Other(String), -} - -impl ThumbnailError { - pub fn video_processing(msg: impl Into) -> Self { - Self::VideoProcessing(msg.into()) - } - - pub fn unsupported_format(format: impl Into) -> Self { - Self::UnsupportedFormat(format.into()) - } - - pub fn database(msg: impl Into) -> Self { - Self::Database(msg.into()) - } - - pub fn other(msg: impl Into) -> Self { - Self::Other(msg.into()) - } -} - -impl From for crate::infra::job::error::JobError { - fn from(err: ThumbnailError) -> Self { - crate::infra::job::error::JobError::execution(err.to_string()) - } -} diff --git a/core/src/ops/media/thumbnail/generator.rs b/core/src/ops/media/thumbnail/generator.rs deleted file mode 100644 index 014048554..000000000 --- a/core/src/ops/media/thumbnail/generator.rs +++ /dev/null @@ -1,482 +0,0 @@ -//! Thumbnail generation engine using existing Spacedrive crates - -use super::error::{ThumbnailError, ThumbnailResult}; -use sd_media_metadata::exif::Orientation; -use serde::{Deserialize, Serialize}; -use std::path::Path; - -/// Information about a generated thumbnail -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbnailInfo { - pub size_bytes: usize, - pub dimensions: (u32, u32), - pub format: String, -} - -/// Multi-format thumbnail generator -#[derive(Debug)] -pub enum ThumbnailGenerator { - #[cfg(target_os = "macos")] - System(SystemGenerator), - Image(ImageGenerator), - Video(VideoGenerator), - Document(DocumentGenerator), -} - -impl ThumbnailGenerator { - /// Create appropriate generator for a MIME type - pub fn for_mime_type(mime_type: &str) -> ThumbnailResult { - // macOS serves every supported category through the system codecs, so the - // per-format dispatch only applies where bundled libraries are needed. - #[cfg(target_os = "macos")] - { - if mime_type.starts_with("image/") - || mime_type.starts_with("video/") - || mime_type == "application/pdf" - { - Ok(Self::System(SystemGenerator::new())) - } else { - Err(ThumbnailError::unsupported_format(mime_type)) - } - } - - #[cfg(not(target_os = "macos"))] - { - match mime_type { - mime if mime.starts_with("image/") => Ok(Self::Image(ImageGenerator::new())), - mime if mime.starts_with("video/") => { - #[cfg(feature = "ffmpeg")] - { - Ok(Self::Video(VideoGenerator::new())) - } - #[cfg(not(feature = "ffmpeg"))] - { - Err(ThumbnailError::other( - "Video thumbnail generation requires FFmpeg feature to be enabled", - )) - } - } - "application/pdf" => Ok(Self::Document(DocumentGenerator::new())), - _ => Err(ThumbnailError::unsupported_format(mime_type)), - } - } - } - - /// Generate thumbnail - pub async fn generate( - &self, - source_path: &Path, - output_path: &Path, - size: u32, - quality: u8, - ) -> ThumbnailResult { - match self { - #[cfg(target_os = "macos")] - Self::System(gen) => gen.generate(source_path, output_path, size, quality).await, - Self::Image(gen) => gen.generate(source_path, output_path, size, quality).await, - Self::Video(gen) => gen.generate(source_path, output_path, size, quality).await, - Self::Document(gen) => gen.generate(source_path, output_path, size, quality).await, - } - } -} - -/// Thumbnail generator backed by the macOS system codecs. -/// -/// ImageIO covers every raster format the platform knows, including HEIC and -/// camera RAW, and QuickLook covers PDF pages, video posters and any other -/// document Finder can preview. One path therefore replaces the bundled FFmpeg, -/// libheif and pdfium libraries, which is what lets the default macOS build ship -/// without a native-deps bundle. -#[cfg(target_os = "macos")] -#[derive(Debug)] -pub struct SystemGenerator; - -#[cfg(target_os = "macos")] -impl SystemGenerator { - pub fn new() -> Self { - Self - } - - pub async fn generate( - &self, - source_path: &Path, - output_path: &Path, - size: u32, - quality: u8, - ) -> ThumbnailResult { - if quality > 100 { - return Err(ThumbnailError::InvalidQuality(quality)); - } - - // Ensure output directory exists - if let Some(parent) = output_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - let source_path = source_path.to_path_buf(); - let output_path = output_path.to_path_buf(); - - let thumbnail_info = tokio::task::spawn_blocking(move || { - // ImageIO applies EXIF orientation itself and bounds the longest side - // to `size`, so no orientation correction or resize is needed here. - let jpeg = sd_imageio::file_thumbnail_jpeg( - &source_path, - i64::from(size), - f64::from(quality) / 100.0, - ) - .ok_or_else(|| { - ThumbnailError::other(format!( - "No system preview available for {}", - source_path.display() - )) - })?; - - // The re-encode keeps this path's output identical in format to the - // other generators. It decodes a thumbnail rather than the original, - // so the cost is negligible next to the system decode above. - let decoded = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg) - .map_err(|e| { - ThumbnailError::other(format!("Failed to decode system preview: {}", e)) - })?; - - let rgb_thumbnail = decoded.to_rgb8(); - let actual_width = rgb_thumbnail.width(); - let actual_height = rgb_thumbnail.height(); - - let webp_encoder = webp::Encoder::from_rgb(&rgb_thumbnail, actual_width, actual_height); - let webp_memory = webp_encoder.encode(quality as f32); - let webp_data = webp_memory.to_vec(); - - std::fs::write(&output_path, &webp_data)?; - - Ok::(ThumbnailInfo { - size_bytes: webp_data.len(), - dimensions: (actual_width, actual_height), - format: "webp".to_string(), - }) - }) - .await - .map_err(|e| ThumbnailError::other(format!("Task join error: {}", e)))??; - - Ok(thumbnail_info) - } -} - -/// Image thumbnail generator using sd-images crate -#[derive(Debug)] -pub struct ImageGenerator; - -impl ImageGenerator { - pub fn new() -> Self { - Self - } - - pub async fn generate( - &self, - source_path: &Path, - output_path: &Path, - size: u32, - quality: u8, - ) -> ThumbnailResult { - if quality > 100 { - return Err(ThumbnailError::InvalidQuality(quality)); - } - - // Ensure output directory exists - if let Some(parent) = output_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Use tokio::task::spawn_blocking for CPU-intensive image processing - let source_path = source_path.to_path_buf(); - let output_path = output_path.to_path_buf(); - - let thumbnail_info = tokio::task::spawn_blocking(move || { - // Use sd-images to load and process the image - let mut img = sd_images::format_image(&source_path) - .map_err(|e| ThumbnailError::other(format!("Failed to load image: {}", e)))?; - - // Apply EXIF orientation correction if available - if let Some(orientation) = Orientation::from_path(&source_path) { - img = orientation.correct_thumbnail(img); - } - - // Calculate target dimensions maintaining aspect ratio - let (original_width, original_height) = (img.width(), img.height()); - let (target_width, target_height) = - calculate_dimensions(original_width, original_height, size); - - // Resize using high-quality algorithm - let thumbnail = img.resize( - target_width, - target_height, - image::imageops::FilterType::Lanczos3, - ); - - // Convert to RGB8 for consistency - let rgb_thumbnail = thumbnail.to_rgb8(); - - // Get actual dimensions from the resized image (may differ from calculated due to rounding) - let actual_width = rgb_thumbnail.width(); - let actual_height = rgb_thumbnail.height(); - - // Verify buffer size matches expected dimensions - let expected_size = (actual_width * actual_height * 3) as usize; - let actual_size = rgb_thumbnail.as_raw().len(); - - if expected_size != actual_size { - return Err(ThumbnailError::other(format!( - "Image buffer size mismatch: expected {} bytes for {}x{}, got {} bytes", - expected_size, actual_width, actual_height, actual_size - ))); - } - - // Encode as WebP using actual dimensions - let webp_encoder = webp::Encoder::from_rgb(&rgb_thumbnail, actual_width, actual_height); - let webp_memory = webp_encoder.encode(quality as f32); - let webp_data = webp_memory.to_vec(); - - // Write to file - std::fs::write(&output_path, &webp_data)?; - - Ok::(ThumbnailInfo { - size_bytes: webp_data.len(), - dimensions: (actual_width, actual_height), - format: "webp".to_string(), - }) - }) - .await - .map_err(|e| ThumbnailError::other(format!("Task join error: {}", e)))??; - - Ok(thumbnail_info) - } -} - -/// Video thumbnail generator using sd-ffmpeg crate -#[derive(Debug)] -pub struct VideoGenerator; - -impl VideoGenerator { - pub fn new() -> Self { - Self - } - - pub async fn generate( - &self, - source_path: &Path, - output_path: &Path, - size: u32, - quality: u8, - ) -> ThumbnailResult { - #[cfg(feature = "ffmpeg")] - { - if quality > 100 { - return Err(ThumbnailError::InvalidQuality(quality)); - } - - // Use sd-ffmpeg helper function to generate thumbnail - sd_ffmpeg::to_thumbnail( - source_path, - output_path, - sd_ffmpeg::ThumbnailSize::Scale(size), - quality as f32, - ) - .await - .map_err(|e| { - ThumbnailError::video_processing(format!("FFmpeg processing failed: {}", e)) - })?; - - // Get file size and return info - let file_size = tokio::fs::metadata(output_path).await?.len() as usize; - - // The written WebP carries the frame's real aspect ratio; reading - // its header is the authority on the output dimensions. - let output_path = output_path.to_path_buf(); - let dimensions = - tokio::task::spawn_blocking(move || image::image_dimensions(&output_path)) - .await - .map_err(|e| ThumbnailError::other(format!("Task join error: {}", e)))? - .map_err(|e| { - ThumbnailError::other(format!("Failed to read thumbnail dimensions: {}", e)) - })?; - - Ok(ThumbnailInfo { - size_bytes: file_size, - dimensions, - format: "webp".to_string(), - }) - } - - #[cfg(not(feature = "ffmpeg"))] - { - let _ = (source_path, output_path, size, quality); // Suppress unused variable warnings - Err(ThumbnailError::other( - "Video thumbnail generation requires FFmpeg feature to be enabled", - )) - } - } -} - -/// Document thumbnail generator using sd-images crate (PDF support) -#[derive(Debug)] -pub struct DocumentGenerator; - -impl DocumentGenerator { - pub fn new() -> Self { - Self - } - - pub async fn generate( - &self, - source_path: &Path, - output_path: &Path, - size: u32, - quality: u8, - ) -> ThumbnailResult { - if quality > 100 { - return Err(ThumbnailError::InvalidQuality(quality)); - } - - // Ensure output directory exists - if let Some(parent) = output_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Use tokio::task::spawn_blocking for CPU-intensive PDF processing - let source_path = source_path.to_path_buf(); - let output_path = output_path.to_path_buf(); - - let thumbnail_info = tokio::task::spawn_blocking(move || { - // Use sd-images to handle PDF (it supports PDF through pdfium-render) - let mut img = sd_images::format_image(&source_path) - .map_err(|e| ThumbnailError::other(format!("Failed to load PDF: {}", e)))?; - - // Apply EXIF orientation correction if available - if let Some(orientation) = Orientation::from_path(&source_path) { - img = orientation.correct_thumbnail(img); - } - - // Calculate target dimensions maintaining aspect ratio - let (original_width, original_height) = (img.width(), img.height()); - let (target_width, target_height) = - calculate_dimensions(original_width, original_height, size); - - // Resize using high-quality algorithm - let thumbnail = img.resize( - target_width, - target_height, - image::imageops::FilterType::Lanczos3, - ); - - // Convert to RGB8 for WebP encoding - let rgb_thumbnail = thumbnail.to_rgb8(); - - // Get actual dimensions from the resized image - let actual_width = rgb_thumbnail.width(); - let actual_height = rgb_thumbnail.height(); - - // Encode as WebP using actual dimensions - let webp_encoder = webp::Encoder::from_rgb(&rgb_thumbnail, actual_width, actual_height); - let webp_memory = webp_encoder.encode(quality as f32); - let webp_data = webp_memory.to_vec(); - - // Write to file - std::fs::write(&output_path, &webp_data)?; - - Ok::(ThumbnailInfo { - size_bytes: webp_data.len(), - dimensions: (actual_width, actual_height), - format: "webp".to_string(), - }) - }) - .await - .map_err(|e| ThumbnailError::other(format!("Task join error: {}", e)))??; - - Ok(thumbnail_info) - } -} - -/// Calculate target dimensions maintaining aspect ratio -fn calculate_dimensions(width: u32, height: u32, target_size: u32) -> (u32, u32) { - let aspect_ratio = width as f32 / height as f32; - - if width > height { - // Landscape - let target_width = target_size; - let target_height = (target_size as f32 / aspect_ratio) as u32; - (target_width, target_height.max(1)) - } else { - // Portrait or square - let target_height = target_size; - let target_width = (target_size as f32 * aspect_ratio) as u32; - (target_width.max(1), target_height) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_calculate_dimensions() { - // Landscape image - let (w, h) = calculate_dimensions(1920, 1080, 256); - assert_eq!(w, 256); - assert_eq!(h, 144); - - // Portrait image - let (w, h) = calculate_dimensions(1080, 1920, 256); - assert_eq!(w, 144); - assert_eq!(h, 256); - - // Square image - let (w, h) = calculate_dimensions(1000, 1000, 256); - assert_eq!(w, 256); - assert_eq!(h, 256); - } - - /// On macOS every supported category resolves to the system codecs, including - /// video and PDF, which is the point of that path: no bundled library is - /// needed for any of them. - #[cfg(target_os = "macos")] - #[test] - fn test_generator_for_mime_type() { - for mime in ["image/jpeg", "image/heic", "video/mp4", "application/pdf"] { - assert!(matches!( - ThumbnailGenerator::for_mime_type(mime), - Ok(ThumbnailGenerator::System(_)) - )); - } - - assert!(ThumbnailGenerator::for_mime_type("text/plain").is_err()); - } - - #[cfg(not(target_os = "macos"))] - #[test] - fn test_generator_for_mime_type() { - assert!(matches!( - ThumbnailGenerator::for_mime_type("image/jpeg"), - Ok(ThumbnailGenerator::Image(_)) - )); - - #[cfg(feature = "ffmpeg")] - { - assert!(matches!( - ThumbnailGenerator::for_mime_type("video/mp4"), - Ok(ThumbnailGenerator::Video(_)) - )); - } - - #[cfg(not(feature = "ffmpeg"))] - { - assert!(ThumbnailGenerator::for_mime_type("video/mp4").is_err()); - } - - assert!(matches!( - ThumbnailGenerator::for_mime_type("application/pdf"), - Ok(ThumbnailGenerator::Document(_)) - )); - - assert!(ThumbnailGenerator::for_mime_type("text/plain").is_err()); - } -} diff --git a/core/src/ops/media/thumbnail/job.rs b/core/src/ops/media/thumbnail/job.rs deleted file mode 100644 index 08068b15a..000000000 --- a/core/src/ops/media/thumbnail/job.rs +++ /dev/null @@ -1,792 +0,0 @@ -//! Thumbnail generation job implementation - -use crate::{ - infra::job::prelude::*, - ops::sidecar::types::{SidecarKind, SidecarStatus, SidecarVariant}, -}; -use serde::{Deserialize, Serialize}; -use std::time::Duration; -use uuid::Uuid; - -use super::{ - config::{ThumbnailVariantConfig, ThumbnailVariants}, - error::{ThumbnailError, ThumbnailResult}, - generator::ThumbnailGenerator, - state::{ThumbnailEntry, ThumbnailPhase, ThumbnailState, ThumbnailStats}, -}; - -/// Configuration for thumbnail generation job -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbnailJobConfig { - /// Target thumbnail variants to generate - #[serde(skip)] - pub variants: Vec, - - /// Whether to regenerate existing thumbnails - pub regenerate: bool, - - /// Batch size for processing - pub batch_size: usize, - - /// Maximum concurrent thumbnail generations - pub max_concurrent: usize, - - /// Whether to run this job in the background (not persisted to database, no UI updates) - #[serde(default)] - pub run_in_background: bool, -} - -impl Default for ThumbnailJobConfig { - fn default() -> Self { - Self { - variants: ThumbnailVariants::defaults(), - regenerate: false, - batch_size: 50, - max_concurrent: 4, - run_in_background: false, - } - } -} - -impl ThumbnailJobConfig { - /// Create a config with all standard variants - pub fn all_variants() -> Self { - Self { - variants: ThumbnailVariants::all(), - ..Default::default() - } - } - - /// Create a config with specific variants - pub fn with_variants(variants: Vec) -> Self { - Self { - variants, - ..Default::default() - } - } -} - -/// Progress information for thumbnail generation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbnailProgress { - pub phase: ThumbnailPhase, - pub generated_count: u64, - pub skipped_count: u64, - pub error_count: u64, - pub total_count: u64, - pub current_file: Option, - pub estimated_time_remaining: Option, -} - -impl JobProgress for ThumbnailProgress {} - -/// Thumbnail generation job -#[derive(Debug, Serialize, Deserialize)] -pub struct ThumbnailJob { - /// Entry IDs to process for thumbnails (if None, process all suitable entries) - pub entry_ids: Option>, - - /// Job configuration - pub config: ThumbnailJobConfig, - - // Resumable state - #[serde(skip_serializing_if = "Option::is_none")] - state: Option, -} - -impl Job for ThumbnailJob { - const NAME: &'static str = "thumbnail_generation"; - const RESUMABLE: bool = true; - const DESCRIPTION: Option<&'static str> = Some("Generate thumbnails for media files"); -} - -impl crate::infra::job::traits::DynJob for ThumbnailJob { - fn job_name(&self) -> &'static str { - Self::NAME - } - - fn should_persist(&self) -> bool { - !self.config.run_in_background - } -} - -/// Output from thumbnail generation job -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbnailOutput { - pub stats: ThumbnailStats, - pub duration: Duration, - pub errors: Vec, -} - -impl From for JobOutput { - fn from(output: ThumbnailOutput) -> Self { - JobOutput::ThumbnailGeneration { - generated_count: output.stats.generated_count, - skipped_count: output.stats.skipped_count, - error_count: output.stats.error_count, - total_size_bytes: output.stats.thumbnails_size_bytes, - } - } -} - -#[async_trait::async_trait] -impl JobHandler for ThumbnailJob { - type Output = ThumbnailOutput; - - async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { - // Initialize or restore state - { - let _state = self.get_or_create_state(&ctx).await?; - } - - // Run each phase sequentially - // Discovery phase - if self.state.as_ref().unwrap().phase == ThumbnailPhase::Discovery { - Self::run_discovery_phase_static( - &self.config, - &self.entry_ids, - self.state.as_mut().unwrap(), - &ctx, - ) - .await?; - } - - // Processing phase - if self.state.as_ref().unwrap().phase == ThumbnailPhase::Processing { - Self::run_processing_phase_static(&self.config, self.state.as_mut().unwrap(), &ctx) - .await?; - } - - // Cleanup phase - if self.state.as_ref().unwrap().phase == ThumbnailPhase::Cleanup { - Self::run_cleanup_phase_static(self.state.as_mut().unwrap(), &ctx).await?; - } - - // Mark as complete and return results - let state = self.state.as_mut().unwrap(); - state.phase = ThumbnailPhase::Complete; - - Ok(ThumbnailOutput { - stats: state.stats.clone(), - duration: state.started_at.elapsed(), - errors: state.errors.clone(), - }) - } - - async fn on_resume(&mut self, ctx: &JobContext<'_>) -> JobResult { - if let Some(state) = &self.state { - ctx.log(format!("Resuming thumbnail job in {:?} phase", state.phase)); - ctx.log(format!( - "Progress: {} generated, {} skipped, {} errors", - state.stats.generated_count, state.stats.skipped_count, state.stats.error_count - )); - } - Ok(()) - } - - async fn on_pause(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log("Pausing thumbnail generation job - state will be preserved"); - Ok(()) - } - - async fn on_cancel(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log("Cancelling thumbnail generation job"); - if let Some(state) = &self.state { - ctx.log(format!( - "Generated {} thumbnails before cancellation", - state.stats.generated_count - )); - } - Ok(()) - } -} - -impl ThumbnailJob { - /// Create a new thumbnail job for all suitable entries - pub fn new(config: ThumbnailJobConfig) -> Self { - Self { - entry_ids: None, - config, - state: None, - } - } - - /// Create a thumbnail job for specific entry IDs - pub fn for_entries(entry_ids: Vec, config: ThumbnailJobConfig) -> Self { - Self { - entry_ids: Some(entry_ids), - config, - state: None, - } - } - - /// Create a thumbnail job with default configuration - pub fn with_defaults() -> Self { - Self::new(ThumbnailJobConfig::default()) - } - - /// Get or create the job state - async fn get_or_create_state( - &mut self, - ctx: &JobContext<'_>, - ) -> JobResult<&mut ThumbnailState> { - if self.state.is_none() { - ctx.log("Initializing new thumbnail generation state"); - self.state = Some(ThumbnailState::new()); - } - Ok(self.state.as_mut().unwrap()) - } - - /// Discovery phase: Find entries that need thumbnails - async fn run_discovery_phase_static( - config: &ThumbnailJobConfig, - entry_ids: &Option>, - state: &mut ThumbnailState, - ctx: &JobContext<'_>, - ) -> JobResult<()> { - ctx.progress(Progress::indeterminate( - "Discovering files for thumbnail generation", - )); - ctx.log("Starting thumbnail discovery phase"); - - use crate::infra::db::entities::{content_identity, entry}; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; - - let library = ctx.library(); - let db = library.db().conn(); - - // Build query for entries with content - let mut query = entry::Entity::find() - .find_also_related(content_identity::Entity) - .filter(content_identity::Column::Uuid.is_not_null()); - - // Filter by specific entry UUIDs if provided - if let Some(ref ids) = entry_ids { - query = query.filter(entry::Column::Uuid.is_in(ids.clone())); - } - - // Filter by file kind (0 = File) and supported extensions - // TODO: Add proper MIME type support to Entry model - query = query - .filter(entry::Column::Kind.eq(0)) // Only files - .order_by_desc(entry::Column::Size); - - // Execute query - let results = query - .all(db) - .await - .map_err(|e| JobError::execution(format!("Database query failed: {}", e)))?; - - ctx.log(format!( - "Query returned {} entries with potential content", - results.len() - )); - - // Process results and check for existing sidecars - let sidecar_manager = library - .core_context() - .get_sidecar_manager() - .await - .ok_or_else(|| JobError::execution("SidecarManager not available"))?; - - let mut skipped_no_content = 0; - let mut skipped_no_uuid = 0; - - for (entry_model, content_opt) in results { - let content = match content_opt { - Some(c) => c, - None => { - skipped_no_content += 1; - continue; - } - }; - - // Skip if content doesn't have a UUID yet - let content_uuid = match content.uuid { - Some(uuid) => uuid, - None => { - skipped_no_uuid += 1; - continue; - } - }; - - // Get full path for this entry - use crate::ops::indexing::PathResolver; - let full_path = match PathResolver::get_full_path(db, entry_model.id).await { - Ok(p) => p, - Err(e) => { - ctx.log(format!( - "Failed to resolve path for entry {}: {}", - entry_model.id, e - )); - continue; - } - }; - - // Check if all required sidecar variants exist - if !config.regenerate { - let mut all_exist = true; - for variant_config in &config.variants { - if !sidecar_manager - .exists( - &library.id(), - &content_uuid, - &SidecarKind::Thumb, - &variant_config.variant, - &variant_config.format(), - ) - .await - .unwrap_or(false) - { - all_exist = false; - break; - } - } - - if all_exist { - state.record_skipped(); - continue; - } - } - - state.pending_entries.push(ThumbnailEntry { - entry_id: entry_model.uuid.unwrap_or_else(Uuid::new_v4), - content_uuid, - content_kind_id: content.kind_id, - extension: entry_model.extension, - file_size: entry_model.size as u64, - relative_path: full_path.to_string_lossy().to_string(), - }); - } - - ctx.log(format!( - "Discovery filtering: skipped {} (no content), {} (no UUID)", - skipped_no_content, skipped_no_uuid - )); - - state.stats.discovered_count = state.pending_entries.len() as u64; - - // Create batches for processing - state.batches = state - .pending_entries - .chunks(config.batch_size) - .map(|chunk| chunk.to_vec()) - .collect(); - - state.phase = ThumbnailPhase::Processing; - - ctx.log(format!( - "Discovery complete: {} entries found, {} batches created", - state.stats.discovered_count, - state.batches.len() - )); - - // Send progress update for start of processing phase - let progress = ThumbnailProgress { - phase: state.phase.clone(), - generated_count: state.stats.generated_count, - skipped_count: state.stats.skipped_count, - error_count: state.stats.error_count, - total_count: state.stats.discovered_count, - current_file: None, - estimated_time_remaining: None, - }; - - use crate::infra::job::generic_progress::ToGenericProgress; - ctx.progress(Progress::generic(progress.to_generic_progress())); - - Ok(()) - } - - /// Processing phase: Generate thumbnails in batches - async fn run_processing_phase_static( - config: &ThumbnailJobConfig, - state: &mut ThumbnailState, - ctx: &JobContext<'_>, - ) -> JobResult<()> { - ctx.log("Starting thumbnail processing phase"); - - let batches = state.batches.clone(); // Clone to avoid borrowing issues - let total_batches = batches.len(); - - for (batch_idx, batch) in batches.iter().enumerate() { - ctx.check_interrupt().await?; - - ctx.log(format!( - "Processing batch {} of {} ({} entries)", - batch_idx + 1, - total_batches, - batch.len() - )); - - // Process entries in the batch concurrently - let tasks = batch - .iter() - .map(|entry| Self::generate_thumbnails_for_entry_static(entry, config, ctx)); - - let results = futures::future::join_all(tasks).await; - - // Process results - for (entry, result) in batch.iter().zip(results.iter()) { - match result { - Ok(thumbnail_size) => { - state.record_generated(*thumbnail_size); - ctx.log(format!("Generated thumbnail for {}", entry.relative_path)); - } - Err(e) => { - let error_msg = format!( - "Failed to generate thumbnail for {}: {}", - entry.relative_path, e - ); - state.add_error(error_msg.clone()); - ctx.add_non_critical_error(crate::infra::job::error::JobError::execution( - error_msg, - )); - } - } - } - - // Update progress with generic progress - let progress = ThumbnailProgress { - phase: state.phase.clone(), - generated_count: state.stats.generated_count, - skipped_count: state.stats.skipped_count, - error_count: state.stats.error_count, - total_count: state.stats.discovered_count, - current_file: batch.last().map(|e| e.relative_path.clone()), - estimated_time_remaining: None, // TODO: Calculate ETA - }; - - use crate::infra::job::generic_progress::ToGenericProgress; - ctx.progress(Progress::generic(progress.to_generic_progress())); - - // Emit ResourceChanged events for affected Files after each batch - if !batch.is_empty() { - let entry_uuids: Vec = - batch.iter().map(|entry| entry.entry_id).collect(); - - if !entry_uuids.is_empty() { - let library = ctx.library(); - let events = library.event_bus().clone(); - let db = std::sync::Arc::new(ctx.library_db().clone()); - - let resource_manager = crate::domain::ResourceManager::new(db, events); - - // Emit events for sidecar changes (which map to files) - // The sidecar UUIDs are in the database, and the ResourceManager - // will map them to the affected file entries - if let Err(e) = resource_manager - .emit_resource_events("entry", entry_uuids) - .await - { - tracing::warn!( - "Failed to emit resource events after thumbnail batch: {}", - e - ); - } - } - } - - // Checkpoint every 10 batches - if batch_idx % 10 == 0 { - ctx.checkpoint().await?; - } - } - - state.phase = ThumbnailPhase::Cleanup; - ctx.log("Processing phase complete"); - - Ok(()) - } - - /// Cleanup phase: Remove orphaned thumbnails - async fn run_cleanup_phase_static( - _state: &mut ThumbnailState, - ctx: &JobContext<'_>, - ) -> JobResult<()> { - ctx.log("Starting cleanup phase"); - ctx.progress(Progress::indeterminate("Cleaning up orphaned thumbnails")); - - // TODO: Implement cleanup logic - // - Find thumbnails that don't have corresponding entries - // - Remove old thumbnails if regenerating - - ctx.log("Cleanup phase complete"); - Ok(()) - } - - /// Strip cloud URL prefix from path to get backend-relative path - fn to_backend_path(path: &str) -> std::path::PathBuf { - if let Some(after_scheme) = path.strip_prefix("s3://") { - // Strip s3://bucket/ prefix to get just the key - if let Some(slash_pos) = after_scheme.find('/') { - let key = &after_scheme[slash_pos + 1..]; - return std::path::PathBuf::from(key); - } - } - // Add support for other cloud services if needed - // For now, return as-is for local paths - std::path::PathBuf::from(path) - } - - /// Check if a path is a cloud path (starts with a cloud scheme) - fn is_cloud_path(path: &str) -> bool { - path.starts_with("s3://") - || path.starts_with("gdrive://") - || path.starts_with("dropbox://") - || path.starts_with("onedrive://") - || path.starts_with("gcs://") - || path.starts_with("azblob://") - || path.starts_with("b2://") - || path.starts_with("wasabi://") - || path.starts_with("spaces://") - } - - /// Generate thumbnails for a single entry - async fn generate_thumbnails_for_entry_static( - entry: &ThumbnailEntry, - config: &ThumbnailJobConfig, - ctx: &JobContext<'_>, - ) -> ThumbnailResult { - use super::generator::ThumbnailGenerator; - use super::utils::ThumbnailUtils; - - // Get library and sidecar manager - let library = ctx.library(); - let sidecar_manager = library - .core_context() - .get_sidecar_manager() - .await - .ok_or_else(|| ThumbnailError::other("SidecarManager not available"))?; - - // Determine MIME type from extension - let mime_type = - entry - .extension - .as_ref() - .and_then(|ext| match ext.to_lowercase().as_str() { - "jpg" | "jpeg" => Some("image/jpeg"), - "png" => Some("image/png"), - "gif" => Some("image/gif"), - "webp" => Some("image/webp"), - "bmp" => Some("image/bmp"), - "tiff" | "tif" => Some("image/tiff"), - // The system codecs decode these on macOS; elsewhere they - // need the bundled HEIF support in sd-images. - #[cfg(any(target_os = "macos", feature = "heif"))] - "heic" | "heif" | "avif" => Some("image/heic"), - "pdf" => Some("application/pdf"), - // Video posters come from QuickLook on macOS and from - // FFmpeg elsewhere. - #[cfg(any(target_os = "macos", feature = "ffmpeg"))] - "mp4" | "mov" | "avi" | "mkv" | "webm" | "flv" | "wmv" | "m4v" => Some("video/mp4"), - _ => None, - }); - - // Skip files with unsupported extensions - let mime_type = match mime_type { - Some(mt) => mt, - None => { - // Skip unsupported file types - return Ok(0); - } - }; - - // Create appropriate generator for the file type - let generator = ThumbnailGenerator::for_mime_type(mime_type)?; - - // Check if this is a cloud path or local path - let is_cloud = Self::is_cloud_path(&entry.relative_path); - - // For cloud files, download to temp file. For local files, use direct path - let (source_path, temp_file): (std::path::PathBuf, Option) = - if is_cloud { - // Cloud path - need to download via volume backend - let volume_manager = ctx.volume_manager().ok_or_else(|| { - ThumbnailError::other("VolumeManager not available for cloud file") - })?; - - // Parse the cloud path to get an SdPath - use crate::domain::addressing::SdPath; - let sdpath = - SdPath::from_uri_with_context(&entry.relative_path, &library.core_context()) - .await - .map_err(|e| { - ThumbnailError::other(format!("Failed to parse cloud path: {}", e)) - })?; - - // Resolve the volume backend for this path - let volume = volume_manager - .resolve_volume_for_sdpath(&sdpath, &library) - .await - .map_err(|e| ThumbnailError::other(format!("Failed to resolve volume: {}", e)))? - .ok_or_else(|| ThumbnailError::other("No volume found for cloud path"))?; - - let backend = volume - .backend - .as_ref() - .ok_or_else(|| ThumbnailError::other("Volume has no backend"))?; - - // Get the backend-relative path (strip s3://bucket/ prefix) - let backend_path = Self::to_backend_path(&entry.relative_path); - - // Download file content from cloud - let file_data = backend.read(&backend_path).await.map_err(|e| { - ThumbnailError::other(format!("Failed to read cloud file: {}", e)) - })?; - - // Write to temporary file - let mut temp = tempfile::NamedTempFile::new().map_err(|e| { - ThumbnailError::other(format!("Failed to create temp file: {}", e)) - })?; - - use std::io::Write; - temp.write_all(&file_data).map_err(|e| { - ThumbnailError::other(format!("Failed to write temp file: {}", e)) - })?; - temp.flush().map_err(|e| { - ThumbnailError::other(format!("Failed to flush temp file: {}", e)) - })?; - - let temp_path = temp.path().to_path_buf(); - ctx.log(format!( - "Downloaded cloud file {} to temp location", - entry.relative_path - )); - - (temp_path, Some(temp)) - } else { - // Local path - use direct filesystem access - let source_path = library.path().join(&entry.relative_path); - - if !source_path.exists() { - return Err(ThumbnailError::FileNotFound(entry.relative_path.clone())); - } - - (source_path, None) - }; - - let mut total_thumbnail_size = 0u64; - - // Generate thumbnails for each configured variant - for variant_config in &config.variants { - // Validate parameters - ThumbnailUtils::validate_thumbnail_params(variant_config.size, variant_config.quality)?; - - // Skip if thumbnail already exists (unless regenerating) - if !config.regenerate - && sidecar_manager - .exists( - &library.id(), - &entry.content_uuid, - &SidecarKind::Thumb, - &variant_config.variant, - &variant_config.format(), - ) - .await - .unwrap_or(false) - { - continue; - } - - // Compute sidecar path - let sidecar_path = sidecar_manager - .compute_path( - &library.id(), - &entry.content_uuid, - &SidecarKind::Thumb, - &variant_config.variant, - &variant_config.format(), - ) - .await - .map_err(|e| ThumbnailError::other(format!("Path computation failed: {}", e)))?; - - let thumbnail_path = sidecar_path.absolute_path; - - // Ensure directory exists - ThumbnailUtils::ensure_thumbnail_dirs(&thumbnail_path).await?; - - // Generate the thumbnail - let thumbnail_info = generator - .generate( - &source_path, - &thumbnail_path, - variant_config.size, - variant_config.quality, - ) - .await?; - - // Record the sidecar in the database - sidecar_manager - .record_sidecar( - library, - &entry.content_uuid, - &SidecarKind::Thumb, - &variant_config.variant, - &variant_config.format(), - thumbnail_info.size_bytes as u64, - None, // checksum - ) - .await - .map_err(|e| ThumbnailError::other(format!("Failed to record sidecar: {}", e)))?; - - total_thumbnail_size += thumbnail_info.size_bytes as u64; - - ctx.log(format!( - "Generated {} thumbnail ({}x{}) for {} ({}KB)", - variant_config.variant.as_str(), - thumbnail_info.dimensions.0, - thumbnail_info.dimensions.1, - entry.relative_path, - thumbnail_info.size_bytes / 1024 - )); - } - - // Media metadata is keyed by content, independent of whether this pass - // wrote new thumbnail files: an already-thumbnailed entry still gets - // its metadata and blurhash backfilled. - if !config.variants.is_empty() { - use crate::ops::media::metadata_extractor::{ - backfill_media_metadata, media_metadata_missing, KIND_IMAGE, KIND_VIDEO, - }; - - let db = library.db().conn(); - let content_kind_id = entry.content_kind_id; - - if media_metadata_missing(db, &entry.content_uuid, content_kind_id).await { - let blurhash = if content_kind_id == KIND_IMAGE || content_kind_id == KIND_VIDEO { - super::blurhash_from_thumbnail( - &sidecar_manager, - library, - &entry.content_uuid, - &config.variants, - ) - .await - } else { - None - }; - - match backfill_media_metadata( - db, - &entry.content_uuid, - &source_path, - content_kind_id, - blurhash, - ) - .await - { - Ok(()) => ctx.log(format!( - "Extracted media metadata for {}", - entry.relative_path - )), - Err(e) => ctx.log(format!("Failed to extract media metadata: {}", e)), - } - } - } - - // Temp file (if any) is automatically cleaned up when dropped here - drop(temp_file); - - Ok(total_thumbnail_size) - } -} diff --git a/core/src/ops/media/thumbnail/mod.rs b/core/src/ops/media/thumbnail/mod.rs deleted file mode 100644 index dc67a812d..000000000 --- a/core/src/ops/media/thumbnail/mod.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Thumbnail generation system -//! -//! This module provides thumbnail generation capabilities for various media types -//! including images, videos, and documents. It operates as a separate job that -//! can run independently or be triggered after indexing operations. - -pub mod action; -mod config; -mod error; -mod generator; -mod job; -pub mod processor; -mod progress; -mod state; -mod utils; - -pub use action::ThumbnailAction; -pub use config::{ThumbnailVariantConfig, ThumbnailVariants}; -pub use error::{ThumbnailError, ThumbnailResult}; -pub use generator::{ImageGenerator, ThumbnailGenerator, ThumbnailInfo, VideoGenerator}; -pub use job::{ThumbnailJob, ThumbnailJobConfig}; -pub use processor::ThumbnailProcessor; -pub use state::{ThumbnailEntry, ThumbnailPhase, ThumbnailState, ThumbnailStats}; -pub use utils::ThumbnailUtils; - -use crate::library::Library; -use crate::ops::sidecar::types::SidecarKind; -use std::path::Path; -use std::sync::Arc; -use uuid::Uuid; - -/// Generate thumbnails for a single file (optimized for watcher/responder use) -/// -/// This function generates the default thumbnail variants for a single file -/// and registers them as sidecars. It's designed to be called inline for -/// individual file creates/updates rather than dispatching a job. -/// -/// # Arguments -/// * `library` - The library context -/// * `content_uuid` - The UUID of the content identity -/// * `source_path` - The filesystem path to the source file -/// * `mime_type` - The MIME type of the file -/// -/// # Returns -/// Number of thumbnails successfully generated -pub async fn generate_thumbnails_for_file( - library: &Arc, - content_uuid: &Uuid, - source_path: &Path, - mime_type: &str, -) -> ThumbnailResult { - use tracing::{debug, warn}; - - // Check if thumbnail generation is supported for this file type - if !ThumbnailUtils::is_thumbnail_supported(mime_type) { - debug!( - "Thumbnail generation not supported for MIME type: {}", - mime_type - ); - return Ok(0); - } - - // Get sidecar manager - let sidecar_manager = library - .core_context() - .get_sidecar_manager() - .await - .ok_or_else(|| ThumbnailError::other("SidecarManager not available"))?; - - // Create thumbnail generator for this MIME type - let generator = ThumbnailGenerator::for_mime_type(mime_type)?; - - // Generate default variants (grid@1x, grid@2x, detail@1x) - let variants = ThumbnailVariants::defaults(); - let mut generated_count = 0; - - for variant_config in &variants { - // Check if thumbnail already exists - if sidecar_manager - .exists( - &library.id(), - content_uuid, - &SidecarKind::Thumb, - &variant_config.variant, - &variant_config.format(), - ) - .await - .unwrap_or(false) - { - debug!( - "Thumbnail already exists for {}: {}", - content_uuid, - variant_config.variant.as_str() - ); - continue; - } - - // Compute output path - let output_path = sidecar_manager - .compute_path( - &library.id(), - content_uuid, - &SidecarKind::Thumb, - &variant_config.variant, - &variant_config.format(), - ) - .await - .map_err(|e| ThumbnailError::other(format!("Failed to compute path: {}", e)))?; - - // Generate thumbnail - match generator - .generate( - source_path, - &output_path.absolute_path, - variant_config.size, - variant_config.quality, - ) - .await - { - Ok(thumbnail_info) => { - // Record the sidecar in the database - if let Err(e) = sidecar_manager - .record_sidecar( - library, - content_uuid, - &SidecarKind::Thumb, - &variant_config.variant, - &variant_config.format(), - thumbnail_info.size_bytes as u64, - None, - ) - .await - { - warn!( - "Failed to record sidecar for {}: {}", - variant_config.variant.as_str(), - e - ); - } else { - debug!( - "✓ Generated thumbnail {}: {}x{}", - variant_config.variant.as_str(), - thumbnail_info.dimensions.0, - thumbnail_info.dimensions.1 - ); - generated_count += 1; - } - } - Err(e) => { - warn!( - "Failed to generate thumbnail {} for {}: {}", - variant_config.variant.as_str(), - source_path.display(), - e - ); - } - } - } - - // Media metadata is keyed by content, independent of whether this call - // wrote new thumbnail files: a file whose thumbnails already exist still - // gets its metadata and blurhash backfilled. - use crate::ops::media::metadata_extractor::{ - backfill_media_metadata, media_metadata_missing, KIND_AUDIO, KIND_IMAGE, KIND_VIDEO, - }; - - let content_kind_id = if mime_type.starts_with("image/") { - KIND_IMAGE - } else if mime_type.starts_with("video/") { - KIND_VIDEO - } else if mime_type.starts_with("audio/") { - KIND_AUDIO - } else { - return Ok(generated_count); - }; - - let db = library.db().conn(); - if media_metadata_missing(db, content_uuid, content_kind_id).await { - let blurhash = if content_kind_id == KIND_IMAGE || content_kind_id == KIND_VIDEO { - blurhash_from_thumbnail(&sidecar_manager, library, content_uuid, &variants).await - } else { - None - }; - - if let Err(e) = - backfill_media_metadata(db, content_uuid, source_path, content_kind_id, blurhash).await - { - debug!( - "Failed to backfill media metadata for {}: {}", - source_path.display(), - e - ); - } - } - - Ok(generated_count) -} - -/// Blurhash for a content identity, encoded from the smallest thumbnail -/// variant on disk. Working from thumbnail-sized pixels keeps the cost to a -/// few milliseconds, which is what makes blurhash viable in the inline path. -async fn blurhash_from_thumbnail( - sidecar_manager: &Arc, - library: &Library, - content_uuid: &Uuid, - variants: &[ThumbnailVariantConfig], -) -> Option { - let smallest = variants.iter().min_by_key(|v| v.size)?; - let path = sidecar_manager - .compute_path( - &library.id(), - content_uuid, - &SidecarKind::Thumb, - &smallest.variant, - &smallest.format(), - ) - .await - .ok()?; - - tokio::task::spawn_blocking(move || { - let thumb = image::open(&path.absolute_path).ok()?; - crate::ops::media::blurhash::generate_blurhash(&thumb).ok() - }) - .await - .ok() - .flatten() -} diff --git a/core/src/ops/media/thumbnail/processor.rs b/core/src/ops/media/thumbnail/processor.rs deleted file mode 100644 index a7390c873..000000000 --- a/core/src/ops/media/thumbnail/processor.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Thumbnail processor - atomic operation for generating thumbnails - -use super::{ThumbnailUtils, ThumbnailVariantConfig, ThumbnailVariants}; -use crate::library::Library; -use crate::ops::indexing::processor::{ProcessorEntry, ProcessorResult}; -use crate::ops::indexing::state::EntryKind; -use anyhow::Result; -use serde_json::Value; -use std::sync::Arc; -use tracing::{debug, warn}; -use uuid::Uuid; - -pub struct ThumbnailProcessor { - library: Arc, - pub variants: Vec, - regenerate: bool, -} - -impl ThumbnailProcessor { - pub fn new(library: Arc) -> Self { - Self { - library, - variants: ThumbnailVariants::defaults(), - regenerate: false, - } - } - - pub fn with_variants(mut self, variants: Vec) -> Self { - self.variants = variants; - self - } - - pub fn with_regenerate(mut self, regenerate: bool) -> Self { - self.regenerate = regenerate; - self - } - - pub fn with_settings(mut self, settings: &Value) -> Result { - // Parse variant names from settings - if let Some(variant_names) = settings.get("variants").and_then(|v| v.as_array()) { - let mut variants = Vec::new(); - for name in variant_names { - if let Some(name_str) = name.as_str() { - let variant = match name_str { - "grid@1x" => ThumbnailVariants::grid_1x(), - "grid@2x" => ThumbnailVariants::grid_2x(), - "detail@1x" => ThumbnailVariants::detail_1x(), - "detail@2x" => ThumbnailVariants::detail_2x(), - "icon@1x" => ThumbnailVariants::icon_1x(), - "icon@2x" => ThumbnailVariants::icon_2x(), - _ => continue, - }; - variants.push(variant); - } - } - if !variants.is_empty() { - self.variants = variants; - } - } - - // Parse quality - if let Some(quality) = settings.get("quality").and_then(|v| v.as_u64()) { - for variant in &mut self.variants { - variant.quality = quality as u8; - } - } - - // Parse regenerate flag - if let Some(regen) = settings.get("regenerate").and_then(|v| v.as_bool()) { - self.regenerate = regen; - } - - Ok(self) - } - - pub fn should_process(&self, entry: &ProcessorEntry) -> bool { - if !matches!(entry.kind, EntryKind::File) { - return false; - } - - if entry.content_id.is_none() { - return false; - } - - entry - .mime_type - .as_ref() - .map_or(false, |m| ThumbnailUtils::is_thumbnail_supported(m)) - } - - pub async fn process( - &self, - db: &sea_orm::DatabaseConnection, - entry: &ProcessorEntry, - ) -> Result { - // Get content UUID - let content_uuid = if let Some(content_id) = entry.content_id { - use crate::infra::db::entities::content_identity; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let ci = content_identity::Entity::find() - .filter(content_identity::Column::Id.eq(content_id)) - .one(db) - .await? - .ok_or_else(|| anyhow::anyhow!("ContentIdentity not found"))?; - - ci.uuid - .ok_or_else(|| anyhow::anyhow!("ContentIdentity missing UUID"))? - } else { - return Ok(ProcessorResult::failure( - "Entry has no content_id".to_string(), - )); - }; - - let mime_type = entry - .mime_type - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Entry has no MIME type"))?; - - if !ThumbnailUtils::is_thumbnail_supported(mime_type) { - debug!("Thumbnails not supported for MIME type: {}", mime_type); - return Ok(ProcessorResult::success(0, 0)); - } - - debug!("→ Generating thumbnails for: {}", entry.path.display()); - - let count = super::generate_thumbnails_for_file( - &self.library, - &content_uuid, - &entry.path, - mime_type, - ) - .await - .map_err(|e| anyhow::anyhow!("Thumbnail generation failed: {}", e))?; - - if count > 0 { - debug!( - "✓ Generated {} thumbnails for: {}", - count, - entry.path.display() - ); - } - - Ok(ProcessorResult::success(count, 0)) - } - - pub fn name(&self) -> &'static str { - "thumbnail" - } -} diff --git a/core/src/ops/media/thumbnail/progress.rs b/core/src/ops/media/thumbnail/progress.rs deleted file mode 100644 index 157620c36..000000000 --- a/core/src/ops/media/thumbnail/progress.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! ThumbnailProgress to GenericProgress conversion - -use super::job::ThumbnailProgress; -use super::state::ThumbnailPhase; -use crate::infra::job::generic_progress::{GenericProgress, ToGenericProgress}; - -impl ToGenericProgress for ThumbnailProgress { - fn to_generic_progress(&self) -> GenericProgress { - let (percentage, phase_name, phase_message) = match &self.phase { - ThumbnailPhase::Discovery => { - let message = format!("Discovering files for thumbnail generation"); - (0.0, "Discovery".to_string(), message) - } - ThumbnailPhase::Processing => { - // Calculate progress based on completed items vs total - let completed = self.generated_count + self.skipped_count + self.error_count; - let progress = if self.total_count > 0 { - completed as f32 / self.total_count as f32 - } else { - 0.0 - }; - // Map to 0-99% range (reserve 100% for complete) - let percentage = (progress * 0.99).min(0.99); - let message = if let Some(ref file) = self.current_file { - format!("Processing thumbnails: {}", file) - } else { - format!( - "Processing thumbnails ({} generated, {} skipped, {} errors)", - self.generated_count, self.skipped_count, self.error_count - ) - }; - (percentage, "Processing".to_string(), message) - } - ThumbnailPhase::Cleanup => { - let message = "Cleaning up".to_string(); - (0.99, "Cleanup".to_string(), message) - } - ThumbnailPhase::Complete => { - let message = "Complete".to_string(); - (1.0, "Complete".to_string(), message) - } - }; - - let completed = self.generated_count + self.skipped_count + self.error_count; - - let mut progress = GenericProgress::new(percentage, &phase_name, &phase_message); - - // Manually set completion without auto-calculating percentage - progress.completion.completed = completed; - progress.completion.total = self.total_count; - - progress - .with_performance( - // Calculate rate: items per second (estimated) - if self.total_count > 0 && completed > 0 { - // Rough estimate - could be improved with actual timing - completed as f32 / 60.0 // Assume 60 seconds for rough rate - } else { - 0.0 - }, - self.estimated_time_remaining, - None, - ) - .with_errors(self.error_count, 0) - .with_metadata(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[test] - fn test_discovery_phase_conversion() { - let thumb_progress = ThumbnailProgress { - phase: ThumbnailPhase::Discovery, - generated_count: 0, - skipped_count: 0, - error_count: 0, - total_count: 0, - current_file: None, - estimated_time_remaining: None, - }; - - let generic = thumb_progress.to_generic_progress(); - assert_eq!(generic.phase, "Discovery"); - assert_eq!(generic.percentage, 0.0); - assert!(generic.message.contains("Discovering")); - } - - #[test] - fn test_processing_phase_conversion() { - let thumb_progress = ThumbnailProgress { - phase: ThumbnailPhase::Processing, - generated_count: 50, - skipped_count: 10, - error_count: 5, - total_count: 100, - current_file: Some("photo.jpg".to_string()), - estimated_time_remaining: Some(Duration::from_secs(30)), - }; - - let generic = thumb_progress.to_generic_progress(); - assert_eq!(generic.phase, "Processing"); - // (50 + 10 + 5) / 100 = 0.65, capped at 0.99 for processing - assert_eq!(generic.percentage, 0.65 * 0.99); - assert_eq!(generic.completion.completed, 65); - assert_eq!(generic.completion.total, 100); - assert_eq!(generic.performance.error_count, 5); - assert!(generic.message.contains("photo.jpg")); - } - - #[test] - fn test_cleanup_phase_conversion() { - let thumb_progress = ThumbnailProgress { - phase: ThumbnailPhase::Cleanup, - generated_count: 95, - skipped_count: 5, - error_count: 0, - total_count: 100, - current_file: None, - estimated_time_remaining: None, - }; - - let generic = thumb_progress.to_generic_progress(); - assert_eq!(generic.phase, "Cleanup"); - assert_eq!(generic.percentage, 0.99); - } - - #[test] - fn test_complete_phase_conversion() { - let thumb_progress = ThumbnailProgress { - phase: ThumbnailPhase::Complete, - generated_count: 100, - skipped_count: 0, - error_count: 0, - total_count: 100, - current_file: None, - estimated_time_remaining: None, - }; - - let generic = thumb_progress.to_generic_progress(); - assert_eq!(generic.phase, "Complete"); - assert_eq!(generic.percentage, 1.0); - } -} diff --git a/core/src/ops/media/thumbnail/state.rs b/core/src/ops/media/thumbnail/state.rs deleted file mode 100644 index b3b70d176..000000000 --- a/core/src/ops/media/thumbnail/state.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Thumbnail job state management - -use serde::{Deserialize, Serialize}; -use std::time::{Duration, Instant}; -use uuid::Uuid; - -/// Phases of thumbnail generation -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ThumbnailPhase { - Discovery, - Processing, - Cleanup, - Complete, -} - -/// Entry information for thumbnail generation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbnailEntry { - pub entry_id: Uuid, - pub content_uuid: Uuid, - pub content_kind_id: i32, - pub extension: Option, - pub file_size: u64, - pub relative_path: String, -} - -/// Statistics for thumbnail generation -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ThumbnailStats { - pub discovered_count: u64, - pub generated_count: u64, - pub skipped_count: u64, - pub error_count: u64, - pub total_size_bytes: u64, - pub thumbnails_size_bytes: u64, -} - -/// State for thumbnail generation job -#[derive(Debug, Clone, Serialize)] -pub struct ThumbnailState { - pub phase: ThumbnailPhase, - pub stats: ThumbnailStats, - pub pending_entries: Vec, - pub batches: Vec>, - pub current_batch_index: usize, - pub errors: Vec, - - #[serde(skip)] - pub started_at: Instant, -} - -impl ThumbnailState { - pub fn new() -> Self { - Self { - phase: ThumbnailPhase::Discovery, - stats: ThumbnailStats::default(), - pending_entries: Vec::new(), - batches: Vec::new(), - current_batch_index: 0, - errors: Vec::new(), - started_at: Instant::now(), - } - } - - pub fn add_error(&mut self, error: String) { - self.errors.push(error); - self.stats.error_count += 1; - } - - pub fn record_generated(&mut self, thumbnail_size: u64) { - self.stats.generated_count += 1; - self.stats.thumbnails_size_bytes += thumbnail_size; - } - - pub fn record_skipped(&mut self) { - self.stats.skipped_count += 1; - } - - pub fn total_processed(&self) -> u64 { - self.stats.generated_count + self.stats.skipped_count + self.stats.error_count - } - - pub fn progress_percentage(&self) -> f32 { - if self.stats.discovered_count == 0 { - return 0.0; - } - (self.total_processed() as f32 / self.stats.discovered_count as f32) * 100.0 - } -} - -impl Default for ThumbnailState { - fn default() -> Self { - Self::new() - } -} - -// Custom deserialization to handle Instant -impl<'de> Deserialize<'de> for ThumbnailState { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct ThumbnailStateHelper { - phase: ThumbnailPhase, - stats: ThumbnailStats, - pending_entries: Vec, - batches: Vec>, - current_batch_index: usize, - errors: Vec, - } - - let helper = ThumbnailStateHelper::deserialize(deserializer)?; - Ok(Self { - phase: helper.phase, - stats: helper.stats, - pending_entries: helper.pending_entries, - batches: helper.batches, - current_batch_index: helper.current_batch_index, - errors: helper.errors, - started_at: Instant::now(), // Reset to current time on deserialization - }) - } -} diff --git a/core/src/ops/media/thumbnail/utils.rs b/core/src/ops/media/thumbnail/utils.rs deleted file mode 100644 index 7a46a27e3..000000000 --- a/core/src/ops/media/thumbnail/utils.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Thumbnail utility functions - -use super::error::{ThumbnailError, ThumbnailResult}; -use std::path::Path; - -/// Utility functions for thumbnail operations -pub struct ThumbnailUtils; - -impl ThumbnailUtils { - /// Check if a file type supports thumbnail generation - pub fn is_thumbnail_supported(mime_type: &str) -> bool { - match mime_type { - mime if mime.starts_with("image/") => true, - // Video posters come from QuickLook on macOS and from FFmpeg - // elsewhere, so macOS supports video without the feature. - mime if mime.starts_with("video/") => { - cfg!(any(target_os = "macos", feature = "ffmpeg")) - } - "application/pdf" => true, - _ => false, - } - } - - /// Validate thumbnail generation parameters - pub fn validate_thumbnail_params(size: u32, quality: u8) -> ThumbnailResult<()> { - if size == 0 || size > 4096 { - return Err(ThumbnailError::InvalidSize(size)); - } - - if quality > 100 { - return Err(ThumbnailError::InvalidQuality(quality)); - } - - Ok(()) - } - - /// Create thumbnail directory structure - pub async fn ensure_thumbnail_dirs(thumbnail_path: &Path) -> ThumbnailResult<()> { - if let Some(parent) = thumbnail_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_thumbnail_supported() { - assert!(ThumbnailUtils::is_thumbnail_supported("image/jpeg")); - assert!(ThumbnailUtils::is_thumbnail_supported("image/png")); - - #[cfg(any(target_os = "macos", feature = "ffmpeg"))] - { - assert!(ThumbnailUtils::is_thumbnail_supported("video/mp4")); - } - - #[cfg(not(any(target_os = "macos", feature = "ffmpeg")))] - { - assert!(!ThumbnailUtils::is_thumbnail_supported("video/mp4")); - } - - assert!(ThumbnailUtils::is_thumbnail_supported("application/pdf")); - assert!(!ThumbnailUtils::is_thumbnail_supported("text/plain")); - assert!(!ThumbnailUtils::is_thumbnail_supported("application/json")); - } - - #[test] - fn test_validate_thumbnail_params() { - assert!(ThumbnailUtils::validate_thumbnail_params(256, 85).is_ok()); - assert!(ThumbnailUtils::validate_thumbnail_params(0, 85).is_err()); - assert!(ThumbnailUtils::validate_thumbnail_params(5000, 85).is_err()); - assert!(ThumbnailUtils::validate_thumbnail_params(256, 101).is_err()); - } -} diff --git a/core/src/ops/media/thumbstrip/action.rs b/core/src/ops/media/thumbstrip/action.rs deleted file mode 100644 index 1f836237b..000000000 --- a/core/src/ops/media/thumbstrip/action.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Thumbstrip generation action handlers - -use crate::{ - context::CoreContext, - infra::action::{error::ActionError, LibraryAction}, -}; -use specta::Type; -use std::sync::Arc; -use uuid::Uuid; - -#[cfg(feature = "ffmpeg")] -use { - super::processor::ThumbstripProcessor, - crate::ops::indexing::{path_resolver::PathResolver, processor::ProcessorEntry}, -}; - -/// Generate thumbstrip for a single video file -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)] -pub struct GenerateThumbstripInput { - /// UUID of the entry to generate thumbstrip for - pub entry_uuid: Uuid, - /// Optional variant names (defaults to thumbstrip_preview) - pub variants: Option>, - /// Force regeneration even if thumbstrip exists - pub force: bool, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)] -pub struct GenerateThumbstripOutput { - /// Number of thumbstrips generated - pub generated_count: usize, - /// Variant names that were generated - pub variants: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GenerateThumbstripAction { - input: GenerateThumbstripInput, -} - -impl GenerateThumbstripAction { - pub fn new(input: GenerateThumbstripInput) -> Self { - Self { input } - } -} - -impl LibraryAction for GenerateThumbstripAction { - type Input = GenerateThumbstripInput; - type Output = GenerateThumbstripOutput; - - fn from_input(input: GenerateThumbstripInput) -> Result { - Ok(Self::new(input)) - } - - async fn execute( - self, - library: Arc, - _context: Arc, - ) -> Result { - #[cfg(feature = "ffmpeg")] - { - use crate::infra::db::entities; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let db = library.db().conn(); - - // Load entry by UUID - let entry = entities::entry::Entity::find() - .filter(entities::entry::Column::Uuid.eq(self.input.entry_uuid)) - .one(db) - .await - .map_err(|e| ActionError::Internal(format!("Failed to load entry: {}", e)))? - .ok_or_else(|| ActionError::Internal("Entry not found".to_string()))?; - - // Get full path - let path = PathResolver::get_full_path(db, entry.id) - .await - .map_err(|e| ActionError::Internal(format!("Failed to resolve path: {}", e)))?; - - // Get MIME type - let mime_type = if let Some(content_id) = entry.content_id { - if let Ok(Some(ci)) = entities::content_identity::Entity::find_by_id(content_id) - .one(db) - .await - { - if let Some(mime_id) = ci.mime_type_id { - if let Ok(Some(mime)) = entities::mime_type::Entity::find_by_id(mime_id) - .one(db) - .await - { - Some(mime.mime_type) - } else { - None - } - } else { - None - } - } else { - None - } - } else { - return Err(ActionError::Internal( - "Entry has no content identity".to_string(), - )); - }; - - // Build processor entry - let kind = match entry.kind { - 0 => crate::ops::indexing::state::EntryKind::File, - 1 => crate::ops::indexing::state::EntryKind::Directory, - 2 => crate::ops::indexing::state::EntryKind::Symlink, - _ => crate::ops::indexing::state::EntryKind::File, - }; - - let proc_entry = ProcessorEntry { - id: entry.id, - uuid: entry.uuid, - path: path.clone(), - kind, - size: entry.size as u64, - content_id: entry.content_id, - mime_type: mime_type.clone(), - }; - - // Create thumbstrip processor with custom settings - let mut processor = - ThumbstripProcessor::new(library.clone()).with_regenerate(self.input.force); - - // Apply custom variants if provided - if let Some(variant_names) = &self.input.variants { - let settings = serde_json::json!({ - "variants": variant_names, - }); - processor = processor - .with_settings(&settings) - .map_err(|e| ActionError::Internal(format!("Invalid settings: {}", e)))?; - } - - // Check if processor should run - if !processor.should_process(&proc_entry) { - return Err(ActionError::Internal( - "File type does not support thumbstrips (not a video)".to_string(), - )); - } - - // Process the file - let result = processor.process(db, &proc_entry).await.map_err(|e| { - ActionError::Internal(format!("Thumbstrip generation failed: {}", e)) - })?; - - if !result.success { - return Err(ActionError::Internal( - result.error.unwrap_or_else(|| "Unknown error".to_string()), - )); - } - - // Get variant names - let variant_names: Vec = processor - .variants - .iter() - .map(|v| v.variant.as_str().to_string()) - .collect(); - - Ok(GenerateThumbstripOutput { - generated_count: result.artifacts_created, - variants: variant_names, - }) - } - - #[cfg(not(feature = "ffmpeg"))] - { - Err(ActionError::InvalidInput( - "Thumbstrip generation feature is not enabled. Please rebuild the daemon with --features ffmpeg".to_string() - )) - } - } - - fn action_kind(&self) -> &'static str { - "media.thumbstrip.generate" - } -} - -crate::register_library_action!(GenerateThumbstripAction, "media.thumbstrip.generate"); diff --git a/core/src/ops/media/thumbstrip/config.rs b/core/src/ops/media/thumbstrip/config.rs deleted file mode 100644 index f7f3c6917..000000000 --- a/core/src/ops/media/thumbstrip/config.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Thumbstrip variant configuration - -use crate::ops::sidecar::types::{SidecarFormat, SidecarVariant}; -use serde::{Deserialize, Serialize}; - -/// Configuration for a single thumbstrip variant -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbstripVariantConfig { - /// Grid columns - pub columns: u32, - /// Grid rows - pub rows: u32, - /// Size of each individual thumbnail (width in pixels) - pub thumbnail_size: u32, - /// WebP quality (0-100) - pub quality: u8, - /// Variant name for sidecar storage - pub variant: SidecarVariant, -} - -impl ThumbstripVariantConfig { - pub fn new( - columns: u32, - rows: u32, - thumbnail_size: u32, - variant: impl Into, - quality: u8, - ) -> Self { - Self { - columns, - rows, - thumbnail_size, - quality, - variant: SidecarVariant::new(variant), - } - } - - pub fn format(&self) -> SidecarFormat { - SidecarFormat::Webp - } - - pub fn total_frames(&self) -> u32 { - self.columns * self.rows - } -} - -/// Standard thumbstrip variants -pub struct ThumbstripVariants; - -impl ThumbstripVariants { - /// Preview thumbstrip (5×5 grid, 320px width thumbnails) - pub fn preview() -> ThumbstripVariantConfig { - ThumbstripVariantConfig::new(5, 5, 320, "thumbstrip_preview", 75) - } - - /// Detailed thumbstrip (10×10 grid, 240px width thumbnails) - pub fn detailed() -> ThumbstripVariantConfig { - ThumbstripVariantConfig::new(10, 10, 240, "thumbstrip_detailed", 70) - } - - /// Mobile thumbstrip (3×3 grid, 160px width thumbnails) - pub fn mobile() -> ThumbstripVariantConfig { - ThumbstripVariantConfig::new(3, 3, 160, "thumbstrip_mobile", 75) - } - - /// Default variants for auto-generation - pub fn defaults() -> Vec { - vec![Self::preview()] - } - - /// All standard variants - pub fn all() -> Vec { - vec![Self::mobile(), Self::preview(), Self::detailed()] - } -} - -/// Configuration for thumbstrip generation job -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbstripJobConfig { - /// Thumbstrip variants to generate - pub variants: Vec, - - /// Whether to regenerate existing thumbstrips - pub regenerate: bool, - - /// Batch size for processing - pub batch_size: usize, -} - -impl Default for ThumbstripJobConfig { - fn default() -> Self { - Self { - variants: ThumbstripVariants::defaults(), - regenerate: false, - batch_size: 10, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_standard_variants() { - let preview = ThumbstripVariants::preview(); - assert_eq!(preview.columns, 5); - assert_eq!(preview.rows, 5); - assert_eq!(preview.thumbnail_size, 320); - assert_eq!(preview.quality, 75); - assert_eq!(preview.total_frames(), 25); - - let detailed = ThumbstripVariants::detailed(); - assert_eq!(detailed.columns, 10); - assert_eq!(detailed.rows, 10); - assert_eq!(detailed.total_frames(), 100); - } - - #[test] - fn test_defaults() { - let defaults = ThumbstripVariants::defaults(); - assert_eq!(defaults.len(), 1); - assert_eq!(defaults[0].total_frames(), 25); - } -} diff --git a/core/src/ops/media/thumbstrip/error.rs b/core/src/ops/media/thumbstrip/error.rs deleted file mode 100644 index c87c8d067..000000000 --- a/core/src/ops/media/thumbstrip/error.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Thumbstrip generation error types - -use thiserror::Error; - -pub type ThumbstripResult = Result; - -#[derive(Error, Debug)] -pub enum ThumbstripError { - #[error("File not found: {0}")] - FileNotFound(String), - - #[error("Unsupported format: {0}")] - UnsupportedFormat(String), - - #[error("Video processing failed: {0}")] - VideoProcessing(String), - - #[error("Invalid quality: {0} (must be 0-100)")] - InvalidQuality(u8), - - #[error("Invalid grid dimensions: {0}x{1}")] - InvalidGridDimensions(u32, u32), - - #[error("No frames extracted from video")] - NoFrames, - - #[error("FFmpeg error: {0}")] - FFmpeg(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("Image processing error: {0}")] - ImageProcessing(String), - - #[error("Other error: {0}")] - Other(String), -} - -impl ThumbstripError { - pub fn other(msg: impl Into) -> Self { - Self::Other(msg.into()) - } - - pub fn video_processing(msg: impl Into) -> Self { - Self::VideoProcessing(msg.into()) - } - - pub fn unsupported_format(format: impl Into) -> Self { - Self::UnsupportedFormat(format.into()) - } -} - -// Conversion from sd-ffmpeg errors -#[cfg(feature = "ffmpeg")] -impl From for ThumbstripError { - fn from(err: sd_ffmpeg::Error) -> Self { - Self::FFmpeg(err.to_string()) - } -} diff --git a/core/src/ops/media/thumbstrip/generator.rs b/core/src/ops/media/thumbstrip/generator.rs deleted file mode 100644 index 8e11d47f9..000000000 --- a/core/src/ops/media/thumbstrip/generator.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! Thumbstrip generation engine -//! -//! Generates grid-based storyboard thumbnails from video files using the sd-ffmpeg crate. - -use super::{ - error::{ThumbstripError, ThumbstripResult}, - ThumbstripVariantConfig, -}; -use image::{imageops, DynamicImage, RgbImage}; -use serde::{Deserialize, Serialize}; -use std::{ops::Deref, path::Path}; -use tokio::task::spawn_blocking; -use tracing::{debug, warn}; -use webp::Encoder; - -/// Information about a generated thumbstrip -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbstripInfo { - pub size_bytes: usize, - pub dimensions: (u32, u32), - pub columns: u32, - pub rows: u32, - pub thumbnail_size: (u32, u32), - pub total_frames: u32, - pub format: String, -} - -/// Video frame data (from sd-ffmpeg) -pub struct VideoFrame { - pub data: Vec, - pub width: u32, - pub height: u32, - pub rotation: f64, -} - -/// Thumbstrip generator -pub struct ThumbstripGenerator { - config: ThumbstripVariantConfig, -} - -impl ThumbstripGenerator { - /// Create a new thumbstrip generator - pub fn new(config: ThumbstripVariantConfig) -> Self { - Self { config } - } - - /// Generate a thumbstrip from a video file - #[cfg(feature = "ffmpeg")] - pub async fn generate( - &self, - video_path: impl AsRef + Send, - output_path: impl AsRef + Send, - ) -> ThumbstripResult { - use sd_ffmpeg::{FrameDecoder, ThumbnailSize}; - - let config = self.config.clone(); - let video_path = video_path.as_ref().to_path_buf(); - let output_path = output_path.as_ref().to_path_buf(); - - spawn_blocking(move || { - // Create frame decoder - let mut decoder = FrameDecoder::new(&video_path, true, false) - .map_err(|e| ThumbstripError::FFmpeg(e.to_string()))?; - - // Get video duration - let duration = decoder - .get_duration_secs() - .ok_or(ThumbstripError::other("Video has no duration"))?; - - // Calculate timestamps for frame extraction - let timestamps = calculate_timestamps(config.total_frames(), duration); - - debug!( - "Extracting {} frames from {:.1}s video", - timestamps.len(), - duration - ); - - // Extract frames at each timestamp - let mut frames = Vec::with_capacity(timestamps.len()); - let thumbnail_size = ThumbnailSize::Scale(config.thumbnail_size); - - for (idx, timestamp) in timestamps.iter().enumerate() { - // Seek to timestamp - if let Err(e) = decoder.seek(*timestamp as i64) { - warn!( - "Failed to seek to {}s (frame {}): {:?}, skipping", - timestamp, idx, e - ); - continue; - } - - // Decode frame at this position - if let Err(e) = decoder.decode_video_frame() { - warn!("Failed to decode frame {}: {:?}, skipping", idx, e); - continue; - } - - // Get scaled frame - match decoder.get_scaled_video_frame(Some(thumbnail_size), true) { - Ok(frame) => { - frames.push(VideoFrame { - data: frame.data, - width: frame.width, - height: frame.height, - rotation: frame.rotation, - }); - } - Err(e) => { - warn!("Failed to scale frame {}: {:?}, skipping", idx, e); - continue; - } - } - } - - if frames.is_empty() { - return Err(ThumbstripError::NoFrames); - } - - debug!("Successfully extracted {} frames", frames.len()); - - // Compose frames into grid - let thumbstrip_image = compose_grid(&frames, config.columns, config.rows)?; - - // Get dimensions - let (width, height) = (thumbstrip_image.width(), thumbstrip_image.height()); - let thumb_width = frames[0].width; - let thumb_height = frames[0].height; - - // Encode to WebP - let webp_data = encode_to_webp(&thumbstrip_image, config.quality)?; - - // Write to file - std::fs::write(&output_path, &webp_data).map_err(|e| ThumbstripError::Io(e))?; - - debug!( - "Generated thumbstrip: {}x{} grid, {} total frames, {} KB", - config.columns, - config.rows, - frames.len(), - webp_data.len() / 1024 - ); - - Ok(ThumbstripInfo { - size_bytes: webp_data.len(), - dimensions: (width, height), - columns: config.columns, - rows: config.rows, - thumbnail_size: (thumb_width, thumb_height), - total_frames: frames.len() as u32, - format: "webp".to_string(), - }) - }) - .await - .map_err(|e| ThumbstripError::other(format!("Task join error: {}", e)))? - } - - /// Generate without ffmpeg feature (returns error) - #[cfg(not(feature = "ffmpeg"))] - pub async fn generate( - &self, - _video_path: impl AsRef + Send, - _output_path: impl AsRef + Send, - ) -> ThumbstripResult { - Err(ThumbstripError::other( - "Thumbstrip generation requires FFmpeg feature to be enabled", - )) - } -} - -/// Calculate evenly-spaced timestamps throughout the video -fn calculate_timestamps(frame_count: u32, duration: f64) -> Vec { - // Skip very first and last frame to avoid black frames - let start_offset = duration * 0.05; // Start 5% into video - let end_offset = duration * 0.95; // End at 95% of video - let usable_duration = end_offset - start_offset; - - (0..frame_count) - .map(|i| { - if frame_count == 1 { - start_offset + usable_duration / 2.0 - } else { - start_offset + (usable_duration * f64::from(i)) / f64::from(frame_count - 1) - } - }) - .collect() -} - -/// Compose individual frames into a grid layout -fn compose_grid(frames: &[VideoFrame], columns: u32, rows: u32) -> ThumbstripResult { - if frames.is_empty() { - return Err(ThumbstripError::NoFrames); - } - - let thumb_width = frames[0].width; - let thumb_height = frames[0].height; - - let grid_width = thumb_width * columns; - let grid_height = thumb_height * rows; - - let mut output = RgbImage::new(grid_width, grid_height); - - // Fill with black background (for incomplete grids) - for pixel in output.pixels_mut() { - *pixel = image::Rgb([0, 0, 0]); - } - - for (idx, frame) in frames.iter().enumerate() { - let row = idx as u32 / columns; - let col = idx as u32 % columns; - - // Stop if we exceed grid dimensions - if row >= rows { - break; - } - - let x_offset = col * thumb_width; - let y_offset = row * thumb_height; - - // Create image from frame data - let frame_image = RgbImage::from_raw(frame.width, frame.height, frame.data.clone()) - .ok_or_else(|| { - ThumbstripError::ImageProcessing( - "Failed to create image from frame data".to_string(), - ) - })?; - - // Copy frame into output grid - imageops::replace(&mut output, &frame_image, x_offset.into(), y_offset.into()); - } - - Ok(DynamicImage::ImageRgb8(output)) -} - -/// Encode image to WebP format -fn encode_to_webp(image: &DynamicImage, quality: u8) -> ThumbstripResult> { - if quality > 100 { - return Err(ThumbstripError::InvalidQuality(quality)); - } - - let quality_f32 = f32::from(quality); - - let encoder = Encoder::from_image(image).map_err(|e| { - ThumbstripError::ImageProcessing(format!("WebP encoder creation failed: {}", e)) - })?; - - // WebPMemory is !Send, so we deref to &[u8] and clone - Ok(encoder.encode(quality_f32).deref().to_vec()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_timestamp_calculation() { - let timestamps = calculate_timestamps(25, 100.0); - - assert_eq!(timestamps.len(), 25); - // First timestamp should be around 5% into video - assert!(timestamps[0] > 4.0 && timestamps[0] < 6.0); - // Last timestamp should be around 95% into video - assert!(timestamps[24] > 94.0 && timestamps[24] < 96.0); - } - - #[test] - fn test_timestamp_calculation_single_frame() { - let timestamps = calculate_timestamps(1, 100.0); - assert_eq!(timestamps.len(), 1); - // Single frame should be at 50% (middle) - assert!(timestamps[0] > 49.0 && timestamps[0] < 51.0); - } -} diff --git a/core/src/ops/media/thumbstrip/job.rs b/core/src/ops/media/thumbstrip/job.rs deleted file mode 100644 index 9576ab8d0..000000000 --- a/core/src/ops/media/thumbstrip/job.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Thumbstrip generation job for batch operations - -use super::processor::ThumbstripProcessor; -use super::{ThumbstripJobConfig, ThumbstripPhase, ThumbstripState}; -use crate::{ - infra::{ - db::entities::entry, - job::{prelude::*, traits::DynJob}, - }, - ops::indexing::processor::ProcessorEntry, -}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::sync::Arc; -use tracing::{info, warn}; -use uuid::Uuid; - -/// Thumbstrip generation job -#[derive(Serialize, Deserialize)] -pub struct ThumbstripJob { - /// Entry IDs to process for thumbstrips (if None, process all suitable entries) - pub entry_ids: Option>, - config: ThumbstripJobConfig, - state: ThumbstripState, -} - -impl ThumbstripJob { - pub fn new(config: ThumbstripJobConfig) -> Self { - Self { - entry_ids: None, - config, - state: ThumbstripState::new(), - } - } - - /// Create a thumbstrip job for specific entry IDs - pub fn for_entries(entry_ids: Vec, config: ThumbstripJobConfig) -> Self { - Self { - entry_ids: Some(entry_ids), - config, - state: ThumbstripState::new(), - } - } - - pub fn with_defaults() -> Self { - Self::new(ThumbstripJobConfig::default()) - } - - async fn run_discovery(&mut self, ctx: &JobContext<'_>) -> JobResult<()> { - use crate::infra::db::entities::{content_identity, entry, mime_type}; - use sea_orm::{ - ColumnTrait, EntityTrait, FromQueryResult, JoinType, QueryFilter, QuerySelect, - RelationTrait, - }; - - ctx.log("Starting thumbstrip discovery phase"); - - let db = ctx.library_db(); - - #[derive(Debug, FromQueryResult)] - struct EntryWithMimeType { - entry_id: i32, - mime_type: Option, - } - - // Query for video entries with MIME types in one go - let mut query = entry::Entity::find() - .select_only() - .column_as(entry::Column::Id, "entry_id") - .column_as(mime_type::Column::MimeType, "mime_type") - .filter(entry::Column::Kind.eq(0)) // Files only - .join(JoinType::InnerJoin, entry::Relation::ContentIdentity.def()) - .join( - JoinType::LeftJoin, - content_identity::Relation::MimeType.def(), - ) - .filter(content_identity::Column::KindId.eq(2)) // Video kind - .filter(content_identity::Column::Uuid.is_not_null()); - - // Filter by specific entry IDs if provided - if let Some(ref ids) = self.entry_ids { - query = query.filter(entry::Column::Uuid.is_in(ids.clone())); - } - - let results = query - .into_model::() - .all(db) - .await - .map_err(|e| JobError::execution(format!("Database query failed: {}", e)))?; - - ctx.log(format!("Found {} video entries", results.len())); - - // Build entry list - for result in results { - let path = crate::ops::indexing::PathResolver::get_full_path(db, result.entry_id) - .await - .map_err(|e| JobError::execution(format!("Failed to resolve path: {}", e)))?; - - self.state - .entries - .push((result.entry_id, path, result.mime_type)); - } - - ctx.log(format!( - "Discovery complete: {} entries to process", - self.state.entries.len() - )); - - Ok(()) - } -} - -impl Job for ThumbstripJob { - const NAME: &'static str = "thumbstrip_generation"; - const RESUMABLE: bool = true; - const DESCRIPTION: Option<&'static str> = - Some("Generate video thumbstrips (storyboard thumbnails)"); -} - -impl DynJob for ThumbstripJob { - fn job_name(&self) -> &'static str { - Self::NAME - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub struct ThumbstripJobOutput { - pub total_processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl From for JobOutput { - fn from(output: ThumbstripJobOutput) -> Self { - JobOutput::Custom(serde_json::json!({ - "type": "thumbstrip_generation", - "total_processed": output.total_processed, - "success_count": output.success_count, - "error_count": output.error_count, - })) - } -} - -#[async_trait::async_trait] -impl JobHandler for ThumbstripJob { - type Output = ThumbstripJobOutput; - - async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { - // Discovery phase - if self.state.phase == ThumbstripPhase::Discovery { - ctx.log("Starting thumbstrip discovery phase"); - self.run_discovery(&ctx).await?; - self.state.phase = ThumbstripPhase::Processing; - } - - // Processing phase - ctx.log(format!( - "Thumbstrip processing phase starting with {} entries", - self.state.entries.len() - )); - - // Create processor instance - let processor = ThumbstripProcessor::new(ctx.library_arc()) - .with_variants(self.config.variants.clone()) - .with_regenerate(self.config.regenerate); - - let total = self.state.entries.len(); - - // Process each entry - while self.state.processed < total { - ctx.check_interrupt().await?; - - let (entry_id, path, mime_type) = &self.state.entries[self.state.processed]; - - // Load entry model - let entry_model = entry::Entity::find_by_id(*entry_id) - .one(ctx.library_db()) - .await? - .ok_or_else(|| JobError::execution("Entry not found"))?; - - // Build processor entry - let proc_entry = ProcessorEntry { - id: *entry_id, - uuid: entry_model.uuid, - path: path.clone(), - kind: crate::ops::indexing::state::EntryKind::File, - size: entry_model.size as u64, - content_id: entry_model.content_id, - mime_type: mime_type.clone(), - }; - - // Check if processor should run - if !processor.should_process(&proc_entry) { - ctx.log(format!( - "Processor.should_process returned false for {} (mime: {:?})", - path.display(), - mime_type - )); - self.state.processed += 1; - continue; - } - - ctx.log(format!("Processor will process: {}", path.display())); - - // Process entry using processor - match processor.process(ctx.library_db(), &proc_entry).await { - Ok(result) if result.success => { - ctx.log(format!( - "Generated {} thumbstrip variants for {}", - result.artifacts_created, - path.display() - )); - self.state.success_count += 1; - } - Ok(_) => { - warn!("Thumbstrip generation failed for {}", path.display()); - self.state.error_count += 1; - } - Err(e) => { - ctx.log(format!( - "ERROR: Thumbstrip error for {}: {}", - path.display(), - e - )); - self.state.error_count += 1; - } - } - - self.state.processed += 1; - - // Report progress - ctx.progress(Progress::Count { - current: self.state.processed, - total, - }); - - // Checkpoint every 10 files - if self.state.processed % 10 == 0 { - ctx.checkpoint().await?; - } - } - - self.state.phase = ThumbstripPhase::Complete; - ctx.log(format!( - "Thumbstrip complete: {} success, {} errors", - self.state.success_count, self.state.error_count - )); - - Ok(ThumbstripJobOutput { - total_processed: self.state.processed, - success_count: self.state.success_count, - error_count: self.state.error_count, - }) - } - - async fn on_resume(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log(format!( - "Resuming thumbstrip job at {}/{}", - self.state.processed, - self.state.entries.len() - )); - Ok(()) - } - - async fn on_pause(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log("Pausing thumbstrip generation job - state will be preserved"); - Ok(()) - } - - async fn on_cancel(&mut self, ctx: &JobContext<'_>) -> JobResult { - ctx.log(format!( - "Cancelling thumbstrip job - generated {} thumbstrips", - self.state.success_count - )); - Ok(()) - } -} diff --git a/core/src/ops/media/thumbstrip/mod.rs b/core/src/ops/media/thumbstrip/mod.rs deleted file mode 100644 index 9cbc6d319..000000000 --- a/core/src/ops/media/thumbstrip/mod.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Thumbstrip generation system -//! -//! Generates grid-based storyboard thumbnails from video files for quick visual overview -//! and timeline scrubbing. Operates as both a processor (for responder) and a job -//! (for batch operations). - -pub mod action; - -#[cfg(feature = "ffmpeg")] -mod config; -#[cfg(feature = "ffmpeg")] -mod error; -#[cfg(feature = "ffmpeg")] -mod generator; -#[cfg(feature = "ffmpeg")] -pub mod job; -#[cfg(feature = "ffmpeg")] -pub mod processor; -#[cfg(feature = "ffmpeg")] -mod state; - -pub use action::GenerateThumbstripAction; - -#[cfg(feature = "ffmpeg")] -pub use config::{ThumbstripJobConfig, ThumbstripVariantConfig, ThumbstripVariants}; -#[cfg(feature = "ffmpeg")] -pub use error::{ThumbstripError, ThumbstripResult}; -#[cfg(feature = "ffmpeg")] -pub use generator::{ThumbstripGenerator, ThumbstripInfo}; -#[cfg(feature = "ffmpeg")] -pub use job::ThumbstripJob; -#[cfg(feature = "ffmpeg")] -pub use processor::ThumbstripProcessor; -#[cfg(feature = "ffmpeg")] -pub use state::{ThumbstripPhase, ThumbstripState}; - -#[cfg(feature = "ffmpeg")] -use crate::library::Library; -#[cfg(feature = "ffmpeg")] -use crate::ops::sidecar::types::SidecarKind; -#[cfg(feature = "ffmpeg")] -use std::path::Path; -#[cfg(feature = "ffmpeg")] -use std::sync::Arc; -#[cfg(feature = "ffmpeg")] -use tracing::{info, warn}; -#[cfg(feature = "ffmpeg")] -use uuid::Uuid; - -/// Generate thumbstrip for a single video file -/// -/// This function generates thumbstrip variants for a single file and registers -/// them as sidecars. Used by both ThumbstripProcessor (responder) and -/// ThumbstripJob (batch operations). -/// -/// # Arguments -/// * `library` - The library context -/// * `content_uuid` - The UUID of the content identity -/// * `video_path` - The filesystem path to the video file -/// * `variants` - The thumbstrip variants to generate -/// * `regenerate` - Whether to regenerate existing thumbstrips -/// -/// # Returns -/// Number of thumbstrips successfully generated -#[cfg(feature = "ffmpeg")] -pub async fn generate_thumbstrip_for_file( - library: &Arc, - content_uuid: &Uuid, - video_path: &Path, - variants: &[ThumbstripVariantConfig], - regenerate: bool, -) -> ThumbstripResult { - // Get sidecar manager - let sidecar_manager = library - .core_context() - .get_sidecar_manager() - .await - .ok_or_else(|| ThumbstripError::other("SidecarManager not available"))?; - - let mut generated_count = 0; - - info!( - "Generating {} thumbstrip variants for {} (regenerate: {})", - variants.len(), - video_path.display(), - regenerate - ); - - for variant_config in variants { - info!( - "Processing variant: {} for {}", - variant_config.variant.as_str(), - content_uuid - ); - - // Check if already exists - let exists_result = sidecar_manager - .exists( - &library.id(), - content_uuid, - &SidecarKind::Thumbstrip, - &variant_config.variant, - &variant_config.format(), - ) - .await; - - match exists_result { - Ok(true) if !regenerate => { - info!( - "Thumbstrip already exists for {}: {}, skipping", - content_uuid, - variant_config.variant.as_str() - ); - continue; - } - Ok(false) => { - info!( - "Thumbstrip does not exist for {}: {}, will generate", - content_uuid, - variant_config.variant.as_str() - ); - } - Ok(true) => { - info!( - "Thumbstrip exists but regenerate=true for {}: {}, will regenerate", - content_uuid, - variant_config.variant.as_str() - ); - } - Err(e) => { - warn!( - "Failed to check thumbstrip existence for {}: {}, will attempt generation anyway: {}", - content_uuid, - variant_config.variant.as_str(), - e - ); - } - } - - // Compute output path - let output_path = sidecar_manager - .compute_path( - &library.id(), - content_uuid, - &SidecarKind::Thumbstrip, - &variant_config.variant, - &variant_config.format(), - ) - .await - .map_err(|e| ThumbstripError::other(format!("Failed to compute path: {}", e)))?; - - // Ensure parent directory exists - if let Some(parent) = output_path.absolute_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Generate thumbstrip using generator - let generator = ThumbstripGenerator::new(variant_config.clone()); - match generator - .generate(video_path, &output_path.absolute_path) - .await - { - Ok(thumbstrip_info) => { - // Record the sidecar - if let Err(e) = sidecar_manager - .record_sidecar( - library, - content_uuid, - &SidecarKind::Thumbstrip, - &variant_config.variant, - &variant_config.format(), - thumbstrip_info.size_bytes as u64, - None, - ) - .await - { - warn!( - "Failed to record sidecar for {}: {}", - variant_config.variant.as_str(), - e - ); - } else { - info!( - "✓ Generated thumbstrip {}: {}x{} ({} frames, {} KB)", - variant_config.variant.as_str(), - thumbstrip_info.dimensions.0, - thumbstrip_info.dimensions.1, - thumbstrip_info.total_frames, - thumbstrip_info.size_bytes / 1024 - ); - generated_count += 1; - } - } - Err(e) => { - warn!( - "Failed to generate thumbstrip {} for {}: {}", - variant_config.variant.as_str(), - video_path.display(), - e - ); - } - } - } - - Ok(generated_count) -} - -/// Generate thumbstrip without ffmpeg feature (returns error) -#[cfg(not(feature = "ffmpeg"))] -pub async fn generate_thumbstrip_for_file( - _library: &std::sync::Arc, - _content_uuid: &uuid::Uuid, - _video_path: &std::path::Path, - _variants: &[()], // Can't reference ThumbstripVariantConfig without feature - _regenerate: bool, -) -> Result { - Err("Thumbstrip generation requires FFmpeg feature to be enabled".to_string()) -} diff --git a/core/src/ops/media/thumbstrip/processor.rs b/core/src/ops/media/thumbstrip/processor.rs deleted file mode 100644 index 6a450d2c8..000000000 --- a/core/src/ops/media/thumbstrip/processor.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! Thumbstrip processor - atomic operation for generating thumbstrips - -use crate::library::Library; -use crate::ops::indexing::processor::{ProcessorEntry, ProcessorResult}; -use crate::ops::indexing::state::EntryKind; -use anyhow::Result; -use serde_json::Value; -use std::sync::Arc; -use tracing::{debug, warn}; - -use super::{ThumbstripVariantConfig, ThumbstripVariants}; - -pub struct ThumbstripProcessor { - library: Arc, - pub variants: Vec, - regenerate: bool, -} - -impl ThumbstripProcessor { - pub fn new(library: Arc) -> Self { - Self { - library, - variants: ThumbstripVariants::defaults(), - regenerate: false, - } - } - - pub fn with_variants(mut self, variants: Vec) -> Self { - self.variants = variants; - self - } - - pub fn with_regenerate(mut self, regenerate: bool) -> Self { - self.regenerate = regenerate; - self - } - - pub fn with_settings(mut self, settings: &Value) -> Result { - // Parse variant names from settings - if let Some(variant_names) = settings.get("variants").and_then(|v| v.as_array()) { - let mut variants = Vec::new(); - for name in variant_names { - if let Some(name_str) = name.as_str() { - let variant = match name_str { - "thumbstrip_preview" => ThumbstripVariants::preview(), - "thumbstrip_detailed" => ThumbstripVariants::detailed(), - "thumbstrip_mobile" => ThumbstripVariants::mobile(), - _ => continue, - }; - variants.push(variant); - } - } - if !variants.is_empty() { - self.variants = variants; - } - } - - // Parse regenerate flag - if let Some(regen) = settings.get("regenerate").and_then(|v| v.as_bool()) { - self.regenerate = regen; - } - - Ok(self) - } - - pub fn should_process(&self, entry: &ProcessorEntry) -> bool { - if !matches!(entry.kind, EntryKind::File) { - debug!("Not a file, skipping"); - return false; - } - - if entry.content_id.is_none() { - debug!("No content_id, skipping"); - return false; - } - - // Only video files - check both MIME type and extension - let is_video = entry - .mime_type - .as_ref() - .map_or(false, |m| m.starts_with("video/")) - || entry - .mime_type - .as_ref() - .and_then(|ext| { - // Common video extensions - matches!( - ext.to_lowercase().as_str(), - "mp4" | "mov" | "avi" | "mkv" | "webm" | "flv" | "wmv" | "m4v" - ) - .then_some(true) - }) - .unwrap_or(false); - - if !is_video { - debug!("Not a video file (mime: {:?}), skipping", entry.mime_type); - } - - is_video - } - - pub async fn process( - &self, - db: &sea_orm::DatabaseConnection, - entry: &ProcessorEntry, - ) -> Result { - // Get content UUID - let content_uuid = if let Some(content_id) = entry.content_id { - use crate::infra::db::entities::content_identity; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - let ci = content_identity::Entity::find() - .filter(content_identity::Column::Id.eq(content_id)) - .one(db) - .await? - .ok_or_else(|| anyhow::anyhow!("ContentIdentity not found"))?; - - ci.uuid - .ok_or_else(|| anyhow::anyhow!("ContentIdentity missing UUID"))? - } else { - return Ok(ProcessorResult::failure( - "Entry has no content_id".to_string(), - )); - }; - - let mime_type = entry - .mime_type - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Entry has no MIME type"))?; - - if !mime_type.starts_with("video/") { - debug!("Thumbstrips only supported for video files"); - return Ok(ProcessorResult::success(0, 0)); - } - - debug!("→ Generating thumbstrip for: {}", entry.path.display()); - - // Call shared generation function - let count = super::generate_thumbstrip_for_file( - &self.library, - &content_uuid, - &entry.path, - &self.variants, - self.regenerate, - ) - .await - .map_err(|e| anyhow::anyhow!("Thumbstrip generation failed: {}", e))?; - - if count > 0 { - debug!( - "✓ Generated {} thumbstrip variants for: {}", - count, - entry.path.display() - ); - } - - Ok(ProcessorResult::success(count, 0)) - } - - pub fn name(&self) -> &'static str { - "thumbstrip" - } -} diff --git a/core/src/ops/media/thumbstrip/state.rs b/core/src/ops/media/thumbstrip/state.rs deleted file mode 100644 index b6b1af1df..000000000 --- a/core/src/ops/media/thumbstrip/state.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Thumbstrip job state structures - -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -/// Job execution phase -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum ThumbstripPhase { - Discovery, - Processing, - Complete, -} - -/// Job state for batch thumbstrip generation -#[derive(Debug, Serialize, Deserialize)] -pub struct ThumbstripState { - pub phase: ThumbstripPhase, - /// Entries to process: (entry_id, path, mime_type) - pub entries: Vec<(i32, PathBuf, Option)>, - pub processed: usize, - pub success_count: usize, - pub error_count: usize, -} - -impl ThumbstripState { - pub fn new() -> Self { - Self { - phase: ThumbstripPhase::Discovery, - entries: Vec::new(), - processed: 0, - success_count: 0, - error_count: 0, - } - } -} - -impl Default for ThumbstripState { - fn default() -> Self { - Self::new() - } -} diff --git a/core/src/ops/mod.rs b/core/src/ops/mod.rs index 66960f138..08b4fd3e9 100644 --- a/core/src/ops/mod.rs +++ b/core/src/ops/mod.rs @@ -20,7 +20,6 @@ pub mod indexing; pub mod jobs; pub mod libraries; pub mod locations; -pub mod media; pub mod metadata; pub mod models; pub mod mounts; @@ -29,7 +28,6 @@ pub mod network; pub mod processes; pub mod redundancy; pub mod search; -pub mod sidecar; pub mod sources; pub mod spaces; pub mod sync; diff --git a/core/src/ops/sidecar/mod.rs b/core/src/ops/sidecar/mod.rs deleted file mode 100644 index a8249f982..000000000 --- a/core/src/ops/sidecar/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod path; -pub mod types; - -pub use path::{SidecarPath, SidecarPathBuilder}; -pub use types::{SidecarFormat, SidecarKind, SidecarStatus, SidecarVariant}; diff --git a/core/src/ops/sidecar/path.rs b/core/src/ops/sidecar/path.rs deleted file mode 100644 index 7f451ea1d..000000000 --- a/core/src/ops/sidecar/path.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -use super::types::{SidecarFormat, SidecarKind, SidecarVariant}; - -/// Represents a fully computed sidecar path -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SidecarPath { - /// Relative path from the sidecars directory - pub relative_path: PathBuf, - /// Full absolute path - pub absolute_path: PathBuf, - /// Shard directories (h0, h1) - pub shards: (String, String), -} - -/// Builder for computing sidecar paths with sharding -pub struct SidecarPathBuilder { - library_path: PathBuf, -} - -impl SidecarPathBuilder { - /// Create a new path builder for a library - pub fn new(library_path: impl AsRef) -> Self { - Self { - library_path: library_path.as_ref().to_path_buf(), - } - } - - /// Compute the shard directories from a content UUID - /// Returns (h0, h1) where h0 and h1 are the first two byte-pairs - /// of the canonical, lowercase hex UUID with hyphens removed - pub fn compute_shards(content_uuid: &Uuid) -> (String, String) { - sd_sidecar_path::compute_shards(content_uuid) - } - - /// Build a sidecar path - pub fn build( - &self, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - ) -> SidecarPath { - let (h0, h1) = Self::compute_shards(content_uuid); - - let relative_path = sd_sidecar_path::relative_path( - content_uuid, - kind.directory(), - variant.as_str(), - format.extension(), - ); - - let mut absolute_path = self.library_path.clone(); - absolute_path.push("sidecars"); - absolute_path.push(&relative_path); - - SidecarPath { - relative_path, - absolute_path, - shards: (h0, h1), - } - } - - /// Build a path to the content directory for a given UUID - pub fn build_content_dir(&self, content_uuid: &Uuid) -> PathBuf { - let (h0, h1) = Self::compute_shards(content_uuid); - - let mut path = self.library_path.clone(); - path.push("sidecars"); - path.push("content"); - path.push(&h0); - path.push(&h1); - path.push(content_uuid.to_string()); - - path - } - - /// Build a path to the manifest file for a given content UUID - pub fn build_manifest_path(&self, content_uuid: &Uuid) -> PathBuf { - let mut path = self.build_content_dir(content_uuid); - path.push("manifest.json"); - path - } - - /// Get the base sidecars directory for the library - pub fn sidecars_dir(&self) -> PathBuf { - let mut path = self.library_path.clone(); - path.push("sidecars"); - path - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_compute_shards() { - let uuid = Uuid::parse_str("abcd1234-5678-90ab-cdef-123456789012").unwrap(); - let (h0, h1) = SidecarPathBuilder::compute_shards(&uuid); - assert_eq!(h0, "ab"); - assert_eq!(h1, "cd"); - } - - #[test] - fn test_build_path() { - let library_path = PathBuf::from("/tmp/test.sdlibrary"); - let builder = SidecarPathBuilder::new(&library_path); - - let uuid = Uuid::parse_str("abcd1234-5678-90ab-cdef-123456789012").unwrap(); - let kind = SidecarKind::Thumb; - let variant = SidecarVariant::new("grid@2x"); - let format = SidecarFormat::Webp; - - let path = builder.build(&uuid, &kind, &variant, &format); - - assert_eq!( - path.relative_path, - PathBuf::from("content/ab/cd/abcd1234-5678-90ab-cdef-123456789012/thumbs/grid@2x.webp") - ); - - assert_eq!( - path.absolute_path, - PathBuf::from("/tmp/test.sdlibrary/sidecars/content/ab/cd/abcd1234-5678-90ab-cdef-123456789012/thumbs/grid@2x.webp") - ); - - assert_eq!(path.shards, ("ab".to_string(), "cd".to_string())); - } - - #[test] - fn test_build_manifest_path() { - let library_path = PathBuf::from("/tmp/test.sdlibrary"); - let builder = SidecarPathBuilder::new(&library_path); - - let uuid = Uuid::parse_str("abcd1234-5678-90ab-cdef-123456789012").unwrap(); - let manifest_path = builder.build_manifest_path(&uuid); - - assert_eq!( - manifest_path, - PathBuf::from("/tmp/test.sdlibrary/sidecars/content/ab/cd/abcd1234-5678-90ab-cdef-123456789012/manifest.json") - ); - } -} diff --git a/core/src/ops/sidecar/sync_job.rs b/core/src/ops/sidecar/sync_job.rs deleted file mode 100644 index afb457658..000000000 --- a/core/src/ops/sidecar/sync_job.rs +++ /dev/null @@ -1,246 +0,0 @@ -use crate::{ - domain::{addressing::SdPath, ResourceManager, SdPathBatch}, - infra::{ - db::entities::sidecar_availability, - job::prelude::*, - }, - ops::files::copy::strategy::CopyStrategy, - service::sidecar_sync::{ - SidecarSyncCoordinator, SidecarSyncFilters, SidecarSyncMode, SidecarTransferPlan, - }, -}; -use serde::{Deserialize, Serialize}; -use std::{sync::Arc, time::Duration}; -use tokio::time::Instant; -use tracing::{debug, error, info}; - -#[derive(Debug, Serialize, Deserialize, Job)] -pub struct SidecarSyncJob { - pub filters: SidecarSyncFilters, - pub mode: SidecarSyncMode, - pub completed_indices: Vec, -} - -impl Job for SidecarSyncJob { - const NAME: &'static str = "sidecar_sync"; - const RESUMABLE: bool = true; - const DESCRIPTION: Option<&'static str> = Some("Sync sidecar files across devices"); -} - -impl crate::infra::job::traits::DynJob for SidecarSyncJob { - fn job_name(&self) -> &'static str { - Self::NAME - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SidecarSyncOutput { - pub discovered: usize, - pub transferred: usize, - pub failed: usize, - pub total_bytes: u64, - pub duration: Duration, -} - -impl From for crate::infra::job::output::JobOutput { - fn from(output: SidecarSyncOutput) -> Self { - Self::SidecarSync { - discovered: output.discovered, - transferred: output.transferred, - failed: output.failed, - total_bytes: output.total_bytes, - } - } -} - -#[async_trait::async_trait] -impl JobHandler for SidecarSyncJob { - type Output = SidecarSyncOutput; - - async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { - let started_at = Instant::now(); - - // Phase 1: Discovery - ctx.progress(Progress::indeterminate("Discovering missing sidecars")); - - let networking = ctx - .networking_service() - .ok_or_else(|| JobError::execution("Networking service not available"))?; - - let sidecar_manager = ctx - .sidecar_manager() - .await - .ok_or_else(|| JobError::execution("Sidecar manager not available"))?; - - let coordinator = SidecarSyncCoordinator::new( - ctx.library_arc(), - networking.clone(), - sidecar_manager.clone(), - ); - - let missing = coordinator - .discover_missing_sidecars(self.filters.clone()) - .await - .map_err(|e| JobError::execution(format!("Discovery failed: {}", e)))?; - - info!("Discovered {} missing sidecars", missing.len()); - ctx.progress(Progress::count(missing.len(), missing.len())); - - if missing.is_empty() { - return Ok(SidecarSyncOutput { - discovered: 0, - transferred: 0, - failed: 0, - total_bytes: 0, - duration: started_at.elapsed(), - }); - } - - // Phase 2: Query Availability - ctx.progress(Progress::indeterminate("Planning transfers")); - - let sources = coordinator - .query_remote_availability(&missing) - .await - .map_err(|e| JobError::execution(format!("Availability query failed: {}", e)))?; - - debug!("Found sources for {} sidecars", sources.len()); - ctx.progress(Progress::indeterminate(format!("Found sources for {} sidecars", sources.len()))); - - // Phase 3: Plan Transfers - let plans = coordinator.plan_transfers(missing.clone(), sources); - - info!("Planned {} transfers", plans.len()); - - if plans.is_empty() { - info!("No online sources available for any sidecars"); - return Ok(SidecarSyncOutput { - discovered: missing.len(), - transferred: 0, - failed: missing.len(), - total_bytes: 0, - duration: started_at.elapsed(), - }); - } - - // Phase 4: Prepare destination - ctx.progress(Progress::indeterminate("Preparing transfers")); - - use crate::device::get_current_device_slug; - let destination_base = ctx.library().path().join("sidecars"); - tokio::fs::create_dir_all(&destination_base) - .await - .map_err(|e| JobError::execution(format!("Failed to create sidecars directory: {}", e)))?; - - // Phase 5: Execute Transfers using RemoteTransferStrategy directly - ctx.progress(Progress::count(0, plans.len())); - - use crate::ops::files::copy::strategy::RemoteTransferStrategy; - let strategy = RemoteTransferStrategy; - - let mut transferred = 0; - let mut failed = 0; - let mut total_bytes = 0u64; - - use crate::device::get_current_device_id; - let db = ctx.library().db(); - let device_uuid = get_current_device_id(); - - for (idx, plan) in plans.iter().enumerate() { - // Skip if already completed - if self.completed_indices.contains(&idx) { - continue; - } - - // Build source and destination paths - let source = SdPath::Sidecar { - content_id: plan.sidecar.content_uuid, - kind: plan.sidecar.kind.clone(), - variant: plan.sidecar.variant.clone(), - format: plan.sidecar.format.clone(), - }; - - // Compute destination filename - let filename = format!( - "{}_{}_{}.{}", - plan.sidecar.content_uuid, - plan.sidecar.kind, - plan.sidecar.variant, - plan.sidecar.format - ); - - let destination = SdPath::Physical { - device_slug: get_current_device_slug(), - path: destination_base.join(&filename), - }; - - // Execute transfer - match strategy.execute(&ctx, &source, &destination, true, None).await { - Ok(bytes) => { - total_bytes += bytes; - transferred += 1; - - // Update availability record - if let Err(e) = sidecar_availability::Entity::update_or_insert( - db.conn(), - &plan.sidecar.content_uuid, - plan.sidecar.kind.as_str(), - plan.sidecar.variant.as_str(), - &device_uuid, - true, // has = true - ) - .await - { - error!("Failed to update availability for sidecar {}: {}", plan.sidecar.sidecar_uuid, e); - } - - // Mark as completed - self.completed_indices.push(idx); - } - Err(e) => { - error!("Failed to transfer sidecar {}: {}", plan.sidecar.sidecar_uuid, e); - failed += 1; - } - } - - // Update progress - ctx.progress(Progress::count(transferred + failed, plans.len())); - } - - // Phase 6: Emit Resource Events - ctx.progress(Progress::indeterminate("Emitting events")); - - let resource_manager = ResourceManager::new( - Arc::new(db.conn().clone()), - ctx.library().event_bus().clone(), - ); - - // Collect sidecar UUIDs for successfully transferred items - let sidecar_uuids: Vec<_> = plans - .iter() - .enumerate() - .filter(|(idx, _)| self.completed_indices.contains(idx)) - .map(|(_, p)| p.sidecar.sidecar_uuid) - .collect(); - - if !sidecar_uuids.is_empty() { - resource_manager - .emit_resource_events("sidecar", sidecar_uuids) - .await - .map_err(|e| JobError::execution(format!("Failed to emit events: {}", e)))?; - } - - info!( - "Sync complete: {} transferred, {} failed", - transferred, failed - ); - - Ok(SidecarSyncOutput { - discovered: missing.len(), - transferred, - failed, - total_bytes, - duration: started_at.elapsed(), - }) - } -} diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index 43b1a95b6..f2a71aa19 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -15,7 +15,6 @@ pub mod file_sync; pub mod mounts; pub mod network; pub mod session; -pub mod sidecar_manager; pub mod statistics_listener; pub mod sync; pub mod thumbs; @@ -25,7 +24,6 @@ pub mod watcher; use device::DeviceService; use file_sharing::FileSharingService; use network::NetworkingService; -use sidecar_manager::SidecarManager; use statistics_listener::StatisticsListenerService; use volume_monitor::{VolumeMonitorConfig, VolumeMonitorService}; use watcher::{FsWatcherService, FsWatcherServiceConfig}; @@ -45,8 +43,6 @@ pub struct Services { pub volume_monitor: Option>, /// Statistics listener service - recalculates library statistics pub statistics_listener: Option>, - /// Sidecar manager - pub sidecar_manager: Arc, /// Key manager pub key_manager: Arc, /// Shared context for all services @@ -63,7 +59,6 @@ impl Services { let file_sharing = Arc::new(FileSharingService::new(context.clone())); let device = Arc::new(DeviceService::new(context.clone())); - let sidecar_manager = Arc::new(SidecarManager::new(context.clone())); let key_manager = context.key_manager.clone(); let statistics_listener = Some(Arc::new(StatisticsListenerService::new(context.clone()))); Self { @@ -73,7 +68,6 @@ impl Services { networking: None, // Initialized separately when needed volume_monitor: None, // Initialized after library manager is available statistics_listener, - sidecar_manager, key_manager, context, } diff --git a/core/src/service/sidecar_manager.rs b/core/src/service/sidecar_manager.rs deleted file mode 100644 index de12dfd8c..000000000 --- a/core/src/service/sidecar_manager.rs +++ /dev/null @@ -1,853 +0,0 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::Arc, -}; - -use anyhow::Result; -use chrono::Utc; -use sea_orm::{entity::prelude::*, ActiveValue, QueryFilter, QuerySelect, TransactionTrait}; -use tokio::sync::{Mutex, RwLock}; -use tracing::{error, info, warn}; -use uuid::Uuid; - -use crate::{ - context::CoreContext, - infra::db::entities::{ - sidecar::{self, Entity as Sidecar}, - sidecar_availability::{self, Entity as SidecarAvailability}, - }, - library::Library, - ops::sidecar::{ - SidecarFormat, SidecarKind, SidecarPath, SidecarPathBuilder, SidecarStatus, SidecarVariant, - }, -}; - -/// Manages the Virtual Sidecar System -pub struct SidecarManager { - context: Arc, - /// Path builders per library - path_builders: RwLock>>, - /// Active generation tasks - active_tasks: Mutex>>, -} - -impl SidecarManager { - /// Create a new sidecar manager - pub fn new(context: Arc) -> Self { - Self { - context, - path_builders: RwLock::new(HashMap::new()), - active_tasks: Mutex::new(HashMap::new()), - } - } - - /// Initialize path builder for a library - pub async fn init_library(&self, library: &Library) -> Result<()> { - let library_path = library.path(); - let sidecars_dir = library_path.join("sidecars"); - - info!( - "Initializing sidecar manager for library {} at path: {}", - library.id(), - library_path.display() - ); - - // Ensure sidecars directory exists - tokio::fs::create_dir_all(&sidecars_dir) - .await - .map_err(|e| { - error!( - "Failed to create sidecars directory {}: {}", - sidecars_dir.display(), - e - ); - e - })?; - - // Create path builder - let builder = Arc::new(SidecarPathBuilder::new(&library_path)); - - let mut builders = self.path_builders.write().await; - builders.insert(library.id(), builder); - - info!( - "Successfully initialized sidecar manager for library {} (path builders count: {})", - library.id(), - builders.len() - ); - Ok(()) - } - - /// Remove path builder for a library - pub async fn deinit_library(&self, library_id: &Uuid) { - let mut builders = self.path_builders.write().await; - builders.remove(library_id); - - info!("Deinitialized sidecar manager for library {}", library_id); - } - - /// Get path builder for a library - async fn get_path_builder(&self, library_id: &Uuid) -> Result> { - let builders = self.path_builders.read().await; - builders - .get(library_id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("Library {} not initialized", library_id)) - } - - /// Compute sidecar path - pub async fn compute_path( - &self, - library_id: &Uuid, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - ) -> Result { - let builder = self.get_path_builder(library_id).await?; - Ok(builder.build(content_uuid, kind, variant, format)) - } - - /// Check if a sidecar exists in the filesystem - pub async fn exists( - &self, - library_id: &Uuid, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - ) -> Result { - let path = self - .compute_path(library_id, content_uuid, kind, variant, format) - .await?; - Ok(tokio::fs::try_exists(&path.absolute_path).await?) - } - - /// Get sidecar presence for multiple content items - pub async fn get_presence( - &self, - library: &Library, - content_uuids: &[Uuid], - kind: &SidecarKind, - variants: &[SidecarVariant], - ) -> Result>> { - let db = library.db(); - - // Query database for local sidecars - let sidecars = Sidecar::find() - .filter(sidecar::Column::ContentUuid.is_in(content_uuids.to_vec())) - .filter(sidecar::Column::Kind.eq(kind.as_str())) - .filter(sidecar::Column::Variant.is_in(variants.iter().map(|v| v.as_str()))) - .all(db.conn()) - .await?; - - // Build presence map - let mut presence_map: HashMap> = HashMap::new(); - - for sidecar in sidecars { - let entry = presence_map - .entry(sidecar.content_uuid) - .or_insert_with(HashMap::new); - - let path = self - .compute_path( - &library.id(), - &sidecar.content_uuid, - &kind, - &SidecarVariant::new(&sidecar.variant), - &sidecar - .format - .as_str() - .try_into() - .map_err(|e: String| anyhow::anyhow!(e))?, - ) - .await?; - - entry.insert( - sidecar.variant.clone(), - SidecarPresence { - local: true, - path: Some(path.relative_path), - status: sidecar - .status - .as_str() - .try_into() - .map_err(|e: String| anyhow::anyhow!(e))?, - devices: vec![], - }, - ); - } - - // Query availability on other devices - let availability = SidecarAvailability::find() - .filter(sidecar_availability::Column::ContentUuid.is_in(content_uuids.to_vec())) - .filter(sidecar_availability::Column::Kind.eq(kind.as_str())) - .filter( - sidecar_availability::Column::Variant.is_in(variants.iter().map(|v| v.as_str())), - ) - .filter(sidecar_availability::Column::Has.eq(true)) - .all(db.conn()) - .await?; - - // Add remote device availability - for avail in availability { - let entry = presence_map - .entry(avail.content_uuid) - .or_insert_with(HashMap::new) - .entry(avail.variant.clone()) - .or_insert(SidecarPresence { - local: false, - path: None, - status: SidecarStatus::Pending, - devices: vec![], - }); - - entry.devices.push(avail.device_uuid); - } - - Ok(presence_map) - } - - /// Get or enqueue a sidecar - pub async fn get_or_enqueue( - &self, - library: &Library, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - ) -> Result { - let path = self - .compute_path(&library.id(), content_uuid, kind, variant, format) - .await?; - - // Check if it exists locally - if tokio::fs::try_exists(&path.absolute_path).await? { - return Ok(SidecarResult::Ready(path.relative_path)); - } - - // Check database - let db = library.db(); - let existing = Sidecar::find() - .filter(sidecar::Column::ContentUuid.eq(*content_uuid)) - .filter(sidecar::Column::Kind.eq(kind.as_str())) - .filter(sidecar::Column::Variant.eq(variant.as_str())) - .one(db.conn()) - .await?; - - if let Some(sidecar) = existing { - match sidecar.status.as_str() { - "ready" => Ok(SidecarResult::Ready(path.relative_path)), - "pending" => Ok(SidecarResult::Pending), - "failed" => { - // Re-enqueue failed sidecars - self.enqueue_generation(library, content_uuid, kind, variant, format) - .await?; - Ok(SidecarResult::Pending) - } - _ => Ok(SidecarResult::Pending), - } - } else { - // Enqueue for generation - self.enqueue_generation(library, content_uuid, kind, variant, format) - .await?; - Ok(SidecarResult::Pending) - } - } - - /// Enqueue sidecar generation - async fn enqueue_generation( - &self, - library: &Library, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - ) -> Result<()> { - let db = library.db(); - let path = self - .compute_path(&library.id(), content_uuid, kind, variant, format) - .await?; - - // Insert pending record - let sidecar = sidecar::ActiveModel { - uuid: ActiveValue::Set(Uuid::new_v4()), // Generate UUID for sync - content_uuid: ActiveValue::Set(*content_uuid), - kind: ActiveValue::Set(kind.as_str().to_string()), - variant: ActiveValue::Set(variant.as_str().to_string()), - format: ActiveValue::Set(format.as_str().to_string()), - rel_path: ActiveValue::Set(path.relative_path.to_string_lossy().to_string()), - size: ActiveValue::Set(0), - checksum: ActiveValue::Set(None), - status: ActiveValue::Set("pending".to_string()), - source: ActiveValue::Set(Some("sidecar_manager".to_string())), - version: ActiveValue::Set(1), - created_at: ActiveValue::Set(Utc::now()), - updated_at: ActiveValue::Set(Utc::now()), - ..Default::default() - }; - - sidecar.insert(db.conn()).await?; - - // Dispatch to job system based on sidecar kind - match kind { - // Image and document thumbnails are generated on every build; the - // job itself skips video sources when ffmpeg support is absent. - SidecarKind::Thumb => { - // For thumbnails, we need to find the entry and dispatch a thumbnail job - // We'll dispatch a job for this specific content_uuid - use crate::infra::db::entities::{content_identity, entry}; - use crate::ops::media::thumbnail::{ThumbnailJob, ThumbnailJobConfig}; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - // Find an entry with this content_uuid - let entry_with_content = entry::Entity::find() - .find_also_related(content_identity::Entity) - .filter(content_identity::Column::Uuid.eq(*content_uuid)) - .one(db.conn()) - .await?; - - if let Some((entry_model, _)) = entry_with_content { - if let Some(entry_uuid) = entry_model.uuid { - // Dispatch thumbnail job for this specific entry - let config = ThumbnailJobConfig::default(); - let job = ThumbnailJob::for_entries(vec![entry_uuid], config); - - library.jobs().dispatch(job).await.map_err(|e| { - anyhow::anyhow!("Failed to dispatch thumbnail job: {}", e) - })?; - - info!( - "Dispatched thumbnail job for {} {} {} (content: {})", - kind.as_str(), - variant.as_str(), - format.as_str(), - content_uuid - ); - } - } - } - _ => { - // For other sidecar types, just log for now - // TODO: Implement dispatch for OCR, transcripts, etc. - info!( - "Enqueued sidecar generation: {} {} {} for {} (no job dispatch yet)", - kind.as_str(), - variant.as_str(), - format.as_str(), - content_uuid - ); - } - } - - Ok(()) - } - - /// Create a reference sidecar that links to an existing entry - /// This allows tracking files in their original locations without moving them - pub async fn create_reference_sidecar( - &self, - library: &Library, - content_uuid: &Uuid, - source_entry_id: i32, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - size: u64, - checksum: Option, - ) -> Result<()> { - let db = library.db(); - - // For reference sidecars, we use the source entry's path - // The rel_path will be empty as the file is not in our sidecar directory - let sidecar = sidecar::ActiveModel { - content_uuid: ActiveValue::Set(*content_uuid), - kind: ActiveValue::Set(kind.as_str().to_string()), - variant: ActiveValue::Set(variant.as_str().to_string()), - format: ActiveValue::Set(format.as_str().to_string()), - rel_path: ActiveValue::Set("".to_string()), // Empty for reference sidecars - source_entry_id: ActiveValue::Set(Some(source_entry_id)), - size: ActiveValue::Set(size as i64), - checksum: ActiveValue::Set(checksum.clone()), - status: ActiveValue::Set("ready".to_string()), - source: ActiveValue::Set(Some("reference".to_string())), - version: ActiveValue::Set(1), - created_at: ActiveValue::Set(Utc::now()), - updated_at: ActiveValue::Set(Utc::now()), - ..Default::default() - }; - - sidecar.insert(db.conn()).await?; - - // Update local availability - self.update_local_availability( - library, - content_uuid, - kind, - variant, - true, - Some(size), - checksum, - ) - .await?; - - info!( - "Created reference sidecar: {} {} {} for {} (entry_id: {})", - kind.as_str(), - variant.as_str(), - format.as_str(), - content_uuid, - source_entry_id - ); - - Ok(()) - } - - /// Convert reference sidecars to owned sidecars by moving files - pub async fn convert_reference_to_owned( - &self, - library: &Library, - content_uuid: &Uuid, - ) -> Result<()> { - let db = library.db(); - - // Find all reference sidecars for this content - let reference_sidecars = Sidecar::find() - .filter(sidecar::Column::ContentUuid.eq(*content_uuid)) - .filter(sidecar::Column::SourceEntryId.is_not_null()) - .all(db.conn()) - .await?; - - for sidecar in reference_sidecars { - if let Some(source_entry_id) = sidecar.source_entry_id { - // Get the source entry to find the file path - use crate::infra::db::entities::entry; - let source_entry = entry::Entity::find_by_id(source_entry_id) - .one(db.conn()) - .await? - .ok_or_else(|| anyhow::anyhow!("Source entry not found"))?; - - // Compute the target sidecar path - let kind = sidecar - .kind - .as_str() - .try_into() - .map_err(|e: String| anyhow::anyhow!(e))?; - let variant = SidecarVariant::new(&sidecar.variant); - let format = sidecar - .format - .as_str() - .try_into() - .map_err(|e: String| anyhow::anyhow!(e))?; - - let target_path = self - .compute_path(&library.id(), content_uuid, &kind, &variant, &format) - .await?; - - // Create parent directory - if let Some(parent) = target_path.absolute_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Move the file - // Get the path from directory_paths for this entry - use crate::infra::db::entities::directory_paths; - let dir_path = directory_paths::Entity::find_by_id(source_entry_id) - .one(db.conn()) - .await? - .ok_or_else(|| anyhow::anyhow!("Directory path not found for entry"))?; - - let source_path = PathBuf::from(&dir_path.path); - tokio::fs::rename(&source_path, &target_path.absolute_path).await?; - - // Update the sidecar record - let mut active: sidecar::ActiveModel = sidecar.into(); - active.rel_path = - ActiveValue::Set(target_path.relative_path.to_string_lossy().to_string()); - active.source_entry_id = ActiveValue::Set(None); - active.source = ActiveValue::Set(Some("converted".to_string())); - active.updated_at = ActiveValue::Set(Utc::now()); - active.update(db.conn()).await?; - - info!( - "Converted reference sidecar to owned: {} {} {} for {}", - kind.as_str(), - variant.as_str(), - format.as_str(), - content_uuid - ); - } - } - - Ok(()) - } - - /// Record sidecar creation - pub async fn record_sidecar( - &self, - library: &Library, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - size: u64, - checksum: Option, - ) -> Result<()> { - self.record_sidecar_internal( - library, - content_uuid, - kind, - variant, - format, - size, - checksum, - true, - ) - .await - } - - async fn record_sidecar_internal( - &self, - library: &Library, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - format: &SidecarFormat, - size: u64, - checksum: Option, - emit_events: bool, - ) -> Result<()> { - let db = library.db(); - let path = self - .compute_path(&library.id(), content_uuid, kind, variant, format) - .await?; - - // Upsert sidecar record - let sidecar = sidecar::ActiveModel { - uuid: ActiveValue::Set(Uuid::new_v4()), - content_uuid: ActiveValue::Set(*content_uuid), - kind: ActiveValue::Set(kind.as_str().to_string()), - variant: ActiveValue::Set(variant.as_str().to_string()), - format: ActiveValue::Set(format.as_str().to_string()), - rel_path: ActiveValue::Set(path.relative_path.to_string_lossy().to_string()), - size: ActiveValue::Set(size as i64), - checksum: ActiveValue::Set(checksum.clone()), - status: ActiveValue::Set("ready".to_string()), - source: ActiveValue::Set(Some("sidecar_manager".to_string())), - version: ActiveValue::Set(1), - updated_at: ActiveValue::Set(Utc::now()), - ..Default::default() - }; - - // Use upsert to handle both insert and update cases - let result = Sidecar::find() - .filter(sidecar::Column::ContentUuid.eq(*content_uuid)) - .filter(sidecar::Column::Kind.eq(kind.as_str())) - .filter(sidecar::Column::Variant.eq(variant.as_str())) - .one(db.conn()) - .await?; - - if let Some(existing) = result { - // Rewriting the file invalidates every cached copy of the old - // pixels; the version travels in sidecar URLs as the cache buster. - let next_version = existing.version + 1; - let mut active: sidecar::ActiveModel = existing.into(); - active.rel_path = ActiveValue::Set(path.relative_path.to_string_lossy().to_string()); - active.size = ActiveValue::Set(size as i64); - active.checksum = ActiveValue::Set(checksum); - active.status = ActiveValue::Set("ready".to_string()); - active.version = ActiveValue::Set(next_version); - active.updated_at = ActiveValue::Set(Utc::now()); - active.update(db.conn()).await?; - } else { - sidecar.insert(db.conn()).await?; - } - - // Update local availability - self.update_local_availability( - library, - content_uuid, - kind, - variant, - true, - Some(size), - None, - ) - .await?; - - // Emit ResourceChanged event for the sidecar - // The resource manager will map sidecar → file automatically - // Skip during bootstrap to avoid spamming frontend with thousands of events - if emit_events { - use crate::domain::ResourceManager; - use crate::infra::db::entities::sidecar; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - - // Find the sidecar record we just created/updated to get its UUID - let sidecar_record = sidecar::Entity::find() - .filter(sidecar::Column::ContentUuid.eq(*content_uuid)) - .filter(sidecar::Column::Kind.eq(kind.as_str())) - .filter(sidecar::Column::Variant.eq(variant.as_str())) - .one(db.conn()) - .await?; - - if let Some(sc) = sidecar_record { - let resource_manager = ResourceManager::new( - std::sync::Arc::new(db.conn().clone()), - library.event_bus().clone(), - ); - - // Emit sidecar event - ResourceManager will map to affected Files - if let Err(e) = resource_manager - .emit_resource_events("sidecar", vec![sc.uuid]) - .await - { - tracing::warn!("Failed to emit resource events for sidecar creation: {}", e); - } else { - tracing::debug!("Emitted sidecar resource event (will map to File updates)"); - } - } - } - - Ok(()) - } - - /// Update local device availability - async fn update_local_availability( - &self, - library: &Library, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - has: bool, - size: Option, - checksum: Option, - ) -> Result<()> { - let db = library.db(); - let device_uuid = self.context.device_manager.current_device().await.id; - - let availability = sidecar_availability::ActiveModel { - content_uuid: ActiveValue::Set(*content_uuid), - kind: ActiveValue::Set(kind.as_str().to_string()), - variant: ActiveValue::Set(variant.as_str().to_string()), - device_uuid: ActiveValue::Set(device_uuid), - has: ActiveValue::Set(has), - size: ActiveValue::Set(size.map(|s| s as i64)), - checksum: ActiveValue::Set(checksum.clone()), - last_seen_at: ActiveValue::Set(Utc::now()), - ..Default::default() - }; - - // Try to update existing record - let existing = SidecarAvailability::find() - .filter(sidecar_availability::Column::ContentUuid.eq(*content_uuid)) - .filter(sidecar_availability::Column::Kind.eq(kind.as_str())) - .filter(sidecar_availability::Column::Variant.eq(variant.as_str())) - .filter(sidecar_availability::Column::DeviceUuid.eq(device_uuid)) - .one(db.conn()) - .await?; - - if let Some(existing) = existing { - let mut active: sidecar_availability::ActiveModel = existing.into(); - active.has = ActiveValue::Set(has); - active.size = ActiveValue::Set(size.map(|s| s as i64)); - active.checksum = ActiveValue::Set(checksum); - active.last_seen_at = ActiveValue::Set(Utc::now()); - active.update(db.conn()).await?; - } else { - availability.insert(db.conn()).await?; - } - - Ok(()) - } - - /// Remove a sidecar - pub async fn remove_sidecar( - &self, - library: &Library, - content_uuid: &Uuid, - kind: &SidecarKind, - variant: &SidecarVariant, - ) -> Result<()> { - let db = library.db(); - - // Delete from database - Sidecar::delete_many() - .filter(sidecar::Column::ContentUuid.eq(*content_uuid)) - .filter(sidecar::Column::Kind.eq(kind.as_str())) - .filter(sidecar::Column::Variant.eq(variant.as_str())) - .exec(db.conn()) - .await?; - - // Update availability - self.update_local_availability(library, content_uuid, kind, variant, false, None, None) - .await?; - - Ok(()) - } - - /// Bootstrap scan sidecars directory and sync with database - pub async fn bootstrap_scan(&self, library: &Library) -> Result<()> { - info!("Starting bootstrap scan for library {}", library.id()); - - let builder = self.get_path_builder(&library.id()).await?; - let sidecars_dir = builder.sidecars_dir(); - let content_dir = sidecars_dir.join("content"); - - if !content_dir.exists() { - info!("No sidecars directory found, skipping bootstrap scan"); - return Ok(()); - } - - let mut scanned_count = 0; - - // Walk through the sharded directory structure - let mut shard_dirs = tokio::fs::read_dir(&content_dir).await?; - - while let Some(h0_entry) = shard_dirs.next_entry().await? { - if !h0_entry.file_type().await?.is_dir() { - continue; - } - - let mut h0_dirs = tokio::fs::read_dir(h0_entry.path()).await?; - - while let Some(h1_entry) = h0_dirs.next_entry().await? { - if !h1_entry.file_type().await?.is_dir() { - continue; - } - - let mut content_dirs = tokio::fs::read_dir(h1_entry.path()).await?; - - while let Some(content_entry) = content_dirs.next_entry().await? { - if !content_entry.file_type().await?.is_dir() { - continue; - } - - let content_uuid_str = content_entry.file_name(); - let content_uuid = match Uuid::parse_str(&content_uuid_str.to_string_lossy()) { - Ok(uuid) => uuid, - Err(_) => { - warn!("Invalid content UUID directory: {:?}", content_uuid_str); - continue; - } - }; - - // Process all sidecars for this content - if let Err(e) = self - .scan_content_sidecars(library, &content_uuid, &content_entry.path()) - .await - { - error!("Failed to scan sidecars for {}: {}", content_uuid, e); - } else { - scanned_count += 1; - } - } - } - } - - info!( - "Bootstrap scan completed: scanned {} content directories", - scanned_count - ); - - Ok(()) - } - - /// Scan all sidecars for a specific content UUID - async fn scan_content_sidecars( - &self, - library: &Library, - content_uuid: &Uuid, - content_path: &Path, - ) -> Result<()> { - // Scan each sidecar kind directory - for kind_str in ["thumbs", "proxies", "embeddings", "ocr", "transcript"] { - let kind_path = content_path.join(kind_str); - if !kind_path.exists() { - continue; - } - - let kind = match kind_str { - "thumbs" => SidecarKind::Thumb, - "proxies" => SidecarKind::Proxy, - "embeddings" => SidecarKind::Embeddings, - "ocr" => SidecarKind::Ocr, - "transcript" => SidecarKind::Transcript, - _ => continue, - }; - - let mut entries = tokio::fs::read_dir(&kind_path).await?; - - while let Some(entry) = entries.next_entry().await? { - if !entry.file_type().await?.is_file() { - continue; - } - - let file_name = entry.file_name(); - let file_name_str = file_name.to_string_lossy(); - - // Parse variant and format from filename - if let Some((variant_str, format_str)) = file_name_str.rsplit_once('.') { - let variant = SidecarVariant::new(variant_str); - let format = match format_str.try_into() { - Ok(f) => f, - Err(_) => { - warn!("Unknown sidecar format: {}", format_str); - continue; - } - }; - - // Get file metadata - let metadata = entry.metadata().await?; - let size = metadata.len(); - - // Record in database - self.record_sidecar( - library, - content_uuid, - &kind, - &variant, - &format, - size, - None, // TODO: Compute checksum if needed - ) - .await?; - } - } - } - - Ok(()) - } - - /// Start watching the sidecars directory for changes - pub async fn start_watcher(&self, library: &Library) -> Result<()> { - // TODO: Implement filesystem watcher - // This would integrate with the existing location_watcher service - // to monitor changes in the sidecars directory - warn!("Sidecar filesystem watcher not yet implemented"); - Ok(()) - } -} - -/// Result of a sidecar request -#[derive(Debug, Clone)] -pub enum SidecarResult { - /// Sidecar is ready with relative path - Ready(PathBuf), - /// Sidecar is being generated - Pending, -} - -/// Sidecar presence information -#[derive(Debug, Clone)] -pub struct SidecarPresence { - /// Available locally - pub local: bool, - /// Local path if available - pub path: Option, - /// Current status - pub status: SidecarStatus, - /// Remote devices that have this sidecar - pub devices: Vec, -} diff --git a/core/src/service/sidecar_sync/coordinator.rs b/core/src/service/sidecar_sync/coordinator.rs deleted file mode 100644 index 9f1f9d9a4..000000000 --- a/core/src/service/sidecar_sync/coordinator.rs +++ /dev/null @@ -1,200 +0,0 @@ -use super::{MissingSidecar, SidecarSource, SidecarSyncFilters, SidecarTransferPlan}; -use crate::{ - device::get_current_device_id, - infra::db::entities::{sidecar, sidecar_availability}, - library::Library, - ops::sidecar::{SidecarKind, SidecarVariant}, - service::{network::NetworkingService, sidecar_manager::SidecarManager}, -}; -use anyhow::Result; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; -use std::{collections::HashMap, sync::Arc}; -use tracing::debug; -use uuid::Uuid; - -pub struct SidecarSyncCoordinator { - library: Arc, - networking: Arc, - sidecar_manager: Arc, -} - -impl SidecarSyncCoordinator { - pub fn new( - library: Arc, - networking: Arc, - sidecar_manager: Arc, - ) -> Self { - Self { - library, - networking, - sidecar_manager, - } - } - - /// Discover sidecars that exist in DB but aren't available locally - pub async fn discover_missing_sidecars( - &self, - filters: SidecarSyncFilters, - ) -> Result> { - let db = self.library.db(); - let device_uuid = get_current_device_id(); - - // Build base query for sidecars - let mut query = sidecar::Entity::find(); - - // Apply kind filter - if let Some(kinds) = &filters.kinds { - let kind_strs: Vec = kinds.iter().map(|k| k.to_string()).collect(); - query = query.filter(sidecar::Column::Kind.is_in(kind_strs)); - } - - // Apply content UUID filter - if let Some(content_uuids) = &filters.content_uuids { - query = query.filter(sidecar::Column::ContentUuid.is_in(content_uuids.clone())); - } - - // Only ready sidecars - query = query.filter(sidecar::Column::Status.eq("ready")); - - // Apply pagination - if let Some(max_count) = filters.max_count { - query = query.limit(max_count as u64); - } - - // Execute query - let all_sidecars = query.all(db.conn()).await?; - - debug!( - "Found {} total sidecars matching filters", - all_sidecars.len() - ); - - // Now filter to only those we don't have locally - let mut missing = Vec::new(); - - for sc in all_sidecars { - // Check if we have availability record - let availability = sidecar_availability::Entity::find() - .filter(sidecar_availability::Column::ContentUuid.eq(sc.content_uuid)) - .filter(sidecar_availability::Column::Kind.eq(&sc.kind)) - .filter(sidecar_availability::Column::Variant.eq(&sc.variant)) - .filter(sidecar_availability::Column::DeviceUuid.eq(device_uuid)) - .one(db.conn()) - .await?; - - // If no record or has = false, it's missing - if availability.is_none() || !availability.unwrap().has { - missing.push(MissingSidecar { - sidecar_uuid: sc.uuid, - content_uuid: sc.content_uuid, - kind: SidecarKind::from_str(&sc.kind).map_err(anyhow::Error::msg)?, - variant: SidecarVariant::new(&sc.variant), - format: sc.format.as_str().try_into().map_err(anyhow::Error::msg)?, - size: sc.size, - checksum: sc.checksum, - }); - } - } - - debug!("Discovered {} missing sidecars", missing.len()); - - Ok(missing) - } - - /// Query remote devices for sidecar availability - pub async fn query_remote_availability( - &self, - missing: &[MissingSidecar], - ) -> Result>> { - if missing.is_empty() { - return Ok(HashMap::new()); - } - - let db = self.library.db(); - let current_device = get_current_device_id(); - - // Build list of content UUIDs to query - let content_uuids: Vec = missing.iter().map(|m| m.content_uuid).collect(); - - // Query availability across all devices (except current) - let availability = sidecar_availability::Entity::find() - .filter(sidecar_availability::Column::ContentUuid.is_in(content_uuids)) - .filter(sidecar_availability::Column::Has.eq(true)) - .filter(sidecar_availability::Column::DeviceUuid.ne(current_device)) - .all(db.conn()) - .await?; - - debug!( - "Found {} availability records from remote devices", - availability.len() - ); - - // Get online devices - let online_devices = self.networking.get_connected_devices().await; - let online_device_ids: std::collections::HashSet = online_devices - .iter() - .map(|d| d.device_id) - .collect(); - - // Build map of content_uuid -> sources, filtering to online devices - let mut sources_map: HashMap> = HashMap::new(); - - for avail in availability { - // Check if device is online - if !online_device_ids.contains(&avail.device_uuid) { - continue; - } - - // Match with missing sidecar (ensure kind/variant match) - let matches = missing.iter().any(|m| { - m.content_uuid == avail.content_uuid - && m.kind.as_str() == avail.kind - && m.variant.as_str() == avail.variant - }); - - if matches { - sources_map - .entry(avail.content_uuid) - .or_default() - .push(SidecarSource { - device_uuid: avail.device_uuid, - last_seen_at: avail.last_seen_at, - verified_checksum: avail.checksum, - }); - } - } - - debug!( - "Found online sources for {} sidecars", - sources_map.len() - ); - - Ok(sources_map) - } - - /// Plan transfers by selecting best source for each sidecar - pub fn plan_transfers( - &self, - missing: Vec, - sources: HashMap>, - ) -> Vec { - missing - .into_iter() - .filter_map(|sidecar| { - let available_sources = sources.get(&sidecar.content_uuid)?; - - if available_sources.is_empty() { - return None; - } - - // Select best source (for now, just pick first one - latency tracking TODO) - let best_source = available_sources.first()?; - - Some(SidecarTransferPlan { - sidecar, - source_device: best_source.device_uuid, - }) - }) - .collect() - } -} diff --git a/core/src/service/sidecar_sync/filters.rs b/core/src/service/sidecar_sync/filters.rs deleted file mode 100644 index 4e82ca389..000000000 --- a/core/src/service/sidecar_sync/filters.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::ops::sidecar::{SidecarFormat, SidecarKind, SidecarVariant}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use specta::Type; -use uuid::Uuid; - -/// Filters for sidecar sync operations -#[derive(Debug, Clone, Serialize, Deserialize, Default, Type)] -pub struct SidecarSyncFilters { - /// Filter by specific sidecar kinds (e.g., only thumbs) - pub kinds: Option>, - - /// Filter by specific content UUIDs - pub content_uuids: Option>, - - /// Maximum number of sidecars to sync in this batch - pub max_count: Option, - - /// Cursor for pagination (format: "content_uuid|kind|variant") - pub cursor: Option, -} - -/// Sync mode for sidecar operations -#[derive(Debug, Clone, Serialize, Deserialize, Type)] -pub enum SidecarSyncMode { - /// Download sidecars we don't have - PullMissing, - /// Upload sidecars to devices that need them (future) - PushNew, - /// Both pull and push (future) - Bidirectional, -} - -/// A sidecar that exists in the database but isn't available locally -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MissingSidecar { - pub sidecar_uuid: Uuid, - pub content_uuid: Uuid, - pub kind: SidecarKind, - pub variant: SidecarVariant, - pub format: SidecarFormat, - pub size: i64, - pub checksum: Option, -} - -/// Information about a device that has a sidecar -#[derive(Debug, Clone)] -pub struct SidecarSource { - pub device_uuid: Uuid, - pub last_seen_at: DateTime, - pub verified_checksum: Option, -} - -/// A planned sidecar transfer with source device selected -#[derive(Debug, Clone)] -pub struct SidecarTransferPlan { - pub sidecar: MissingSidecar, - pub source_device: Uuid, -} diff --git a/core/src/service/sidecar_sync/mod.rs b/core/src/service/sidecar_sync/mod.rs deleted file mode 100644 index 40279f13f..000000000 --- a/core/src/service/sidecar_sync/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod coordinator; -mod filters; - -pub use coordinator::SidecarSyncCoordinator; -pub use filters::{ - MissingSidecar, SidecarSource, SidecarSyncFilters, SidecarSyncMode, SidecarTransferPlan, -}; diff --git a/packages/interface/src/components/Inspector/variants/FileInspector.tsx b/packages/interface/src/components/Inspector/variants/FileInspector.tsx index 2de1cd064..e132ec229 100644 --- a/packages/interface/src/components/Inspector/variants/FileInspector.tsx +++ b/packages/interface/src/components/Inspector/variants/FileInspector.tsx @@ -32,7 +32,6 @@ import {toast} from '@spacedrive/primitives'; import clsx from 'clsx'; import {LocationMap} from '../LocationMap'; import {useState} from 'react'; -import {useJobsContext} from '../../../components/JobManager/hooks/JobsContext'; import {TagSelectorButton} from '../../../components/Tags'; import {usePlatform} from '../../../contexts/PlatformContext'; import {useServer} from '../../../contexts/ServerContext'; @@ -161,20 +160,6 @@ function FileQuickActions({file}: {file: File}) { const explorer = useOptionalExplorer(); const [isFavorite, setIsFavorite] = useState(false); // TODO: Get from file metadata - // AI Processing mutations - const extractText = useLibraryMutation('media.ocr.extract'); - const transcribeAudio = useLibraryMutation('media.speech.transcribe'); - const generateSplat = useLibraryMutation('media.splat.generate'); - const regenerateThumbnail = useLibraryMutation('media.thumbnail.regenerate'); - const generateThumbstrip = useLibraryMutation('media.thumbstrip.generate'); - const generateProxy = useLibraryMutation('media.proxy.generate'); - - // Check content kind for available actions - const isImage = getContentKind(file) === 'image'; - const isVideo = getContentKind(file) === 'video'; - const isAudio = getContentKind(file) === 'audio'; - const showJobsButton = isImage || isVideo || isAudio; - // Get physical path for sharing const getPhysicalPath = (): string | null => { if (file.sd_path && 'Physical' in file.sd_path) { @@ -211,89 +196,6 @@ function FileQuickActions({file}: {file: File}) { } }; - // Jobs context menu - const jobsMenu = useContextMenu({ - items: [ - // Image actions - { - icon: TextAa, - label: 'Extract Text (OCR)', - onClick: () => { - extractText.mutate({ - entry_uuid: file.id, - languages: ['eng'], - force: false - }); - }, - condition: () => isImage - }, - { - icon: Cube, - label: 'Generate 3D Splat', - onClick: () => { - generateSplat.mutate({ - entry_uuid: file.id, - model_path: null - }); - }, - condition: () => isImage - }, - // Video/Audio actions - { - icon: Microphone, - label: 'Generate Subtitles', - onClick: () => { - transcribeAudio.mutate({ - entry_uuid: file.id, - model: 'base', - language: null - }); - }, - condition: () => isVideo || isAudio - }, - // Video-only actions - { - icon: FilmStrip, - label: 'Generate Thumbstrip', - onClick: () => { - generateThumbstrip.mutate({ - entry_uuid: file.id, - variants: ['thumbstrip_preview', 'thumbstrip_detailed'], - force: false - }); - }, - condition: () => isVideo - }, - { - icon: VideoCamera, - label: 'Generate Scrubbing Proxy', - onClick: () => { - generateProxy.mutate({ - entry_uuid: file.id, - resolution: 'scrubbing', - force: false, - use_hardware_accel: true - }); - }, - condition: () => isVideo - }, - {type: 'separator' as const}, - // Common actions - { - icon: ArrowsClockwise, - label: 'Regenerate Thumbnails', - onClick: () => { - regenerateThumbnail.mutate({ - entry_uuid: file.id, - variants: ['grid@1x', 'grid@2x', 'detail@1x'], - force: true - }); - }, - condition: () => isImage || isVideo - } - ] - }); - return (
{/* Quick Preview Button — spacebar isn't available on touch devices */} @@ -335,17 +237,6 @@ function FileQuickActions({file}: {file: File}) { )} - {/* Jobs Button */} - {showJobsButton && ( - - )}
); } @@ -651,24 +542,6 @@ function OverviewTab({file}: {file: File}) { const applyTag = useLibraryMutation('tags.apply', { onSuccess: refetchTagQueries }); const unapplyTag = useLibraryMutation('tags.unapply', { onSuccess: refetchTagQueries }); - // AI Processing mutations - const extractText = useLibraryMutation('media.ocr.extract'); - const transcribeAudio = useLibraryMutation('media.speech.transcribe'); - const generateSplat = useLibraryMutation('media.splat.generate'); - const regenerateThumbnail = useLibraryMutation( - 'media.thumbnail.regenerate' - ); - const generateThumbstrip = useLibraryMutation('media.thumbstrip.generate'); - const generateProxy = useLibraryMutation('media.proxy.generate'); - - // Job tracking for long-running operations - const {jobs} = useJobsContext(); - const isSpeechJobRunning = jobs.some( - (job) => - job.name === 'speech_to_text' && - (job.status === 'running' || job.status === 'queued') - ); - // Check content kind for available actions const isImage = getContentKind(file) === 'image'; const isVideo = getContentKind(file) === 'video'; @@ -980,377 +853,10 @@ function OverviewTab({file}: {file: File}) { )} - {/* AI Processing */} + {/* Extracted text, when something has produced it */} {(isImage || isVideo || isAudio) && (
- {/* OCR for images */} - {isImage && ( - - )} - - {/* Gaussian Splat for images */} - {isImage && ( - - )} - - {/* Speech-to-text for audio/video */} - {(isVideo || isAudio) && ( - - )} - - {/* Regenerate thumbnails */} - {(isImage || isVideo) && ( - - )} - - {/* Generate thumbstrip (for videos) */} - {isVideo && ( - - )} - - {/* Generate proxy (for videos) */} - {isVideo && ( - - )} - {/* Show extracted text if available */} {hasText && (
diff --git a/packages/interface/src/components/JobManager/types.ts b/packages/interface/src/components/JobManager/types.ts index 2f89e1a5e..e38ec74aa 100644 --- a/packages/interface/src/components/JobManager/types.ts +++ b/packages/interface/src/components/JobManager/types.ts @@ -71,10 +71,6 @@ export function getJobDisplayName(job: JobListItem): string { } break; } - case "media.thumbnail": - return "Generating Thumbnails"; - case "media.extract": - return "Extracting Media"; case "indexing.start": return "Indexing Location"; case "volumes.index": { diff --git a/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts b/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts index 3e2337a05..e571e71c2 100644 --- a/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts +++ b/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts @@ -58,13 +58,6 @@ export function useFileContextMenu({ }); const createFolder = useLibraryMutation('files.createFolder'); const addToLibrary = useLibraryMutation('sources.track'); - const regenerateThumbnail = useLibraryMutation( - 'media.thumbnail.regenerate' - ); - const extractText = useLibraryMutation('media.ocr.extract'); - const transcribeAudio = useLibraryMutation('media.speech.transcribe'); - const generateThumbstrip = useLibraryMutation('media.thumbstrip.generate'); - const generateProxy = useLibraryMutation('media.proxy.generate'); // Helper to run a mutation on each target file const forEachTarget = async ( @@ -398,257 +391,6 @@ export function useFileContextMenu({ keybindId: 'explorer.paste', condition: () => clipboard.hasClipboard() }, - // Media Processing submenu - { - type: 'submenu', - icon: Image, - label: 'Image Processing', - condition: () => !!file && getContentKind(file) === 'image', - submenu: [ - { - icon: Sparkle, - label: 'Generate Blurhash', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - regenerateThumbnail.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: false - }) - ); - }, - condition: () => - !!file && !file.image_media_data?.blurhash - }, - { - icon: Crop, - label: 'Regenerate Thumbnail', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - regenerateThumbnail.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: true - }) - ); - } - }, - { - icon: TextAa, - label: 'Extract Text (OCR)', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - extractText.mutateAsync({ - entry_uuid: f.id, - languages: null, - force: false - }) - ); - }, - keybind: '⌘⇧T' - } - ] - }, - { - type: 'submenu', - icon: Video, - label: 'Video Processing', - condition: () => !!file && getContentKind(file) === 'video', - submenu: [ - { - icon: FilmStrip, - label: 'Generate Thumbstrip', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - generateThumbstrip.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: false - }) - ); - }, - condition: () => - !!file && - !file.sidecars?.some((s) => s.kind === 'thumbstrip') - }, - { - icon: Sparkle, - label: 'Generate Blurhash', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - regenerateThumbnail.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: false - }) - ); - }, - condition: () => - !!file && !file.video_media_data?.blurhash - }, - { - icon: Crop, - label: 'Regenerate Thumbnail', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - regenerateThumbnail.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: true - }) - ); - } - }, - { - icon: Waveform, - label: 'Extract Subtitles', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - transcribeAudio.mutateAsync({ - entry_uuid: f.id, - model: null, - language: null - }) - ); - } - }, - { - icon: FileVideo, - label: 'Generate Proxy', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - generateProxy.mutateAsync({ - entry_uuid: f.id, - resolution: null, - use_hardware_accel: null, - force: false - }) - ); - }, - keybind: '⌘⇧P' - } - ] - }, - { - type: 'submenu', - icon: Microphone, - label: 'Audio Processing', - condition: () => !!file && getContentKind(file) === 'audio', - submenu: [ - { - icon: TextAa, - label: 'Transcribe Audio', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - transcribeAudio.mutateAsync({ - entry_uuid: f.id, - model: 'whisper-base', - language: null - }) - ); - }, - keybind: '⌘⇧T' - } - ] - }, - { - type: 'submenu', - icon: FileText, - label: 'Document Processing', - condition: () => - !!file && - file.kind === 'File' && - ['pdf', 'doc', 'docx'].includes(file.extension || ''), - submenu: [ - { - icon: TextAa, - label: 'Extract Text (OCR)', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - extractText.mutateAsync({ - entry_uuid: f.id, - languages: null, - force: false - }) - ); - }, - keybind: '⌘⇧T' - }, - { - icon: Crop, - label: 'Regenerate Thumbnail', - onClick: async () => { - const targets = getTargetFiles(); - await forEachTarget(targets, (f) => - regenerateThumbnail.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: true - }) - ); - } - } - ] - }, - // Batch operations submenu - { - type: 'submenu', - icon: Stack, - label: `Process ${selectedFiles.length} Items`, - condition: () => selected && selectedFiles.length > 1, - submenu: [ - { - icon: Crop, - label: 'Regenerate All Thumbnails', - onClick: async () => { - await forEachTarget(selectedFiles, (f) => - regenerateThumbnail.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: true - }) - ); - } - }, - { - icon: Sparkle, - label: 'Generate Blurhashes', - onClick: async () => { - await forEachTarget(selectedFiles, (f) => - regenerateThumbnail.mutateAsync({ - entry_uuid: f.id, - variants: null, - force: false - }) - ); - }, - keybind: '⌘⇧B' - }, - { - icon: TextAa, - label: 'Extract Text (OCR)', - onClick: async () => { - await forEachTarget(selectedFiles, (f) => - extractText.mutateAsync({ - entry_uuid: f.id, - languages: null, - force: false - }) - ); - } - } - ] - }, { icon: TagIconComponent, label: diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index 460d94c82..c9265b86b 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -1281,26 +1281,6 @@ export type EventSeverity = */ export type ExportStats = { entries: number; content_identities: number; user_metadata: number; tags: number; media_data: number }; -export type ExtractTextInput = { -/** - * UUID of the entry to extract text from - */ -entry_uuid: string; -/** - * Languages to use for OCR (e.g., ["eng", "spa"]) - */ -languages: string[] | null; -/** - * Force re-extraction even if text exists - */ -force: boolean }; - -export type ExtractTextOutput = { -/** - * Job ID for tracking OCR progress - */ -job_id: string }; - /** * Represents a file within the Spacedrive VDFS. * @@ -1580,76 +1560,6 @@ export type FrontendReads = { frontend: string; reads: number }; */ export type FsRawEventKind = { Create: { path: string } } | { Modify: { path: string } } | { Remove: { path: string } } | { Rename: { from: string; to: string } }; -/** - * Generate proxy for a single video file - */ -export type GenerateProxyInput = { -/** - * UUID of the entry to generate proxy for - */ -entry_uuid: string; -/** - * Proxy resolution (scrubbing, ultra_low, quick, editing) - */ -resolution: string | null; -/** - * Force regeneration even if proxy exists - */ -force: boolean; -/** - * Use hardware acceleration if available - */ -use_hardware_accel: boolean | null }; - -export type GenerateProxyOutput = { -/** - * Number of proxies generated - */ -generated_count: number; -/** - * Variant names that were generated - */ -variants: string[]; -/** - * Total encoding time in seconds - */ -encoding_time_secs: number }; - -export type GenerateSplatInput = { entry_uuid: string; model_path: string | null }; - -export type GenerateSplatOutput = { -/** - * Job ID for tracking splat generation progress - */ -job_id: string }; - -/** - * Generate thumbstrip for a single video file - */ -export type GenerateThumbstripInput = { -/** - * UUID of the entry to generate thumbstrip for - */ -entry_uuid: string; -/** - * Optional variant names (defaults to thumbstrip_preview) - */ -variants: string[] | null; -/** - * Force regeneration even if thumbstrip exists - */ -force: boolean }; - -export type GenerateThumbstripOutput = { -/** - * Number of thumbstrips generated - */ -generated_count: number; -/** - * Variant names that were generated - */ -variants: string[] }; - /** * Generic progress information that all job types can convert into */ @@ -3553,22 +3463,6 @@ volumes: VolumeRedundancySummary[]; */ library_totals: LibraryRedundancyTotals }; -export type RegenerateThumbnailInput = { -/** - * UUID of the entry to regenerate thumbnails for - */ -entry_uuid: string; -/** - * Optional variant names (defaults to grid@1x, grid@2x, detail@1x) - */ -variants: string[] | null; -/** - * Force regeneration even if thumbnails exist - */ -force: boolean }; - -export type RegenerateThumbnailOutput = { generated_count: number; variants: string[] }; - /** * State of a job running on a remote device */ @@ -4563,8 +4457,6 @@ export type ThumbSource = { id: string; cache_path: string; */ tile_size: number }; -export type ThumbnailInput = { paths: string[]; size: number; quality: number }; - /** * Thumbnail generation policy */ @@ -4638,14 +4530,6 @@ volume_uuid: string | null; */ whole_volume: boolean; job_id: string | null }; -export type TranscribeAudioInput = { entry_uuid: string; model: string | null; language: string | null }; - -export type TranscribeAudioOutput = { -/** - * Job ID for tracking transcription progress - */ -job_id: string }; - /** * What to untag — uses entry UUIDs (matching the File.id exposed to frontend) */ @@ -5386,13 +5270,6 @@ export type LibraryAction = | { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput } | { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput } | { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput } - | { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput } - | { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput } - | { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput } - | { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput } - | { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt } - | { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput } - | { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput } | { type: 'sources.create'; input: CreateSourceInput; output: CreateSourceOutput } | { type: 'sources.delete'; input: DeleteSourceInput; output: DeleteSourceOutput } | { type: 'sources.sync'; input: SyncSourceInput; output: JobReceipt } @@ -5541,13 +5418,6 @@ export const WIRE_METHODS = { 'locations.import': 'action:locations.import.input', 'locations.remove': 'action:locations.remove.input', 'locations.update': 'action:locations.update.input', - 'media.ocr.extract': 'action:media.ocr.extract.input', - 'media.proxy.generate': 'action:media.proxy.generate.input', - 'media.speech.transcribe': 'action:media.speech.transcribe.input', - 'media.splat.generate': 'action:media.splat.generate.input', - 'media.thumbnail': 'action:media.thumbnail.input', - 'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input', - 'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input', 'sources.create': 'action:sources.create.input', 'sources.delete': 'action:sources.delete.input', 'sources.sync': 'action:sources.sync.input',