Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
873b89d
fix(search): apply TagFilter in search.files query
slvnlrt Mar 17, 2026
a9dde16
feat(tags): implement tags.by_id, tags.ancestors, tags.children, file…
slvnlrt Mar 17, 2026
1bc19a3
fix(tags): prevent duplicate tag applications on the same file
slvnlrt Mar 17, 2026
b4415cd
fix(tags,ui): make tag view files navigable and wire Overview search …
slvnlrt Mar 18, 2026
a096965
feat(tags): render tag view using standard explorer with full File ob…
slvnlrt Mar 19, 2026
b414c20
feat(tags): add unapply/delete actions, fix tag sync and Inspector UX
slvnlrt Mar 19, 2026
6529043
refactor: extract shared useRefetchTagQueries hook
slvnlrt Mar 19, 2026
c61d036
fix(core): use current device slug instead of \"unknown-device\" fall…
slvnlrt Mar 20, 2026
ff449d0
fix(media): replace broken useJobDispatch with direct mutations
slvnlrt Mar 20, 2026
d59ef2a
fix(tags): address CodeRabbit review findings on tag system
slvnlrt Mar 24, 2026
c02bfa7
fix(migration): keep newest row (MAX id) when deduplicating tag appli…
slvnlrt Mar 24, 2026
16daa27
revert(tags): restore independent tagModeActive state
slvnlrt Mar 24, 2026
a3edd3c
fix(tags): address second round of CodeRabbit review
slvnlrt Mar 24, 2026
4022524
fix(tags): skip rows with undecodable required fields instead of fabr…
slvnlrt Mar 24, 2026
93ccb62
fix(tags): remove broken optimistic update and alert() dialog
slvnlrt Mar 24, 2026
c0efb33
fix(tags): emit file events on tag delete, refetch files.by_id for in…
slvnlrt Mar 24, 2026
0b75b82
fix(tags): add extension to root-level file paths, validate entry UUIDs
slvnlrt Mar 24, 2026
ed9890a
fix(tags): pre-index content rows to avoid O(n²) tag merge, require e…
slvnlrt Mar 24, 2026
4ad8df6
fix(tags): secure FTS5 escaping, batch entry lookups for performance
slvnlrt Mar 25, 2026
637a941
chore: remove dead useJobDispatch hook
slvnlrt Mar 25, 2026
9295f85
fix(tags): remove redundant inline sea_orm imports
slvnlrt Mar 25, 2026
dea06b9
fix(tags): validate entry UUIDs in create action before applying
slvnlrt Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();

// Remove duplicate (user_metadata_id, tag_id) pairs, keeping the newest (MAX id)
// which has the most recent version/updated_at/device_uuid state.
// This must run before creating the unique index.
db.execute_unprepared(
"DELETE FROM user_metadata_tag \
WHERE id NOT IN ( \
SELECT MAX(id) FROM user_metadata_tag \
GROUP BY user_metadata_id, tag_id \
)",
)
.await?;

// Add unique index so the pair can never be duplicated again.
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_umt_unique_pair")
.table(Alias::new("user_metadata_tag"))
.col(Alias::new("user_metadata_id"))
.col(Alias::new("tag_id"))
.unique()
.to_owned(),
)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.name("idx_umt_unique_pair")
.table(Alias::new("user_metadata_tag"))
.to_owned(),
)
.await?;

Ok(())
}
}
2 changes: 2 additions & 0 deletions core/src/infra/db/migration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mod m20260104_000001_replace_device_id_with_volume_id;
mod m20260105_000001_add_volume_id_to_locations;
mod m20260114_000001_fix_search_index_include_directories;
mod m20260123_000001_remove_legacy_sync_columns;
mod m20260125_000001_unique_user_metadata_tag;

pub struct Migrator;

