Delete the media pipeline that nothing could reach

Deleting locations.triggerJob cut the last live dispatcher for every media
job. What was left was six jobs, six processors, six registered actions and
an 853-line SidecarManager, all keyed on entry rows nothing writes, reachable
from nothing. The UI buttons that fired them were the only thing keeping the
registrations honest, and they were firing into a pipeline that had no input.

The hot tier is unaffected. ThumbService over sources/<id>/thumbs.pvcache is
the live thumbnail path, it is source-scoped already, and it shares no code
with any of this.

Two things survive because they outlive their writers. SidecarKind, variant,
format and status move to domain::sidecar: they are the vocabulary a derived
artifact is addressed by, they are what SdPath::Sidecar parses, and
content_sidecar will be keyed on the same three. And File.sidecars keeps its
shape, empty until something fills it, so the subtitle and thumbstrip lookups
in the client keep compiling.

The EXIF and ffmpeg extraction went with the rest. It read into
image_media_data and video_media_data, which are entity rows on their way
out, and its field mapping is a straight match for the facets declared in
df965b851. When the facet writer gets built it wants rewriting against those
columns rather than resurrecting; git show df965b851~1:core/src/ops/media/
metadata_extractor.rs has the EXIF handling worth copying.

sd-server's sidecar route now builds its path through sd-sidecar-path, the
same way the tauri one already did. Both still serve from <library>/sidecars,
both are equally empty, and L5 re-roots them together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jamie PineandClaude Opus 5 committed 2026-09-09 20:30:06 -07:00
1 parent 7d41e07ab9
commit ba8efcdda5
72 files changed
+30 -10735

No files matched your search

