mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-10-30 04:49:32 -04:00
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:
1 parent
7d41e07ab9
commit
ba8efcdda5
72 files changed
+30
-10735
No files matched your search
Generated
+1
@@ -11746,6 +11746,7 @@ dependencies = [
|
||||
"mime_guess",
|
||||
"rust-embed",
|
||||
"sd-core",
|
||||
"sd-sidecar-path",
|
||||
"secstr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod path;
|
||||
pub mod types;
|
||||
|
||||
pub use path::{SidecarPath, SidecarPathBuilder};
|
||||
pub use types::{SidecarFormat, SidecarKind, SidecarStatus, SidecarVariant};
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user