Expand Down Expand Up @@ -81,6 +82,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260105_000001_add_volume_id_to_locations::Migration),
Box::new(m20260114_000001_fix_search_index_include_directories::Migration),
Box::new(m20260123_000001_remove_legacy_sync_columns::Migration),
Box::new(m20260125_000001_unique_user_metadata_tag::Migration),
]
}
}
77 changes: 42 additions & 35 deletions core/src/ops/media/thumbnail/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ use crate::{
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<std::path::PathBuf>,
pub paths: Vec<PathBuf>,
pub size: u32,
pub quality: u8,
}
Expand All @@ -25,13 +26,11 @@ pub struct ThumbnailAction {
}

impl ThumbnailAction {
/// Create a new thumbnail generation action
pub fn new(input: ThumbnailInput) -> Self {
Self { input }
}
}

// Implement the unified LibraryAction (replaces ActionHandler)
impl LibraryAction for ThumbnailAction {
type Input = ThumbnailInput;
type Output = crate::infra::job::handle::JobReceipt;
Expand All @@ -45,19 +44,13 @@ impl LibraryAction for ThumbnailAction {
library: std::sync::Arc<crate::library::Library>,
_context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
// Create thumbnail job config from size
let config = ThumbnailJobConfig::from_sizes(vec![self.input.size]);

// Create job instance directly
let job = ThumbnailJob::new(config);

// Dispatch job and return handle directly
let job_handle = library
.jobs()
.dispatch(job)
.await
.map_err(ActionError::Job)?;

Ok(job_handle.into())
}

Expand All @@ -66,7 +59,6 @@ impl LibraryAction for ThumbnailAction {
}
}

// Register action
crate::register_library_action!(ThumbnailAction, "media.thumbnail");

// ============================================================================
Expand All @@ -85,9 +77,7 @@ pub struct RegenerateThumbnailInput {

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Type)]
pub struct RegenerateThumbnailOutput {
/// Number of thumbnails generated
pub generated_count: usize,
/// Variant names that were generated
pub variants: Vec<String>,
}

Expand Down Expand Up @@ -133,31 +123,29 @@ impl LibraryAction for RegenerateThumbnailAction {
.await
.map_err(|e| ActionError::Internal(format!("Failed to resolve path: {}", e)))?;

// Get MIME type
// Get MIME type: try content_identity first, fall back to extension
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 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)
entities::mime_type::Entity::find_by_id(mime_id)
.one(db)
.await
{
Some(mime.mime_type)
} else {
None
}
.ok()
.flatten()
.map(|m| m.mime_type)
.or_else(|| mime_from_extension(&path))
} else {
None
mime_from_extension(&path)
}
} else {
None
mime_from_extension(&path)
}
} else {
return Err(ActionError::Internal(
"Entry has no content identity".to_string(),
));
mime_from_extension(&path)
};

// Build processor entry
Expand All @@ -178,28 +166,23 @@ impl LibraryAction for RegenerateThumbnailAction {
mime_type: mime_type.clone(),
};

// Create thumbnail processor with custom settings
// Create thumbnail processor
let mut processor =
ThumbnailProcessor::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,
});
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 thumbnails".to_string(),
));
}

// Process the file - will fail with proper error if video without ffmpeg
let result = processor
.process(db, &proc_entry)
.await
Expand All @@ -211,7 +194,6 @@ impl LibraryAction for RegenerateThumbnailAction {
));
}

