Files
RuDS/crates/bds-core/src/engine/media.rs

1509 lines
47 KiB
Rust

use std::fs;
use std::path::Path;
use crate::db::DbConnection as Connection;
use uuid::Uuid;
use walkdir::WalkDir;
use crate::db::fts;
use crate::db::queries::media as qm;
use crate::db::queries::media_translation as qmt;
use crate::db::queries::post as qp;
use crate::db::queries::post_media as qpm;
use crate::engine::{EngineError, EngineResult, domain_events};
use crate::model::{DomainEntity, Media, MediaTranslation, NotificationAction, PostMedia};
use crate::util::sidecar::{
MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
};
use crate::util::thumbnail::{
THUMBNAIL_SIZES, ThumbnailFormat, generate_all_thumbnails, image_dimensions,
mime_from_extension,
};
use crate::util::{
atomic_write_str, content_hash, media_dir_path, media_sidecar_path,
media_translation_sidecar_path, now_unix_ms,
};
/// Report returned by `rebuild_media_from_filesystem`.
#[derive(Debug, Default)]
pub struct MediaRebuildReport {
pub media_created: usize,
pub media_updated: usize,
pub translations_created: usize,
pub translations_updated: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct MediaLinkRebuildReport {
pub links: usize,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ThumbnailRepairReport {
pub media_repaired: usize,
pub thumbnails_generated: usize,
}
/// Rebuild the exact post/media relationship set stored in canonical media
/// sidecars. Stale database links are removed as well as missing links added.
pub fn rebuild_media_links(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
conn.begin_savepoint()?;
match rebuild_media_links_inner(conn, data_dir, project_id) {
Ok(report) => {
conn.release_savepoint()?;
Ok(report)
}
Err(error) => {
let _ = conn.rollback_savepoint();
Err(error)
}
}
}
fn rebuild_media_links_inner(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
let mut report = MediaLinkRebuildReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?)
.map_err(EngineError::Parse)?;
for link in qpm::list_post_media_by_media(conn, &item.id)? {
qpm::unlink_media(conn, &link.post_id, &item.id)?;
}
for (sort_order, post_id) in sidecar.linked_post_ids.into_iter().enumerate() {
let post = qp::get_post_by_id(conn, &post_id)?;
if post.project_id != project_id {
return Err(EngineError::Validation(format!(
"media {} sidecar links to a post outside the active project",
item.id
)));
}
qpm::link_media(
conn,
&PostMedia {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
post_id,
media_id: item.id.clone(),
sort_order: sort_order as i32,
created_at: now_unix_ms(),
},
)?;
report.links += 1;
}
}
Ok(report)
}
/// Regenerate all standard thumbnail variants for items missing at least one
/// variant. Existing complete sets are left untouched.
pub fn regenerate_missing_thumbnails(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<ThumbnailRepairReport> {
let mut report = ThumbnailRepairReport::default();
for item in qm::list_media_by_project(conn, project_id)? {
if !item.mime_type.starts_with("image/") {
continue;
}
let prefix = &item.id[..2.min(item.id.len())];
let missing = THUMBNAIL_SIZES
.iter()
.filter(|size| {
let extension = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
!data_dir
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{}", item.id, size.name, extension))
.is_file()
})
.count();
if missing == 0 {
continue;
}
generate_all_thumbnails(
&data_dir.join(&item.file_path),
&data_dir.join("thumbnails"),
&item.id,
)
.map_err(EngineError::Parse)?;
report.media_repaired += 1;
report.thumbnails_generated += missing;
}
Ok(report)
}
/// Supported image MIME types for import (per media_processing.allium).
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp",
"image/heic",
"image/heif",
];
pub fn is_supported_image_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.map(mime_from_extension)
.is_some_and(|mime_type| SUPPORTED_IMAGE_TYPES.contains(&mime_type))
}
/// Import a media file (image, etc.) into the project.
#[expect(
clippy::too_many_arguments,
reason = "arguments are the user-supplied media metadata fields"
)]
pub fn import_media(
conn: &Connection,
data_dir: &Path,
project_id: &str,
source_path: &Path,
original_name: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
author: Option<&str>,
language: Option<&str>,
tags: Vec<String>,
) -> EngineResult<Media> {
import_media_at(
conn,
data_dir,
project_id,
source_path,
original_name,
title,
alt,
caption,
author,
language,
tags,
now_unix_ms(),
)
}
/// Import media while preserving a trusted source timestamp. Importers use
/// this path so the canonical date-based location and sidecar metadata match
/// the source system; interactive imports continue to use the current time.
#[expect(
clippy::too_many_arguments,
reason = "arguments are the user-supplied media metadata fields plus source timestamp"
)]
pub(crate) fn import_media_at(
conn: &Connection,
data_dir: &Path,
project_id: &str,
source_path: &Path,
original_name: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
author: Option<&str>,
language: Option<&str>,
tags: Vec<String>,
created_at: i64,
) -> EngineResult<Media> {
// Validate file type per spec
let ext = Path::new(original_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let mime_type = mime_from_extension(ext).to_string();
if !is_supported_image_path(Path::new(original_name)) {
return Err(EngineError::Validation(format!(
"unsupported file type: {mime_type} (file: {original_name})"
)));
}
let id = Uuid::new_v4().to_string();
let filename = format!("{id}.{ext}");
// Compute target directory and copy file
let dir_path = media_dir_path(created_at);
let rel_file_path = format!("{dir_path}{filename}");
let abs_file_path = data_dir.join(&rel_file_path);
if let Some(parent) = abs_file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source_path, &abs_file_path)?;
// Get image dimensions
let (width, height) = image_dimensions(&abs_file_path)
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
.unwrap_or((None, None));
// Get file size
let file_size = fs::metadata(&abs_file_path)?.len() as i64;
// Compute sidecar path
let sidecar_rel = media_sidecar_path(&rel_file_path);
// Compute checksum of the copied file
let file_bytes = fs::read(&abs_file_path)?;
let checksum = content_hash(&file_bytes);
// Generate thumbnails (silently ignore errors for non-image files)
let thumbnails_dir = data_dir.join("thumbnails");
let _ = generate_all_thumbnails(&abs_file_path, &thumbnails_dir, &id);
let media = Media {
id: id.clone(),
project_id: project_id.to_string(),
filename,
original_name: original_name.to_string(),
mime_type,
size: file_size,
width,
height,
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
author: author.map(|s| s.to_string()),
language: language.map(|s| s.to_string()),
file_path: rel_file_path,
sidecar_path: sidecar_rel.clone(),
checksum: Some(checksum),
tags,
created_at,
updated_at: created_at,
};
// Write sidecar
let sidecar = MediaSidecar::from_media(&media, &[]);
let abs_sidecar = data_dir.join(&sidecar_rel);
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
// Insert into DB
qm::insert_media(conn, &media)?;
// Index in FTS
fts_index_media(conn, &media)?;
emit_media(&media, NotificationAction::Created);
Ok(media)
}
/// Update a media item's metadata fields.
#[expect(
clippy::too_many_arguments,
reason = "optional arguments represent independent media field changes"
)]
pub fn update_media(
conn: &Connection,
data_dir: &Path,
media_id: &str,
title: Option<Option<&str>>,
alt: Option<Option<&str>>,
caption: Option<Option<&str>>,
author: Option<Option<&str>>,
language: Option<Option<&str>>,
tags: Option<Vec<String>>,
) -> EngineResult<Media> {
let mut media = qm::get_media_by_id(conn, media_id)?;
if let Some(t) = title {
media.title = t.map(|s| s.to_string());
}
if let Some(a) = alt {
media.alt = a.map(|s| s.to_string());
}
if let Some(c) = caption {
media.caption = c.map(|s| s.to_string());
}
if let Some(a) = author {
media.author = a.map(|s| s.to_string());
}
if let Some(l) = language {
media.language = l.map(|s| s.to_string());
}
if let Some(t) = tags {
media.tags = t;
}
media.updated_at = now_unix_ms();
qm::update_media(conn, &media)?;
// Rewrite sidecar (need linked_post_ids from post_media table)
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
let linked_post_ids: Vec<String> = linked.iter().map(|pm| pm.post_id.clone()).collect();
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
let abs_sidecar = data_dir.join(&media.sidecar_path);
atomic_write_str(&abs_sidecar, &sidecar.to_string())?;
// Re-index FTS
fts_index_media(conn, &media)?;
emit_media(&media, NotificationAction::Updated);
Ok(media)
}
/// Replace a media item's binary while preserving its identity, public path,
/// metadata, translations, and post links. Returns `None` when the new file is
/// byte-for-byte identical to the stored binary.
pub fn replace_media_file(
conn: &Connection,
data_dir: &Path,
media_id: &str,
new_source_path: &Path,
) -> EngineResult<Option<Media>> {
let mut media = qm::get_media_by_id(conn, media_id)
.map_err(|_| EngineError::NotFound(format!("media {media_id}")))?;
if !new_source_path.is_file() {
return Err(EngineError::Validation(format!(
"replacement file does not exist: {}",
new_source_path.display()
)));
}
let replacement_checksum = crate::util::file_hash(new_source_path)?;
if media.checksum.as_deref() == Some(replacement_checksum.as_str()) {
return Ok(None);
}
let destination = data_dir.join(&media.file_path);
if !destination.is_file() {
return Err(EngineError::NotFound(format!(
"stored media file {}",
destination.display()
)));
}
let (width, height) = image_dimensions(new_source_path)
.map(|(w, h)| (Some(w as i32), Some(h as i32)))
.map_err(|error| {
EngineError::Validation(format!(
"replacement is not a readable image '{}': {error}",
new_source_path.display(),
))
})?;
let replacement_size = fs::metadata(new_source_path)?.len() as i64;
// Generate all derived files before touching the canonical binary.
let staged_thumbnails = data_dir
.join("thumbnails")
.join(format!(".replace-{media_id}"));
if staged_thumbnails.exists() {
fs::remove_dir_all(&staged_thumbnails)?;
}
let staged_paths = generate_all_thumbnails(new_source_path, &staged_thumbnails, media_id)
.map_err(EngineError::Parse)?;
let backup = destination.with_extension(format!(
"{}.bak",
destination
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("media")
));
if backup.exists() {
fs::remove_file(&backup)?;
}
fs::rename(&destination, &backup)?;
if let Err(error) = fs::copy(new_source_path, &destination) {
let _ = fs::rename(&backup, &destination);
let _ = fs::remove_dir_all(&staged_thumbnails);
return Err(error.into());
}
let previous_media = media.clone();
let previous_sidecar = fs::read_to_string(data_dir.join(&media.sidecar_path)).ok();
media.size = replacement_size;
media.width = width;
media.height = height;
media.checksum = Some(replacement_checksum);
media.updated_at = now_unix_ms();
let apply_result = (|| -> EngineResult<()> {
conn.begin_savepoint()?;
qm::update_media(conn, &media)?;
let linked = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
let linked_post_ids: Vec<String> = linked.iter().map(|item| item.post_id.clone()).collect();
let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
atomic_write_str(&data_dir.join(&media.sidecar_path), &sidecar.to_string())?;
fts_index_media(conn, &media)?;
for staged in &staged_paths {
let staged = Path::new(staged);
let relative = staged.strip_prefix(&staged_thumbnails).map_err(|error| {
EngineError::Validation(format!("invalid staged thumbnail path: {error}"))
})?;
let target = data_dir.join("thumbnails").join(relative);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(staged, target)?;
}
conn.release_savepoint()?;
Ok(())
})();
let _ = fs::remove_dir_all(&staged_thumbnails);
match apply_result {
Ok(()) => {
fs::remove_file(backup)?;
emit_media(&media, NotificationAction::Updated);
Ok(Some(media))
}
Err(error) => {
let _ = conn.rollback_savepoint();
let _ = fs::remove_file(&destination);
let _ = fs::rename(&backup, &destination);
let _ = qm::update_media(conn, &previous_media);
if let Some(previous_sidecar) = previous_sidecar {
let _ = atomic_write_str(
&data_dir.join(&previous_media.sidecar_path),
&previous_sidecar,
);
}
Err(error)
}
}
}
/// Delete a media item and all related artifacts.
pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id)?;
// Delete binary file
let abs_file = data_dir.join(&media.file_path);
if abs_file.exists() {
fs::remove_file(&abs_file)?;
}
// Delete sidecar file
let abs_sidecar = data_dir.join(&media.sidecar_path);
if abs_sidecar.exists() {
fs::remove_file(&abs_sidecar)?;
}
// Delete all translation sidecar files
let translations = qmt::list_media_translations_by_media(conn, media_id).unwrap_or_default();
for t in &translations {
let trans_sidecar = media_translation_sidecar_path(&media.file_path, &t.language);
let abs_trans = data_dir.join(&trans_sidecar);
if abs_trans.exists() {
fs::remove_file(&abs_trans)?;
}
}
// Delete all thumbnails
let ext_map = |fmt: &ThumbnailFormat| -> &str {
match fmt {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
}
};
let prefix = &media_id[..2.min(media_id.len())];
for size in THUMBNAIL_SIZES {
let ext = ext_map(&size.format);
let thumb_rel = format!("thumbnails/{prefix}/{media_id}-{}.{ext}", size.name);
let abs_thumb = data_dir.join(&thumb_rel);
if abs_thumb.exists() {
let _ = fs::remove_file(&abs_thumb);
}
}
// Delete media translations from DB
for t in &translations {
qmt::delete_media_translation(conn, media_id, &t.language)?;
}
// Delete post_media links from DB
let links = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
for link in &links {
qpm::unlink_media(conn, &link.post_id, media_id)?;
}
// Remove from FTS index
fts::remove_media_from_index(conn, media_id)?;
// Delete from media table
qm::delete_media(conn, media_id)?;
emit_media(&media, NotificationAction::Deleted);
Ok(())
}
fn emit_media(media: &Media, action: NotificationAction) {
domain_events::entity_changed(&media.project_id, DomainEntity::Media, &media.id, action);
}
/// Create or update a translation for a media item.
pub fn upsert_media_translation(
conn: &Connection,
data_dir: &Path,
media_id: &str,
language: &str,
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
) -> EngineResult<MediaTranslation> {
let media = qm::get_media_by_id(conn, media_id)?;
let now = now_unix_ms();
// Check if translation already exists
let existing = qmt::get_media_translation_by_media_and_language(conn, media_id, language);
let translation = match existing {
Ok(mut t) => {
t.title = title.map(|s| s.to_string());
t.alt = alt.map(|s| s.to_string());
t.caption = caption.map(|s| s.to_string());
t.updated_at = now;
qmt::upsert_media_translation(conn, &t)?;
t
}
Err(_) => {
let t = MediaTranslation {
id: Uuid::new_v4().to_string(),
project_id: media.project_id.clone(),
translation_for: media_id.to_string(),
language: language.to_string(),
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
created_at: now,
updated_at: now,
};
qmt::upsert_media_translation(conn, &t)?;
t
}
};
// Write translation sidecar file
let trans_sidecar = MediaTranslationSidecar {
translation_for: media_id.to_string(),
language: language.to_string(),
title: title.map(|s| s.to_string()),
alt: alt.map(|s| s.to_string()),
caption: caption.map(|s| s.to_string()),
};
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
let abs_sidecar = data_dir.join(&sidecar_rel);
atomic_write_str(&abs_sidecar, &trans_sidecar.to_string())?;
// Re-index FTS for parent media
fts_index_media(conn, &media)?;
emit_media(&media, NotificationAction::Updated);
Ok(translation)
}
/// Delete a media translation and its sidecar file.
pub fn delete_media_translation(
conn: &Connection,
data_dir: &Path,
media_id: &str,
language: &str,
) -> EngineResult<()> {
let media = qm::get_media_by_id(conn, media_id)?;
// Delete translation sidecar file
let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
let abs_sidecar = data_dir.join(&sidecar_rel);
if abs_sidecar.exists() {
fs::remove_file(&abs_sidecar)?;
}
// Delete from DB
qmt::delete_media_translation(conn, media_id, language)?;
// Re-index FTS for parent media
fts_index_media(conn, &media)?;
Ok(())
}
/// Rebuild media entries from filesystem. Walk `media/` directory, parse sidecars, upsert into DB.
pub fn rebuild_media_from_filesystem(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<MediaRebuildReport> {
rebuild_media_from_filesystem_with_progress(conn, data_dir, project_id, None)
}
/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
/// Like `rebuild_media_from_filesystem` but with optional per-item progress.
pub fn rebuild_media_from_filesystem_with_progress(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_item: Option<ItemProgressFn>,
) -> EngineResult<MediaRebuildReport> {
let mut report = MediaRebuildReport::default();
let media_dir = data_dir.join("media");
if !media_dir.exists() {
return Ok(report);
}
let mut canonical_sidecars = Vec::new();
let mut translation_sidecars = Vec::new();
for entry in WalkDir::new(&media_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if !file_name.ends_with(".meta") {
continue;
}
if is_translation_sidecar(file_name) {
translation_sidecars.push(path.to_path_buf());
} else {
canonical_sidecars.push(path.to_path_buf());
}
}
let total = canonical_sidecars.len() + translation_sidecars.len();
// Process canonical sidecars first
for (i, path) in canonical_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(i + 1, total, name);
}
match rebuild_canonical_media(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.media_created += 1;
} else {
report.media_updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
// Process translation sidecars
let offset = canonical_sidecars.len();
for (i, path) in translation_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
cb(offset + i + 1, total, name);
}
match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
Ok(created) => {
if created {
report.translations_created += 1;
} else {
report.translations_updated += 1;
}
}
Err(e) => {
report.errors.push(format!("{}: {e}", path.display()));
}
}
}
// Re-index FTS for all media in this project
let all_media = qm::list_media_by_project(conn, project_id)?;
for m in &all_media {
fts_index_media(conn, m)?;
}
Ok(report)
}
// --- Internal helpers ---
/// Index a media item in FTS, gathering translation texts.
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
let translation_data: Vec<fts::MediaTranslationFts> = translations
.iter()
.map(|t| fts::MediaTranslationFts {
title: t.title.clone(),
alt: t.alt.clone(),
caption: t.caption.clone(),
language: t.language.clone(),
})
.collect();
let lang = media.language.as_deref().unwrap_or("en");
fts::index_media(
conn,
&media.id,
media.title.as_deref(),
media.alt.as_deref(),
media.caption.as_deref(),
&media.original_name,
&media.tags,
&translation_data,
lang,
)?;
Ok(())
}
/// Check if a .meta filename is a translation sidecar: `{name}.{lang}.meta`
/// where lang is exactly 2 lowercase ASCII letters.
fn is_translation_sidecar(file_name: &str) -> bool {
// file_name ends with .meta
// Strip .meta suffix, then check if remaining ends with .{2-letter-lang}
let without_meta = match file_name.strip_suffix(".meta") {
Some(s) => s,
None => return false,
};
// Find the last dot in what remains
if let Some(dot_pos) = without_meta.rfind('.') {
let suffix = &without_meta[dot_pos + 1..];
// Must also have another dot before this (the extension of the media file)
// e.g. "foo.jpg.de" -> dot_pos points to the dot before "de"
// "foo.jpg" would be the canonical sidecar (no extra dot+lang)
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase()) && dot_pos > 0
} else {
false
}
}
/// Rebuild a canonical media from its sidecar file. Returns true if created, false if updated.
pub(crate) fn rebuild_canonical_media(
conn: &Connection,
data_dir: &Path,
project_id: &str,
sidecar_path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(sidecar_path)?;
let sc = read_sidecar(&content).map_err(EngineError::Parse)?;
// Derive file_path: the sidecar path minus ".meta" suffix, relative to data_dir
let sidecar_rel = sidecar_path
.strip_prefix(data_dir)
.unwrap_or(sidecar_path)
.to_string_lossy()
.to_string();
let file_path = sidecar_rel
.strip_suffix(".meta")
.unwrap_or(&sidecar_rel)
.to_string();
// Derive filename from file_path
let filename = Path::new(&file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let linked_post_ids = sc.linked_post_ids.clone();
let existing = qm::get_media_by_id(conn, &sc.id);
let created = match existing {
Ok(mut media) => {
// Update existing media
media.original_name = sc.original_name;
media.mime_type = sc.mime_type;
media.size = sc.size;
media.width = sc.width;
media.height = sc.height;
media.title = sc.title;
media.alt = sc.alt;
media.caption = sc.caption;
media.author = sc.author;
media.language = sc.language;
media.file_path = file_path;
media.sidecar_path = sidecar_rel;
media.checksum = None;
media.tags = sc.tags;
media.updated_at = sc.updated_at;
qm::update_media(conn, &media)?;
false
}
Err(_) => {
let media = Media {
id: sc.id.clone(),
project_id: project_id.to_string(),
filename,
original_name: sc.original_name,
mime_type: sc.mime_type,
size: sc.size,
width: sc.width,
height: sc.height,
title: sc.title,
alt: sc.alt,
caption: sc.caption,
author: sc.author,
language: sc.language,
file_path,
sidecar_path: sidecar_rel,
checksum: None,
tags: sc.tags,
created_at: sc.created_at,
updated_at: sc.updated_at,
};
qm::insert_media(conn, &media)?;
true
}
};
let existing_post_ids: std::collections::HashSet<_> =
qpm::list_post_media_by_media(conn, &sc.id)?
.into_iter()
.map(|link| link.post_id)
.collect();
for post_id in linked_post_ids {
if !existing_post_ids.contains(&post_id) && qp::get_post_by_id(conn, &post_id).is_ok() {
qpm::link_media(
conn,
&PostMedia {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
post_id,
media_id: sc.id.clone(),
sort_order: 0,
created_at: now_unix_ms(),
},
)?;
}
}
Ok(created)
}
/// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated.
pub(crate) fn rebuild_translation_sidecar(
conn: &Connection,
_data_dir: &Path,
project_id: &str,
sidecar_path: &Path,
) -> EngineResult<bool> {
let content = fs::read_to_string(sidecar_path)?;
let sc = read_translation_sidecar(&content).map_err(EngineError::Parse)?;
// Check parent media exists
let _media = qm::get_media_by_id(conn, &sc.translation_for).map_err(|_| {
EngineError::NotFound(format!(
"parent media '{}' not found for translation",
sc.translation_for
))
})?;
let now = now_unix_ms();
let existing =
qmt::get_media_translation_by_media_and_language(conn, &sc.translation_for, &sc.language);
match existing {
Ok(mut t) => {
t.title = sc.title;
t.alt = sc.alt;
t.caption = sc.caption;
t.updated_at = now;
qmt::upsert_media_translation(conn, &t)?;
Ok(false)
}
Err(_) => {
let t = MediaTranslation {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
translation_for: sc.translation_for,
language: sc.language,
title: sc.title,
alt: sc.alt,
caption: sc.caption,
created_at: now,
updated_at: now,
};
qmt::upsert_media_translation(conn, &t)?;
Ok(true)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::fts::ensure_fts_tables;
use crate::db::queries::project::{insert_project, make_test_project};
use image::DynamicImage;
use tempfile::TempDir;
fn setup() -> (Database, TempDir) {
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = TempDir::new().unwrap();
(db, dir)
}
/// Create a simple 100x80 PNG in the given directory.
fn create_test_png(dir: &Path) -> std::path::PathBuf {
let path = dir.join("test-source.png");
let img = DynamicImage::new_rgb8(100, 80);
img.save(&path).unwrap();
path
}
#[test]
fn import_media_creates_artifacts() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
Some("My Photo"),
Some("A photo"),
None,
Some("Alice"),
Some("en"),
vec!["nature".into()],
)
.unwrap();
// Verify DB entry
let from_db = qm::get_media_by_id(db.conn(), &media.id).unwrap();
assert_eq!(from_db.original_name, "photo.png");
assert_eq!(from_db.title.as_deref(), Some("My Photo"));
assert_eq!(from_db.alt.as_deref(), Some("A photo"));
assert_eq!(from_db.author.as_deref(), Some("Alice"));
assert_eq!(from_db.language.as_deref(), Some("en"));
assert_eq!(from_db.tags, vec!["nature"]);
assert_eq!(from_db.mime_type, "image/png");
assert_eq!(from_db.width, Some(100));
assert_eq!(from_db.height, Some(80));
assert!(from_db.checksum.is_some());
assert!(from_db.size > 0);
// Verify binary file exists
let abs_file = dir.path().join(&from_db.file_path);
assert!(abs_file.exists(), "binary file should exist");
// Verify sidecar exists
let abs_sidecar = dir.path().join(&from_db.sidecar_path);
assert!(abs_sidecar.exists(), "sidecar should exist");
// Verify sidecar content is parseable
let sidecar_content = fs::read_to_string(&abs_sidecar).unwrap();
let sc = read_sidecar(&sidecar_content).unwrap();
assert_eq!(sc.id, media.id);
assert_eq!(sc.original_name, "photo.png");
// Verify thumbnails exist
let prefix = &media.id[..2];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let thumb = dir
.path()
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{ext}", media.id, size.name));
assert!(thumb.exists(), "thumbnail {} should exist", size.name);
}
}
#[test]
fn update_media_rewrites_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
Some("Original Title"),
None,
None,
None,
None,
vec![],
)
.unwrap();
// Read original sidecar
let abs_sidecar = dir.path().join(&media.sidecar_path);
let original_content = fs::read_to_string(&abs_sidecar).unwrap();
// Update
let updated = update_media(
db.conn(),
dir.path(),
&media.id,
Some(Some("New Title")),
Some(Some("New alt")),
None,
None,
None,
Some(vec!["updated-tag".into()]),
)
.unwrap();
assert_eq!(updated.title.as_deref(), Some("New Title"));
assert_eq!(updated.alt.as_deref(), Some("New alt"));
assert_eq!(updated.tags, vec!["updated-tag"]);
// Verify sidecar was rewritten
let new_content = fs::read_to_string(&abs_sidecar).unwrap();
assert_ne!(original_content, new_content);
let sc = read_sidecar(&new_content).unwrap();
assert_eq!(sc.title.as_deref(), Some("New Title"));
assert_eq!(sc.tags, vec!["updated-tag"]);
}
#[test]
fn rebuild_media_links_replaces_stale_links_and_rolls_back_invalid_sidecars() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let first = crate::engine::post::create_post(
db.conn(),
dir.path(),
"p1",
"First",
None,
vec![],
vec![],
None,
None,
None,
)
.unwrap();
let stale = crate::engine::post::create_post(
db.conn(),
dir.path(),
"p1",
"Stale",
None,
vec![],
vec![],
None,
None,
None,
)
.unwrap();
crate::engine::post_media::link_media_to_post(
db.conn(),
dir.path(),
"p1",
&first.id,
&media.id,
0,
)
.unwrap();
crate::engine::post_media::link_media_to_post(
db.conn(),
dir.path(),
"p1",
&stale.id,
&media.id,
1,
)
.unwrap();
atomic_write_str(
&dir.path().join(&media.sidecar_path),
&MediaSidecar::from_media(&media, std::slice::from_ref(&first.id)).to_string(),
)
.unwrap();
let report = rebuild_media_links(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.links, 1);
let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].post_id, first.id);
atomic_write_str(
&dir.path().join(&media.sidecar_path),
&MediaSidecar::from_media(&media, &["missing-post".into()]).to_string(),
)
.unwrap();
assert!(rebuild_media_links(db.conn(), dir.path(), "p1").is_err());
let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap();
assert_eq!(
links.len(),
1,
"the failed repair must roll back link deletion"
);
assert_eq!(links[0].post_id, first.id);
}
#[test]
fn replace_media_file_preserves_identity_and_regenerates_artifacts() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
Some("Kept title"),
None,
None,
None,
None,
vec!["kept".into()],
)
.unwrap();
let replacement = dir.path().join("replacement.png");
DynamicImage::new_rgb8(320, 180).save(&replacement).unwrap();
let old_checksum = media.checksum.clone();
let updated = replace_media_file(db.conn(), dir.path(), &media.id, &replacement)
.unwrap()
.unwrap();
assert_eq!(updated.id, media.id);
assert_eq!(updated.file_path, media.file_path);
assert_eq!(updated.original_name, media.original_name);
assert_eq!(updated.title, media.title);
assert_eq!(updated.tags, media.tags);
assert_eq!((updated.width, updated.height), (Some(320), Some(180)));
assert_ne!(updated.checksum, old_checksum);
assert!(!dir.path().join(format!("{}.bak", media.file_path)).exists());
let sidecar =
read_sidecar(&fs::read_to_string(dir.path().join(&updated.sidecar_path)).unwrap())
.unwrap();
assert_eq!((sidecar.width, sidecar.height), (Some(320), Some(180)));
let prefix = &media.id[..2];
let ai_thumb = dir
.path()
.join("thumbnails")
.join(prefix)
.join(format!("{}-ai.jpg", media.id));
assert!(ai_thumb.is_file());
}
#[test]
fn replace_media_file_is_noop_for_identical_content() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let stored = dir.path().join(&media.file_path);
let updated_at = media.updated_at;
assert!(
replace_media_file(db.conn(), dir.path(), &media.id, &stored)
.unwrap()
.is_none()
);
assert_eq!(
qm::get_media_by_id(db.conn(), &media.id)
.unwrap()
.updated_at,
updated_at
);
}
#[test]
fn delete_media_removes_everything() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
let abs_file = dir.path().join(&media.file_path);
let abs_sidecar = dir.path().join(&media.sidecar_path);
assert!(abs_file.exists());
assert!(abs_sidecar.exists());
// Delete
delete_media(db.conn(), dir.path(), &media.id).unwrap();
// Verify file gone
assert!(!abs_file.exists(), "binary file should be removed");
// Verify sidecar gone
assert!(!abs_sidecar.exists(), "sidecar should be removed");
// Verify DB entry gone
assert!(qm::get_media_by_id(db.conn(), &media.id).is_err());
// Verify thumbnails gone
let prefix = &media.id[..2];
for size in THUMBNAIL_SIZES {
let ext = match size.format {
ThumbnailFormat::Webp => "webp",
ThumbnailFormat::Jpeg => "jpg",
};
let thumb = dir
.path()
.join("thumbnails")
.join(prefix)
.join(format!("{}-{}.{ext}", media.id, size.name));
assert!(!thumb.exists(), "thumbnail {} should be removed", size.name);
}
}
#[test]
fn upsert_media_translation_writes_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
// Create translation
let t = upsert_media_translation(
db.conn(),
dir.path(),
&media.id,
"de",
Some("Deutsches Foto"),
Some("Ein Foto"),
None,
)
.unwrap();
assert_eq!(t.language, "de");
assert_eq!(t.title.as_deref(), Some("Deutsches Foto"));
// Verify translation sidecar file
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
let abs_sidecar = dir.path().join(&sidecar_rel);
assert!(abs_sidecar.exists(), "translation sidecar should exist");
let content = fs::read_to_string(&abs_sidecar).unwrap();
let sc = read_translation_sidecar(&content).unwrap();
assert_eq!(sc.language, "de");
assert_eq!(sc.title.as_deref(), Some("Deutsches Foto"));
}
#[test]
fn delete_media_translation_removes_sidecar() {
let (db, dir) = setup();
let source = create_test_png(dir.path());
let media = import_media(
db.conn(),
dir.path(),
"p1",
&source,
"photo.png",
None,
None,
None,
None,
None,
vec![],
)
.unwrap();
// Create translation
upsert_media_translation(
db.conn(),
dir.path(),
&media.id,
"de",
Some("Titel"),
None,
None,
)
.unwrap();
let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
let abs_sidecar = dir.path().join(&sidecar_rel);
assert!(abs_sidecar.exists());
// Delete translation
delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();
// Sidecar should be gone
assert!(
!abs_sidecar.exists(),
"translation sidecar should be removed"
);
// DB entry should be gone
assert!(
qmt::get_media_translation_by_media_and_language(db.conn(), &media.id, "de").is_err()
);
}
#[test]
fn rebuild_media_from_filesystem_test() {
let (db, dir) = setup();
// Create a fake media file and its sidecar manually
let media_subdir = dir.path().join("media").join("2024").join("01");
fs::create_dir_all(&media_subdir).unwrap();
// Write a dummy binary file
let media_file = media_subdir.join("abcdef12-test-uuid.png");
fs::write(&media_file, b"fake-png-data").unwrap();
// Write a canonical sidecar
let sidecar_content = "\
---
id: abcdef12-test-uuid
originalName: \"photo.png\"
mimeType: image/png
size: 13
title: \"Rebuild Test\"
alt: \"An image\"
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: [\"test\"]
---";
fs::write(
media_subdir.join("abcdef12-test-uuid.png.meta"),
sidecar_content,
)
.unwrap();
// Write a translation sidecar
let trans_sidecar_content = "\
---
translationFor: abcdef12-test-uuid
language: de
title: \"Wiederherstellungstest\"
alt: \"Ein Bild\"
---";
fs::write(
media_subdir.join("abcdef12-test-uuid.png.de.meta"),
trans_sidecar_content,
)
.unwrap();
// Run rebuild
let report = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report.media_created, 1);
assert_eq!(report.translations_created, 1);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Verify media in DB
let media = qm::get_media_by_id(db.conn(), "abcdef12-test-uuid").unwrap();
assert_eq!(media.title.as_deref(), Some("Rebuild Test"));
assert_eq!(media.tags, vec!["test"]);
assert_eq!(media.original_name, "photo.png");
// Verify translation in DB
let trans =
qmt::get_media_translation_by_media_and_language(db.conn(), "abcdef12-test-uuid", "de")
.unwrap();
assert_eq!(trans.title.as_deref(), Some("Wiederherstellungstest"));
// Run rebuild again - should update, not create
let report2 = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(report2.media_created, 0);
assert_eq!(report2.media_updated, 1);
assert_eq!(report2.translations_created, 0);
assert_eq!(report2.translations_updated, 1);
}
#[test]
fn rebuild_media_trusts_sidecars_without_generating_thumbnails() {
let (db, dir) = setup();
let media_subdir = dir.path().join("media/2024/01");
fs::create_dir_all(&media_subdir).unwrap();
DynamicImage::new_rgb8(100, 80)
.save(media_subdir.join("sidecar-only.png"))
.unwrap();
fs::write(
media_subdir.join("sidecar-only.png.meta"),
"---\nid: sidecar-only\noriginalName: \"photo.png\"\nmimeType: image/png\nsize: 123\nwidth: 640\nheight: 480\ncreatedAt: 2024-01-15T12:00:00.000Z\nupdatedAt: 2024-01-15T12:00:00.000Z\ntags: []\n---",
)
.unwrap();
rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
let media = qm::get_media_by_id(db.conn(), "sidecar-only").unwrap();
assert_eq!(
(media.size, media.width, media.height),
(123, Some(640), Some(480))
);
assert!(!dir.path().join("thumbnails").exists());
}
#[test]
fn is_translation_sidecar_detection() {
assert!(is_translation_sidecar("photo.jpg.de.meta"));
assert!(is_translation_sidecar("photo.jpg.fr.meta"));
assert!(is_translation_sidecar("uuid.png.en.meta"));
assert!(!is_translation_sidecar("photo.jpg.meta"));
assert!(!is_translation_sidecar("photo.meta"));
assert!(!is_translation_sidecar("photo.jpg.123.meta"));
assert!(!is_translation_sidecar("photo.jpg.DEU.meta"));
}
}