Generated
+1
View File
@@ -11746,6 +11746,7 @@ dependencies = [
"mime_guess",
"rust-embed",
"sd-core",
"sd-sidecar-path",
"secstr",
"serde",
"serde_json",
+1
View File
@@ -13,6 +13,7 @@ ai = ["sd-core/ai"]
[dependencies]
# Spacedrive core
sd-sidecar-path = { path = "../../crates/sidecar-path" }
sd-core = { path = "../../core" }
# HTTP server
+12 -15
View File
@@ -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::<uuid::Uuid>() 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
-13
View File
@@ -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<VolumeManager>,
pub key_manager: Arc<KeyManager>,
// This is wrapped in an RwLock to allow it to be set after initialization
pub sidecar_manager: Arc<RwLock<Option<Arc<SidecarManager>>>>,
pub action_manager: Arc<RwLock<Option<Arc<ActionManager>>>>,
pub networking: Arc<RwLock<Option<Arc<NetworkingService>>>>,
#[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<Arc<RwLock<crate::infra::extension::PluginManager>>> {
self.plugin_manager.read().await.clone()
}
/// Helper method to get the sidecar manager
pub async fn get_sidecar_manager(&self) -> Option<Arc<SidecarManager>> {
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<SidecarManager>) {
*self.sidecar_manager.write().await = Some(sidecar_manager);
}
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
-68
View File
@@ -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<Uuid>) -> 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<Uuid>,
) -> 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 {
+2
View File
@@ -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,
};
@@ -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;
-30
View File
@@ -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))
+1 -15
View File
@@ -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 {
-21
View File
@@ -537,27 +537,6 @@ impl Library {
Ok(true)
}
/// Start thumbnail generation job
pub async fn generate_thumbnails(
&self,
entry_ids: Option<Vec<Uuid>>,
) -> Result<crate::infra::job::handle::JobHandle> {
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<F>(&self, f: F) -> Result<()>
where
+3 -3
View File
@@ -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<SdPath, PathResolutionError> {
// TODO: Implement full sidecar resolution
// For now, return not found to avoid breaking existing code
-1
View File
@@ -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;
-122
View File
@@ -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<Uuid>,
pub path: PathBuf,
pub kind: EntryKind,
pub size: u64,
pub content_id: Option<i32>,
pub mime_type: Option<String>,
}
/// 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<String>,
}
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<ProcessorConfig>,
}
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
}),
},
],
}
}
}
-15
View File
@@ -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;
-15
View File
@@ -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
-88
View File
@@ -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<String, BlurhashError> {
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<String, BlurhashError> {
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());
}
}
-437
View File
@@ -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<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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<image_media_data::ActiveModel, Box<dyn std::error::Error + Send + Sync>> {
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<String>,
) -> Result<image_media_data::ActiveModel, Box<dyn std::error::Error + Send + Sync>> {
// Extract EXIF metadata
let exif = sd_media_metadata::exif::ExifMetadata::from_path(path)
.await?
.ok_or("No EXIF data found")?;
// Convert MediaDate to DateTime<Utc>
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<video_media_data::ActiveModel, Box<dyn std::error::Error + Send + Sync>> {
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<String>,
) -> Result<video_media_data::ActiveModel, Box<dyn std::error::Error + Send + Sync>> {
// 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::<Vec<_>>()
);
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<audio_media_data::ActiveModel, Box<dyn std::error::Error + Send + Sync>> {
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()),
})
}
-37
View File
@@ -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};
-105
View File
@@ -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<Vec<String>>,
/// 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<Self, String> {
Ok(Self::new(input))
}
async fn execute(
self,
library: Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
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");
-369
View File
@@ -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<Uuid>,
/// Single entry UUID to process (for UI-triggered single file)
pub entry_uuid: Option<Uuid>,
/// Languages for OCR (e.g., ["eng", "spa"])
pub languages: Vec<String>,
/// 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<String>)>, // (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<Self::Output> {
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<OcrJobOutput> 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<OcrJob> for Box<dyn DynJob> {
fn from(job: OcrJob) -> Self {
Box::new(job)
}
}
-67
View File
@@ -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<String> {
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"
}
}
-110
View File
@@ -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<Library>,
languages: Vec<String>,
min_confidence: f32,
}
impl OcrProcessor {
pub fn new(library: Arc<Library>) -> Self {
Self {
library,
languages: vec!["eng".to_string()],
min_confidence: 0.6,
}
}
pub fn with_languages(mut self, languages: Vec<String>) -> 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<Self> {
// 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<ProcessorResult> {
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"
}
}
-180
View File
@@ -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<String>,
/// Force regeneration even if proxy exists
pub force: bool,
/// Use hardware acceleration if available
pub use_hardware_accel: Option<bool>,
}
#[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<String>,
/// 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<Self, String> {
Ok(Self::new(input))
}
async fn execute(
self,
library: Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
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");
-192
View File
@@ -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<u32> {
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<ProxyVariantConfig> {
vec![Self::scrubbing()] // Only scrubbing proxy by default
}
/// All standard variants
pub fn all() -> Vec<ProxyVariantConfig> {
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<ProxyVariantConfig>,
/// 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);
}
}
-45
View File
@@ -1,45 +0,0 @@
//! Proxy generation error types
use thiserror::Error;
pub type ProxyResult<T> = Result<T, ProxyError>;
#[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<String>) -> Self {
Self::Other(msg.into())
}
pub fn encoding_failed(msg: impl Into<String>) -> Self {
Self::EncodingFailed(msg.into())
}
}
-291
View File
@@ -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<HardwareAccel>,
}
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<Path>,
output: impl AsRef<Path>,
) -> ProxyResult<ProxyInfo> {
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<Vec<OsString>> {
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<f32> {
// 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::<f32>().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
}
}
-136
View File
@@ -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<HardwareAccel> {
// 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);
}
}
-248
View File
@@ -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<ProxyJobOutput> 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<Self::Output> {
// 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(())
}
}
-159
View File
@@ -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<Library>,
content_uuid: &Uuid,
video_path: &Path,
variants: &[ProxyVariantConfig],
use_hardware_accel: bool,
preset: &str,
regenerate: bool,
) -> ProxyResult<usize> {
// 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)
}
-152
View File
@@ -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<Library>,
pub variant: ProxyVariantConfig,
enabled: bool,
max_file_size_gb: u64,
use_hardware_accel: bool,
preset: String,
}
impl ProxyProcessor {
pub fn new(library: Arc<Library>) -> 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<Self> {
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<ProcessorResult> {
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"
}
}
-43
View File
@@ -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<String>)>,
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()
}
}
-106
View File
@@ -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<String>, // whisper model (tiny, base, small, medium, large)
pub language: Option<String>, // 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<Self, String> {
Ok(Self::new(input))
}
async fn execute(
self,
library: Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
#[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");
-371
View File
@@ -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<Uuid>,
/// Single entry UUID to process (for UI-triggered single file)
pub entry_uuid: Option<Uuid>,
/// Whisper model to use (tiny, base, small, medium, large)
pub model: String,
/// Language code (None = auto-detect)
pub language: Option<String>,
/// 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<String>)>,
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<Self::Output> {
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<SpeechToTextJobOutput> 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<SpeechToTextJob> for Box<dyn DynJob> {
fn from(job: SpeechToTextJob) -> Self {
Box::new(job)
}
}
-180
View File
@@ -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<String> {
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<Vec<f32>> {
// 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<f32> {
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)
}
-172
View File
@@ -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<Library>,
model: String,
language: Option<String>,
}
impl SpeechToTextProcessor {
pub fn new(library: Arc<Library>) -> 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<String>) -> Self {
self.language = language;
self
}
pub fn with_settings(mut self, settings: &Value) -> Result<Self> {
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<ProcessorResult> {
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"
}
}
-97
View File
@@ -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<String>, // 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<Self, String> {
Ok(Self::new(input))
}
async fn execute(
self,
library: Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
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");
-346
View File
@@ -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<Uuid>,
/// Single entry UUID to process (for UI-triggered single file)
pub entry_uuid: Option<Uuid>,
/// Path to SHARP model checkpoint (None = auto-download)
pub model_path: Option<String>,
/// 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<String>)>,
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<Self::Output> {
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<GaussianSplatJobOutput> 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<GaussianSplatJob> for Box<dyn DynJob> {
fn from(job: GaussianSplatJob) -> Self {
Box::new(job)
}
}
-99
View File
@@ -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<PathBuf> {
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<bool> {
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"
)
}
-167
View File
@@ -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<Library>,
model_path: Option<String>,
}
impl GaussianSplatProcessor {
pub fn new(library: Arc<Library>) -> 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<Self> {
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<ProcessorResult> {
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"
}
}
-252
View File
@@ -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<PathBuf>,
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<Self, String> {
Ok(ThumbnailAction::new(input))
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
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<Vec<String>>,
/// 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<String>,
}
#[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<Self, String> {
Ok(Self::new(input))
}
async fn execute(
self,
library: Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
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<String> = 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<String> {
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())
}
-149
View File
@@ -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<String>, 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<ThumbnailVariantConfig> {
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<ThumbnailVariantConfig> {
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<ThumbnailVariantConfig> {
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"
);
}
}
-68
View File
@@ -1,68 +0,0 @@
//! Thumbnail generation errors
use thiserror::Error;
pub type ThumbnailResult<T> = Result<T, ThumbnailError>;
#[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<String>) -> Self {
Self::VideoProcessing(msg.into())
}
pub fn unsupported_format(format: impl Into<String>) -> Self {
Self::UnsupportedFormat(format.into())
}
pub fn database(msg: impl Into<String>) -> Self {
Self::Database(msg.into())
}
pub fn other(msg: impl Into<String>) -> Self {
Self::Other(msg.into())
}
}
impl From<ThumbnailError> for crate::infra::job::error::JobError {
fn from(err: ThumbnailError) -> Self {
crate::infra::job::error::JobError::execution(err.to_string())
}
}
-482
View File
@@ -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<Self> {
// 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<ThumbnailInfo> {
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<ThumbnailInfo> {
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, ThumbnailError>(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<ThumbnailInfo> {
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, ThumbnailError>(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<ThumbnailInfo> {
#[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<ThumbnailInfo> {
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, ThumbnailError>(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());
}
}
-792
View File
@@ -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<ThumbnailVariantConfig>,
/// 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<ThumbnailVariantConfig>) -> 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<String>,
pub estimated_time_remaining: Option<Duration>,
}
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<Vec<Uuid>>,
/// Job configuration
pub config: ThumbnailJobConfig,
// Resumable state
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<ThumbnailState>,
}
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<String>,
}
impl From<ThumbnailOutput> 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<Self::Output> {
// 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<Uuid>, 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<Vec<Uuid>>,
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<uuid::Uuid> =
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<u64> {
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<tempfile::NamedTempFile>) =
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)
}
}
-227
View File
@@ -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<Library>,
content_uuid: &Uuid,
source_path: &Path,
mime_type: &str,
) -> ThumbnailResult<usize> {
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<crate::service::sidecar_manager::SidecarManager>,
library: &Library,
content_uuid: &Uuid,
variants: &[ThumbnailVariantConfig],
) -> Option<String> {
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()
}
-150
View File
@@ -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<Library>,
pub variants: Vec<ThumbnailVariantConfig>,
regenerate: bool,
}
impl ThumbnailProcessor {
pub fn new(library: Arc<Library>) -> Self {
Self {
library,
variants: ThumbnailVariants::defaults(),
regenerate: false,
}
}
pub fn with_variants(mut self, variants: Vec<ThumbnailVariantConfig>) -> 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<Self> {
// 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<ProcessorResult> {
// 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"
}
}
-147
View File
@@ -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);
}
}
-124
View File
@@ -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<String>,
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<ThumbnailEntry>,
pub batches: Vec<Vec<ThumbnailEntry>>,
pub current_batch_index: usize,
pub errors: Vec<String>,
#[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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct ThumbnailStateHelper {
phase: ThumbnailPhase,
stats: ThumbnailStats,
pending_entries: Vec<ThumbnailEntry>,
batches: Vec<Vec<ThumbnailEntry>>,
current_batch_index: usize,
errors: Vec<String>,
}
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
})
}
}
-77
View File
@@ -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());
}
}
-183
View File
@@ -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<Vec<String>>,
/// 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<String>,
}
#[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<Self, String> {
Ok(Self::new(input))
}
async fn execute(
self,
library: Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
#[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<String> = 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");
-125
View File
@@ -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<String>,
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<ThumbstripVariantConfig> {
vec![Self::preview()]
}
/// All standard variants
pub fn all() -> Vec<ThumbstripVariantConfig> {
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<ThumbstripVariantConfig>,
/// 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);
}
}
-60
View File
@@ -1,60 +0,0 @@
//! Thumbstrip generation error types
use thiserror::Error;
pub type ThumbstripResult<T> = Result<T, ThumbstripError>;
#[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<String>) -> Self {
Self::Other(msg.into())
}
pub fn video_processing(msg: impl Into<String>) -> Self {
Self::VideoProcessing(msg.into())
}
pub fn unsupported_format(format: impl Into<String>) -> Self {
Self::UnsupportedFormat(format.into())
}
}
// Conversion from sd-ffmpeg errors
#[cfg(feature = "ffmpeg")]
impl From<sd_ffmpeg::Error> for ThumbstripError {
fn from(err: sd_ffmpeg::Error) -> Self {
Self::FFmpeg(err.to_string())
}
}
-273
View File
@@ -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<u8>,
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<Path> + Send,
output_path: impl AsRef<Path> + Send,
) -> ThumbstripResult<ThumbstripInfo> {
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<Path> + Send,
_output_path: impl AsRef<Path> + Send,
) -> ThumbstripResult<ThumbstripInfo> {
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<f64> {
// 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<DynamicImage> {
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<Vec<u8>> {
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);
}
}
-278
View File
@@ -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<Vec<Uuid>>,
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<Uuid>, 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<String>,
}
// 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::<EntryWithMimeType>()
.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<ThumbstripJobOutput> 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<Self::Output> {
// 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(())
}
}
-218
View File
@@ -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<Library>,
content_uuid: &Uuid,
video_path: &Path,
variants: &[ThumbstripVariantConfig],
regenerate: bool,
) -> ThumbstripResult<usize> {
// 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<crate::library::Library>,
_content_uuid: &uuid::Uuid,
_video_path: &std::path::Path,
_variants: &[()], // Can't reference ThumbstripVariantConfig without feature
_regenerate: bool,
) -> Result<usize, String> {
Err("Thumbstrip generation requires FFmpeg feature to be enabled".to_string())
}
-163
View File
@@ -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<Library>,
pub variants: Vec<ThumbstripVariantConfig>,
regenerate: bool,
}
impl ThumbstripProcessor {
pub fn new(library: Arc<Library>) -> Self {
Self {
library,
variants: ThumbstripVariants::defaults(),
regenerate: false,
}
}
pub fn with_variants(mut self, variants: Vec<ThumbstripVariantConfig>) -> 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<Self> {
// 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<ProcessorResult> {
// 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"
}
}
-41
View File
@@ -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<String>)>,
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()
}
}
-2
View File
@@ -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;
-5
View File
@@ -1,5 +0,0 @@
pub mod path;
pub mod types;
pub use path::{SidecarPath, SidecarPathBuilder};
pub use types::{SidecarFormat, SidecarKind, SidecarStatus, SidecarVariant};
-144
View File
@@ -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<Path>) -> 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")
);
}
}
-246
View File
@@ -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<usize>,
}
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<SidecarSyncOutput> 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<Self::Output> {
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(),
})
}
}
-6
View File
@@ -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<Arc<VolumeMonitorService>>,
/// Statistics listener service - recalculates library statistics
pub statistics_listener: Option<Arc<StatisticsListenerService>>,
/// Sidecar manager
pub sidecar_manager: Arc<SidecarManager>,
/// Key manager
pub key_manager: Arc<KeyManager>,
/// 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,
}
-853
View File
@@ -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<CoreContext>,
/// Path builders per library
path_builders: RwLock<HashMap<Uuid, Arc<SidecarPathBuilder>>>,
/// Active generation tasks
active_tasks: Mutex<HashMap<(Uuid, String, String), tokio::task::JoinHandle<()>>>,
}
impl SidecarManager {
/// Create a new sidecar manager
pub fn new(context: Arc<CoreContext>) -> 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<Arc<SidecarPathBuilder>> {
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<SidecarPath> {
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<bool> {
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<HashMap<Uuid, HashMap<String, SidecarPresence>>> {
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<Uuid, HashMap<String, SidecarPresence>> = 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<SidecarResult> {
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<String>,
) -> 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<String>,
) -> 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<String>,
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<u64>,
checksum: Option<String>,
) -> 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<PathBuf>,
/// Current status
pub status: SidecarStatus,
/// Remote devices that have this sidecar
pub devices: Vec<Uuid>,
}
@@ -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<Library>,
networking: Arc<NetworkingService>,
sidecar_manager: Arc<SidecarManager>,
}
impl SidecarSyncCoordinator {
pub fn new(
library: Arc<Library>,
networking: Arc<NetworkingService>,
sidecar_manager: Arc<SidecarManager>,
) -> 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<Vec<MissingSidecar>> {
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<String> = 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<HashMap<Uuid, Vec<SidecarSource>>> {
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<Uuid> = 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<Uuid> = online_devices
.iter()
.map(|d| d.device_id)
.collect();
// Build map of content_uuid -> sources, filtering to online devices
let mut sources_map: HashMap<Uuid, Vec<SidecarSource>> = 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<MissingSidecar>,
sources: HashMap<Uuid, Vec<SidecarSource>>,
) -> Vec<SidecarTransferPlan> {
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()
}
}
-59
View File
@@ -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<Vec<SidecarKind>>,
/// Filter by specific content UUIDs
pub content_uuids: Option<Vec<Uuid>>,
/// Maximum number of sidecars to sync in this batch
pub max_count: Option<usize>,
/// Cursor for pagination (format: "content_uuid|kind|variant")
pub cursor: Option<String>,
}
/// 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<String>,
}
/// Information about a device that has a sidecar
#[derive(Debug, Clone)]
pub struct SidecarSource {
pub device_uuid: Uuid,
pub last_seen_at: DateTime<Utc>,
pub verified_checksum: Option<String>,
}
/// A planned sidecar transfer with source device selected
#[derive(Debug, Clone)]
pub struct SidecarTransferPlan {
pub sidecar: MissingSidecar,
pub source_device: Uuid,
}
-7
View File
@@ -1,7 +0,0 @@
mod coordinator;
mod filters;
pub use coordinator::SidecarSyncCoordinator;
pub use filters::{
MissingSidecar, SidecarSource, SidecarSyncFilters, SidecarSyncMode, SidecarTransferPlan,
};
@@ -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 (
<div className="flex items-center gap-1.5">
{/* Quick Preview Button — spacebar isn't available on touch devices */}
@@ -335,17 +237,6 @@ function FileQuickActions({file}: {file: File}) {
</button>
)}
{/* Jobs Button */}
{showJobsButton && (
<button
type="button"
onClick={(e) => jobsMenu.show(e)}
className="border-sidebar-line/30 bg-sidebar-box/20 text-sidebar-inkDull hover:bg-sidebar-box/30 hover:text-sidebar-ink flex size-7 items-center justify-center rounded-full border transition-all active:scale-95"
title="Processing Jobs"
>
<Sparkle size={14} weight="bold" />
</button>
)}
</div>
);
}
@@ -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}) {
)}
</Section>
{/* AI Processing */}
{/* Extracted text, when something has produced it */}
{(isImage || isVideo || isAudio) && (
<Section title="AI Processing" icon={Sparkle}>
<div className="flex flex-col gap-2">
{/* OCR for images */}
{isImage && (
<button
onClick={() => {
console.log(
'Extract text clicked for file:',
file.id
);
extractText.mutate(
{
entry_uuid: file.id,
languages: ['eng'],
force: false
},
{
onSuccess: (data) => {
console.log(
'OCR success:',
data
);
},
onError: (error) => {
console.error(
'OCR error:',
error
);
}
}
);
}}
disabled={extractText.isPending}
className={clsx(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'bg-app-box hover:bg-app-hover border-app-line border',
extractText.isPending &&
'cursor-not-allowed opacity-50'
)}
>
<TextAa size={4} weight="bold" />
<span>
{extractText.isPending
? 'Extracting...'
: 'Extract Text (OCR)'}
</span>
</button>
)}
{/* Gaussian Splat for images */}
{isImage && (
<button
onClick={() => {
console.log(
'Generate splat clicked for file:',
file.id
);
generateSplat.mutate(
{
entry_uuid: file.id,
model_path: null
},
{
onSuccess: (data) => {
console.log(
'Splat generation success:',
data
);
},
onError: (error) => {
console.error(
'Splat generation error:',
error
);
}
}
);
}}
disabled={generateSplat.isPending}
className={clsx(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'bg-app-box hover:bg-app-hover border-app-line border',
generateSplat.isPending &&
'cursor-not-allowed opacity-50'
)}
>
<Cube size={4} weight="bold" />
<span>
{generateSplat.isPending
? 'Generating...'
: 'Generate 3D Splat'}
</span>
</button>
)}
{/* Speech-to-text for audio/video */}
{(isVideo || isAudio) && (
<button
onClick={() => {
console.log(
'Transcribe clicked for file:',
file.id
);
transcribeAudio.mutate(
{
entry_uuid: file.id,
model: 'base',
language: null
},
{
onSuccess: (data) => {
console.log(
'Transcription success:',
data
);
},
onError: (error) => {
console.error(
'Transcription error:',
error
);
// Check if it's a feature-disabled error
const errorMessage =
error instanceof Error
? error.message
: String(error);
if (
errorMessage.includes(
'feature is not enabled'
) ||
errorMessage.includes(
'--features ffmpeg'
)
) {
toast.error({
title: 'Feature Not Available',
body: 'Speech-to-text requires FFmpeg. Please rebuild the daemon with --features ffmpeg,heif or use `cargo daemon`'
});
} else {
toast.error({
title: 'Transcription Failed',
body: errorMessage
});
}
}
}
);
}}
disabled={
transcribeAudio.isPending ||
isSpeechJobRunning
}
className={clsx(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'bg-app-box hover:bg-app-hover border-app-line border',
(transcribeAudio.isPending ||
isSpeechJobRunning) &&
'cursor-not-allowed opacity-50'
)}
>
<Microphone size={4} weight="bold" />
<span>
{transcribeAudio.isPending ||
isSpeechJobRunning
? 'Transcribing...'
: 'Generate Subtitles'}
</span>
</button>
)}
{/* Regenerate thumbnails */}
{(isImage || isVideo) && (
<button
onClick={() => {
console.log(
'Regenerate thumbnails clicked for file:',
file.id
);
regenerateThumbnail.mutate(
{
entry_uuid: file.id,
variants: [
'grid@1x',
'grid@2x',
'detail@1x'
],
force: true
},
{
onSuccess: (data) => {
console.log(
'Thumbnail regeneration success:',
data
);
},
onError: (error) => {
console.error(
'Thumbnail regeneration error:',
error
);
const errorMessage =
error instanceof Error
? error.message
: String(error);
if (
errorMessage.includes(
'feature is not enabled'
) ||
errorMessage.includes(
'--features ffmpeg'
) ||
errorMessage.includes(
'FFmpeg feature'
)
) {
toast.error({
title: 'Feature Not Available',
body: 'Video thumbnail generation requires FFmpeg. Please rebuild the daemon with --features ffmpeg or use `cargo daemon`'
});
} else {
toast.error({
title: 'Thumbnail Generation Failed',
body: errorMessage
});
}
}
}
);
}}
disabled={regenerateThumbnail.isPending}
className={clsx(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'bg-app-box hover:bg-app-hover border-app-line border',
regenerateThumbnail.isPending &&
'cursor-not-allowed opacity-50'
)}
>
<ArrowsClockwise size={4} weight="bold" />
<span>
{regenerateThumbnail.isPending
? 'Generating...'
: 'Regenerate Thumbnails'}
</span>
</button>
)}
{/* Generate thumbstrip (for videos) */}
{isVideo && (
<button
onClick={() => {
console.log(
'Generate thumbstrip clicked for file:',
file.id
);
generateThumbstrip.mutate(
{
entry_uuid: file.id,
variants: [
'thumbstrip_preview',
'thumbstrip_detailed'
],
force: false
},
{
onSuccess: (data) => {
console.log(
'Thumbstrip generation success:',
data
);
},
onError: (error) => {
console.error(
'Thumbstrip generation error:',
error
);
const errorMessage =
error instanceof Error
? error.message
: String(error);
if (
errorMessage.includes(
'feature is not enabled'
) ||
errorMessage.includes(
'--features ffmpeg'
)
) {
toast.error({
title: 'Feature Not Available',
body: 'Thumbstrip generation requires FFmpeg. Please rebuild the daemon with --features ffmpeg,heif or use `cargo daemon`'
});
} else {
toast.error({
title: 'Thumbstrip Generation Failed',
body: errorMessage
});
}
}
}
);
}}
disabled={generateThumbstrip.isPending}
className={clsx(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'bg-app-box hover:bg-app-hover border-app-line border',
generateThumbstrip.isPending &&
'cursor-not-allowed opacity-50'
)}
>
<FilmStrip size={4} weight="bold" />
<span>
{generateThumbstrip.isPending
? 'Generating...'
: 'Generate Thumbstrip'}
</span>
</button>
)}
{/* Generate proxy (for videos) */}
{isVideo && (
<button
onClick={() => {
console.log(
'Generate proxy clicked for file:',
file.id
);
generateProxy.mutate(
{
entry_uuid: file.id,
resolution: 'scrubbing', // Fast scrubbing proxy
force: false,
use_hardware_accel: true
},
{
onSuccess: (data) => {
console.log(
'Proxy generation success:',
data
);
},
onError: (error) => {
console.error(
'Proxy generation error:',
error
);
}
}
);
}}
disabled={generateProxy.isPending}
className={clsx(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'bg-app-box hover:bg-app-hover border-app-line border',
generateProxy.isPending &&
'cursor-not-allowed opacity-50'
)}
>
<VideoCamera size={4} weight="bold" />
<span>
{generateProxy.isPending
? 'Encoding...'
: 'Generate Scrubbing Proxy'}
</span>
</button>
)}
{/* Show extracted text if available */}
{hasText && (
<div className="bg-app-box/40 border-app-line/50 mt-2 rounded-lg border p-3">
@@ -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": {
@@ -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:
-130
View File
@@ -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',