// Get variant names
let variant_names: Vec<String> = processor
.variants
.iter()
Expand All @@ -230,3 +212,28 @@ impl LibraryAction for RegenerateThumbnailAction {
}

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())
}
103 changes: 48 additions & 55 deletions core/src/ops/metadata/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ use crate::ops::tags::manager::TagManager;
use anyhow::Result;
use chrono::Utc;
use sea_orm::DatabaseConnection;
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, NotSet, QueryFilter, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, NotSet, QueryFilter, Set,
sea_query::{Expr, OnConflict},
};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
Expand Down Expand Up @@ -246,75 +249,65 @@ impl UserMetadataManager {
let uuid_to_db_id: HashMap<Uuid, i32> =
tag_models.into_iter().map(|m| (m.uuid, m.id)).collect();

// Insert tag applications
// Atomic upsert: INSERT ... ON CONFLICT(user_metadata_id, tag_id) DO UPDATE
for app in tag_applications {
if let Some(&tag_db_id) = uuid_to_db_id.get(&app.tag_id) {
let tag_application = user_metadata_tag::ActiveModel {
let instance_attributes_value = if app.instance_attributes.is_empty() {
None
} else {
Some(serde_json::to_value(&app.instance_attributes).unwrap().into())
};

let now = Utc::now();
let new_model = user_metadata_tag::ActiveModel {
id: NotSet,
user_metadata_id: Set(metadata_db_id),
tag_id: Set(tag_db_id),
applied_context: Set(app.applied_context.clone()),
applied_variant: Set(app.applied_variant.clone()),
confidence: Set(app.confidence),
source: Set(app.source.as_str().to_string()),
instance_attributes: Set(if app.instance_attributes.is_empty() {
None
} else {
Some(
serde_json::to_value(&app.instance_attributes)
.unwrap()
.into(),
)
}),
instance_attributes: Set(instance_attributes_value),
created_at: Set(app.created_at),
updated_at: Set(Utc::now()),
updated_at: Set(now),
device_uuid: Set(device_uuid),
uuid: Set(Uuid::new_v4()),
version: Set(1),
};

// Insert or update if exists
let model = match tag_application.clone().insert(&*db).await {
Ok(model) => model,
Err(_) => {
// If insert fails due to unique constraint, update existing
let existing = user_metadata_tag::Entity::find()
.filter(user_metadata_tag::Column::UserMetadataId.eq(metadata_db_id))
.filter(user_metadata_tag::Column::TagId.eq(tag_db_id))
.one(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?;

if let Some(existing_model) = existing {
let mut update_model: user_metadata_tag::ActiveModel =
existing_model.into();
update_model.applied_context = Set(app.applied_context.clone());
update_model.applied_variant = Set(app.applied_variant.clone());
update_model.confidence = Set(app.confidence);
update_model.source = Set(app.source.as_str().to_string());
update_model.instance_attributes =
Set(if app.instance_attributes.is_empty() {
None
} else {
Some(
serde_json::to_value(&app.instance_attributes)
.unwrap()
.into(),
)
});
update_model.updated_at = Set(Utc::now());
update_model.device_uuid = Set(device_uuid);
update_model.version = Set(update_model.version.unwrap() + 1);

update_model
.update(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?
} else {
continue;
}
}
};
let on_conflict = OnConflict::columns([
user_metadata_tag::Column::UserMetadataId,
user_metadata_tag::Column::TagId,
])
.update_columns([
user_metadata_tag::Column::AppliedContext,
user_metadata_tag::Column::AppliedVariant,
user_metadata_tag::Column::Confidence,
user_metadata_tag::Column::Source,
user_metadata_tag::Column::InstanceAttributes,
user_metadata_tag::Column::UpdatedAt,
user_metadata_tag::Column::DeviceUuid,
])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.value(
user_metadata_tag::Column::Version,
Expr::col(user_metadata_tag::Column::Version).add(1),
)
.to_owned();

user_metadata_tag::Entity::insert(new_model)
.on_conflict(on_conflict)
.exec(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?;

// Re-query to get the final model (handles both insert and update cases)
let model = user_metadata_tag::Entity::find()
.filter(user_metadata_tag::Column::UserMetadataId.eq(metadata_db_id))
.filter(user_metadata_tag::Column::TagId.eq(tag_db_id))
.one(&*db)
.await
.map_err(|e| TagError::DatabaseError(e.to_string()))?
.ok_or_else(|| TagError::DatabaseError("Upsert succeeded but row not found".to_string()))?;

created_models.push(model);
}
Expand Down
Loading