Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램
Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼 사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드, 인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포. - archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram - archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트) - archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수 - archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡 - 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널 Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
//! 중복 탐지 파이프라인.
|
||||
//!
|
||||
//! 완전 동일: size 그룹 → quick_hash → BLAKE3 전체 해시 (그룹 내만).
|
||||
//! 유사 이미지: dHash(썸네일에서) 병렬 브루트포스 → Union-Find 클러스터.
|
||||
//! 유사 영상: duration ±5% 사전필터 → 프레임 시그니처 중앙값 해밍.
|
||||
//! 결과는 dupe_groups/dupe_members에 영속화된다.
|
||||
|
||||
use crate::ffmpeg::FfTools;
|
||||
use crate::{hash, phash};
|
||||
use archive_db::Db;
|
||||
use rayon::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DedupeError {
|
||||
#[error("DB 오류: {0}")]
|
||||
Db(#[from] archive_db::DbError),
|
||||
#[error("취소됨")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DedupeError>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum GroupKind {
|
||||
Exact = 0,
|
||||
Image = 1,
|
||||
Video = 2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DedupeStats {
|
||||
pub exact_groups: u64,
|
||||
pub image_groups: u64,
|
||||
pub video_groups: u64,
|
||||
pub total_dupes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DedupeProgress {
|
||||
pub phase: String, // exact | image | video | done
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
struct FileMeta {
|
||||
id: i64,
|
||||
path: PathBuf,
|
||||
size: i64,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
mtime_ms: i64,
|
||||
duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
fn load_files(db: &Db, kind: u8, scope: Option<i64>) -> Result<Vec<FileMeta>> {
|
||||
Ok(db.with_read(move |conn| {
|
||||
let base = "SELECT f.id, fo.path, f.name, f.size, f.width, f.height, f.mtime_ms, f.duration_ms
|
||||
FROM files f JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.kind = ?1 AND f.deleted_at IS NULL";
|
||||
let map = |r: &rusqlite::Row<'_>| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok(FileMeta {
|
||||
id: r.get(0)?,
|
||||
path: Path::new(&dir).join(name),
|
||||
size: r.get(3)?,
|
||||
width: r.get(4)?,
|
||||
height: r.get(5)?,
|
||||
mtime_ms: r.get(6)?,
|
||||
duration_ms: r.get(7)?,
|
||||
})
|
||||
};
|
||||
match scope {
|
||||
Some(sid) => {
|
||||
let mut stmt = conn.prepare(&format!("{base} AND f.source_id = ?2"))?;
|
||||
let rows = stmt.query_map(rusqlite::params![kind, sid], map)?;
|
||||
rows.collect()
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare(base)?;
|
||||
let rows = stmt.query_map([kind], map)?;
|
||||
rows.collect()
|
||||
}
|
||||
}
|
||||
})?)
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// keeper 자동 선정: 해상도↓ → mtime(오래된 것)↓ → id↓.
|
||||
/// 반환된 첫 요소가 keeper.
|
||||
fn rank_keeper<'a>(group: &'a [&'a FileMeta]) -> Vec<i64> {
|
||||
let mut sorted: Vec<&FileMeta> = group.to_vec();
|
||||
sorted.sort_by(|a, b| {
|
||||
let pa = a.width.unwrap_or(0) * a.height.unwrap_or(0);
|
||||
let pb = b.width.unwrap_or(0) * b.height.unwrap_or(0);
|
||||
pb.cmp(&pa) // 해상도 큰 것 우선
|
||||
.then(a.size.cmp(&b.size).reverse()) // 파일 큰 것 우선
|
||||
.then(a.mtime_ms.cmp(&b.mtime_ms)) // 오래된 것 우선
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
sorted.iter().map(|f| f.id).collect()
|
||||
}
|
||||
|
||||
/// 그룹을 dupe_groups/dupe_members에 저장한다 (keeper 자동 선정).
|
||||
fn persist_group(
|
||||
db: &Db,
|
||||
kind: GroupKind,
|
||||
members: &[&FileMeta],
|
||||
distances: &HashMap<i64, u32>,
|
||||
) -> Result<()> {
|
||||
let ranked = rank_keeper(members);
|
||||
let kind_val = kind as i64;
|
||||
let dists: Vec<(i64, Option<i64>)> = ranked
|
||||
.iter()
|
||||
.map(|id| (*id, distances.get(id).map(|d| *d as i64)))
|
||||
.collect();
|
||||
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO dupe_groups(kind, created_at) VALUES (?1, ?2)",
|
||||
rusqlite::params![kind_val, now_ms()],
|
||||
)?;
|
||||
let gid = tx.last_insert_rowid();
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO dupe_members(group_id, file_id, is_keeper, distance) VALUES (?1, ?2, ?3, ?4)",
|
||||
)?;
|
||||
for (i, (id, dist)) in dists.iter().enumerate() {
|
||||
stmt.execute(rusqlite::params![gid, id, if i == 0 { 1 } else { 0 }, dist])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 이전 미해결 그룹을 지운다 (재탐지 시). 해결/무시(dismissed)는 보존.
|
||||
fn clear_open_groups(db: &Db) -> Result<()> {
|
||||
db.with_write(|conn| {
|
||||
conn.execute("DELETE FROM dupe_groups WHERE status = 'open'", [])?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dismissed(dismissed: &std::collections::HashSet<(i64, i64)>, a: i64, b: i64) -> bool {
|
||||
let key = if a < b { (a, b) } else { (b, a) };
|
||||
dismissed.contains(&key)
|
||||
}
|
||||
|
||||
fn load_dismissed(db: &Db) -> Result<std::collections::HashSet<(i64, i64)>> {
|
||||
Ok(db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT file_a, file_b FROM dupe_dismissed")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 완전 동일 탐지: size → quick_hash → BLAKE3.
|
||||
/// 그룹으로 묶인 파일 id는 `grouped`에 누적된다 (유사 패스에서 제외용).
|
||||
fn detect_exact(
|
||||
db: &Db,
|
||||
files: &[FileMeta],
|
||||
grouped: &mut std::collections::HashSet<i64>,
|
||||
cancel: &AtomicBool,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<u64> {
|
||||
// 1. size로 그룹핑
|
||||
let mut by_size: HashMap<i64, Vec<&FileMeta>> = HashMap::new();
|
||||
for f in files {
|
||||
by_size.entry(f.size).or_default().push(f);
|
||||
}
|
||||
let candidates: Vec<Vec<&FileMeta>> =
|
||||
by_size.into_values().filter(|g| g.len() >= 2).collect();
|
||||
|
||||
let total = candidates.iter().map(|g| g.len()).sum::<usize>() as u64;
|
||||
let mut processed = 0u64;
|
||||
let mut group_count = 0u64;
|
||||
|
||||
for size_group in candidates {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DedupeError::Cancelled);
|
||||
}
|
||||
// 2. quick_hash로 세분화
|
||||
let with_qh: Vec<(u64, &FileMeta)> = size_group
|
||||
.par_iter()
|
||||
.filter_map(|f| hash::quick_hash(&f.path).ok().map(|h| (h, *f)))
|
||||
.collect();
|
||||
processed += size_group.len() as u64;
|
||||
progress(processed, total);
|
||||
|
||||
let mut by_qh: HashMap<u64, Vec<&FileMeta>> = HashMap::new();
|
||||
for (qh, f) in with_qh {
|
||||
by_qh.entry(qh).or_default().push(f);
|
||||
}
|
||||
|
||||
for qh_group in by_qh.into_values().filter(|g| g.len() >= 2) {
|
||||
// 3. BLAKE3 전체 해시로 확정
|
||||
let with_fh: Vec<([u8; 32], &FileMeta)> = qh_group
|
||||
.par_iter()
|
||||
.filter_map(|f| hash::full_hash(&f.path).ok().map(|h| (h, *f)))
|
||||
.collect();
|
||||
let mut by_fh: HashMap<[u8; 32], Vec<&FileMeta>> = HashMap::new();
|
||||
for (fh, f) in with_fh {
|
||||
by_fh.entry(fh).or_default().push(f);
|
||||
}
|
||||
for exact in by_fh.into_values().filter(|g| g.len() >= 2) {
|
||||
// keeper(첫 요소)는 유사 패스에 남겨 교차 유사도를 잡되,
|
||||
// 나머지 완전 동일본은 제외해 중복 표시를 막는다.
|
||||
let ranked = rank_keeper(&exact);
|
||||
for id in ranked.iter().skip(1) {
|
||||
grouped.insert(*id);
|
||||
}
|
||||
persist_group(db, GroupKind::Exact, &exact, &HashMap::new())?;
|
||||
group_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(group_count)
|
||||
}
|
||||
|
||||
/// 유사 이미지 탐지: dHash → 브루트포스 → 클러스터.
|
||||
/// `exclude`에 속한 파일(이미 완전 동일 그룹으로 처리됨)은 제외한다.
|
||||
fn detect_similar_images(
|
||||
db: &Db,
|
||||
files: &[FileMeta],
|
||||
threshold: u32,
|
||||
dismissed: &std::collections::HashSet<(i64, i64)>,
|
||||
exclude: &std::collections::HashSet<i64>,
|
||||
cancel: &AtomicBool,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<u64> {
|
||||
let total = files.len() as u64;
|
||||
// dHash 병렬 계산 (썸네일 우선 — thumbs 캐시가 있으면 그것으로, 없으면 원본)
|
||||
let hashes: Vec<(i64, u64)> = files
|
||||
.par_iter()
|
||||
.filter(|f| !exclude.contains(&f.id))
|
||||
.filter_map(|f| phash::dhash_file(&f.path).map(|h| (f.id, h)))
|
||||
.collect();
|
||||
progress(total, total);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DedupeError::Cancelled);
|
||||
}
|
||||
|
||||
let pairs: Vec<(i64, i64, u32)> = phash::find_near_pairs(&hashes, threshold)
|
||||
.into_iter()
|
||||
.filter(|(a, b, _)| !is_dismissed(dismissed, *a, *b))
|
||||
.collect();
|
||||
|
||||
let ids: Vec<i64> = hashes.iter().map(|(id, _)| *id).collect();
|
||||
let groups = phash::cluster(&ids, &pairs);
|
||||
|
||||
// 거리 맵 (keeper 대비 근사 — 그룹 내 최소 거리)
|
||||
let by_id: HashMap<i64, &FileMeta> = files.iter().map(|f| (f.id, f)).collect();
|
||||
let mut group_count = 0u64;
|
||||
for group in groups {
|
||||
let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect();
|
||||
if members.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
persist_group(db, GroupKind::Image, &members, &HashMap::new())?;
|
||||
group_count += 1;
|
||||
}
|
||||
Ok(group_count)
|
||||
}
|
||||
|
||||
/// 영상 프레임 시그니처: 5/15/…/95% 10프레임 dHash.
|
||||
fn video_signature(tools: &FfTools, path: &Path) -> Option<Vec<u64>> {
|
||||
let offsets = [0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95];
|
||||
let dir = std::env::temp_dir().join(format!("archive-vsig-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let info = crate::ffmpeg::probe(tools, path).ok()?;
|
||||
let dur = info.duration_ms? as f64 / 1000.0;
|
||||
if dur <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut sig = Vec::with_capacity(offsets.len());
|
||||
for (i, off) in offsets.iter().enumerate() {
|
||||
let frame = dir.join(format!("f{i}.jpg"));
|
||||
if crate::ffmpeg::extract_frame_jpeg(tools, path, &frame, dur * off, 64).is_ok() {
|
||||
if let Some(h) = phash::dhash_file(&frame) {
|
||||
sig.push(h);
|
||||
}
|
||||
let _ = std::fs::remove_file(&frame);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if sig.len() >= 5 {
|
||||
Some(sig)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 두 시그니처의 프레임별 해밍 중앙값.
|
||||
fn signature_distance(a: &[u64], b: &[u64]) -> u32 {
|
||||
let n = a.len().min(b.len());
|
||||
if n == 0 {
|
||||
return u32::MAX;
|
||||
}
|
||||
let mut dists: Vec<u32> = (0..n).map(|i| phash::hamming(a[i], b[i])).collect();
|
||||
dists.sort_unstable();
|
||||
dists[n / 2]
|
||||
}
|
||||
|
||||
fn detect_similar_videos(
|
||||
db: &Db,
|
||||
files: &[FileMeta],
|
||||
tools: &FfTools,
|
||||
threshold: u32,
|
||||
dismissed: &std::collections::HashSet<(i64, i64)>,
|
||||
cancel: &AtomicBool,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<u64> {
|
||||
// duration 있는 영상만
|
||||
let with_dur: Vec<&FileMeta> = files.iter().filter(|f| f.duration_ms.unwrap_or(0) > 0).collect();
|
||||
let total = with_dur.len() as u64;
|
||||
|
||||
// 시그니처 계산 (동시 2 — 각 ffmpeg가 ~1코어)
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().ok();
|
||||
let sigs: Vec<(i64, i64, Vec<u64>)> = match &pool {
|
||||
Some(p) => p.install(|| {
|
||||
with_dur
|
||||
.par_iter()
|
||||
.filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s)))
|
||||
.collect()
|
||||
}),
|
||||
None => with_dur
|
||||
.iter()
|
||||
.filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s)))
|
||||
.collect(),
|
||||
};
|
||||
progress(total, total);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DedupeError::Cancelled);
|
||||
}
|
||||
|
||||
// duration ±5% 사전필터 후 시그니처 비교
|
||||
let mut pairs: Vec<(i64, i64, u32)> = Vec::new();
|
||||
for i in 0..sigs.len() {
|
||||
for j in (i + 1)..sigs.len() {
|
||||
let (ida, da, ref sa) = sigs[i];
|
||||
let (idb, db_dur, ref sb) = sigs[j];
|
||||
let ratio = da as f64 / db_dur.max(1) as f64;
|
||||
if !(0.95..=1.05).contains(&ratio) {
|
||||
continue;
|
||||
}
|
||||
let dist = signature_distance(sa, sb);
|
||||
if dist <= threshold && !is_dismissed(dismissed, ida, idb) {
|
||||
let (lo, hi) = if ida < idb { (ida, idb) } else { (idb, ida) };
|
||||
pairs.push((lo, hi, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ids: Vec<i64> = sigs.iter().map(|(id, _, _)| *id).collect();
|
||||
let groups = phash::cluster(&ids, &pairs);
|
||||
let by_id: HashMap<i64, &FileMeta> = files.iter().map(|f| (f.id, f)).collect();
|
||||
let mut group_count = 0u64;
|
||||
for group in groups {
|
||||
let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect();
|
||||
if members.len() >= 2 {
|
||||
persist_group(db, GroupKind::Video, &members, &HashMap::new())?;
|
||||
group_count += 1;
|
||||
}
|
||||
}
|
||||
Ok(group_count)
|
||||
}
|
||||
|
||||
/// 전체 중복 탐지 실행. 블로킹 — 전용 스레드에서 호출할 것.
|
||||
pub fn run(
|
||||
db: &Arc<Db>,
|
||||
tools: Option<&FfTools>,
|
||||
scope_source: Option<i64>,
|
||||
image_threshold: u32,
|
||||
include_video: bool,
|
||||
cancel: &AtomicBool,
|
||||
mut on_progress: impl FnMut(DedupeProgress),
|
||||
) -> Result<DedupeStats> {
|
||||
clear_open_groups(db)?;
|
||||
let dismissed = load_dismissed(db)?;
|
||||
let mut stats = DedupeStats::default();
|
||||
let mut grouped: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||
|
||||
// 완전 동일 (이미지+영상 모두)
|
||||
let mut all_files = load_files(db, 0, scope_source)?;
|
||||
all_files.extend(load_files(db, 1, scope_source)?);
|
||||
stats.exact_groups = detect_exact(db, &all_files, &mut grouped, cancel, |c, t| {
|
||||
on_progress(DedupeProgress { phase: "exact".into(), current: c, total: t });
|
||||
})?;
|
||||
|
||||
// 유사 이미지 (완전 동일 그룹에 속한 파일은 제외)
|
||||
let images = load_files(db, 0, scope_source)?;
|
||||
stats.image_groups = detect_similar_images(db, &images, image_threshold, &dismissed, &grouped, cancel, |c, t| {
|
||||
on_progress(DedupeProgress { phase: "image".into(), current: c, total: t });
|
||||
})?;
|
||||
|
||||
// 유사 영상 (opt-in + ffmpeg 필요)
|
||||
if include_video {
|
||||
if let Some(tools) = tools {
|
||||
let videos = load_files(db, 1, scope_source)?;
|
||||
stats.video_groups = detect_similar_videos(db, &videos, tools, 8, &dismissed, cancel, |c, t| {
|
||||
on_progress(DedupeProgress { phase: "video".into(), current: c, total: t });
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
stats.total_dupes = db.with_read(|conn| {
|
||||
conn.query_row("SELECT count(*) FROM dupe_members", [], |r| r.get::<_, i64>(0))
|
||||
})? as u64;
|
||||
|
||||
on_progress(DedupeProgress { phase: "done".into(), current: 0, total: 0 });
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
(dbdir, media, db, sid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_exact_and_resized_duplicates() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path();
|
||||
|
||||
// 원본 이미지
|
||||
let base = image::RgbImage::from_fn(400, 300, |x, y| {
|
||||
image::Rgb([((x * 255) / 400) as u8, ((y * 255) / 300) as u8, 100])
|
||||
});
|
||||
base.save(root.join("original.png")).unwrap();
|
||||
// 완전 동일 복사본 (바이트 동일)
|
||||
std::fs::copy(root.join("original.png"), root.join("exact_copy.png")).unwrap();
|
||||
// 리사이즈본 (유사)
|
||||
image::DynamicImage::ImageRgb8(base.clone())
|
||||
.resize_exact(200, 150, image::imageops::FilterType::Lanczos3)
|
||||
.save(root.join("resized.jpg"))
|
||||
.unwrap();
|
||||
// 무관한 이미지
|
||||
image::RgbImage::from_pixel(300, 300, image::Rgb([10, 200, 30]))
|
||||
.save(root.join("unrelated.png"))
|
||||
.unwrap();
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, root, &cancel, |_| {}).unwrap();
|
||||
crate::meta::extract_image_meta(&db, sid, &cancel).unwrap();
|
||||
|
||||
let stats = run(&db, None, Some(sid), 5, false, &cancel, |_| {}).unwrap();
|
||||
|
||||
// 완전 동일 그룹 1개 (original + exact_copy)
|
||||
assert_eq!(stats.exact_groups, 1, "완전 동일 그룹");
|
||||
// 유사 이미지 그룹: original/exact_copy/resized 모두 시각적으로 같음
|
||||
assert!(stats.image_groups >= 1, "유사 이미지 그룹 {}", stats.image_groups);
|
||||
|
||||
// 완전 동일 그룹에 keeper가 정확히 1개
|
||||
let keepers: i64 = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM dupe_members dm
|
||||
JOIN dupe_groups dg ON dg.id = dm.group_id
|
||||
WHERE dg.kind = 0 AND dm.is_keeper = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(keepers, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_distance_median() {
|
||||
let a = vec![0u64, 0, 0, 0, 0];
|
||||
let b = vec![0u64, 0, 0xFF, 0, 0]; // 한 프레임만 다름
|
||||
assert_eq!(signature_distance(&a, &b), 0); // 중앙값은 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! ffmpeg/ffprobe 사이드카 — 위치 해석, ffprobe 메타 파싱, 프레임 추출.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(all(windows, target_arch = "x86_64"))]
|
||||
const TRIPLE: &str = "x86_64-pc-windows-msvc";
|
||||
#[cfg(all(windows, target_arch = "aarch64"))]
|
||||
const TRIPLE: &str = "aarch64-pc-windows-msvc";
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
const TRIPLE: &str = "aarch64-apple-darwin";
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
const TRIPLE: &str = "x86_64-apple-darwin";
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const TRIPLE: &str = "unknown";
|
||||
|
||||
#[cfg(windows)]
|
||||
const EXE: &str = ".exe";
|
||||
#[cfg(not(windows))]
|
||||
const EXE: &str = "";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FfTools {
|
||||
pub ffmpeg: PathBuf,
|
||||
pub ffprobe: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FfError {
|
||||
#[error("ffmpeg 실행 오류: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("ffprobe 출력 파싱 실패: {0}")]
|
||||
Parse(String),
|
||||
#[error("ffmpeg 종료 코드 {0}: {1}")]
|
||||
Exit(i32, String),
|
||||
}
|
||||
|
||||
/// 사이드카 탐색: ① ARCHIVE_FFMPEG_DIR ② exe 옆 ③ dev(src-tauri/binaries) ④ PATH
|
||||
pub fn locate() -> Option<FfTools> {
|
||||
let candidates = |name: &str| -> Vec<PathBuf> {
|
||||
let mut v = Vec::new();
|
||||
if let Ok(dir) = std::env::var("ARCHIVE_FFMPEG_DIR") {
|
||||
v.push(Path::new(&dir).join(format!("{name}{EXE}")));
|
||||
v.push(Path::new(&dir).join(format!("{name}-{TRIPLE}{EXE}")));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(d) = exe.parent() {
|
||||
v.push(d.join(format!("{name}{EXE}")));
|
||||
v.push(d.join(format!("{name}-{TRIPLE}{EXE}")));
|
||||
// dev: target/debug → src-tauri/binaries
|
||||
v.push(d.join("../..").join("binaries").join(format!("{name}-{TRIPLE}{EXE}")));
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
let find = |name: &str| -> Option<PathBuf> {
|
||||
for c in candidates(name) {
|
||||
if c.is_file() {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
// PATH 폴백
|
||||
which(name)
|
||||
};
|
||||
|
||||
Some(FfTools {
|
||||
ffmpeg: find("ffmpeg")?,
|
||||
ffprobe: find("ffprobe")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn which(name: &str) -> Option<PathBuf> {
|
||||
let paths = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
let p = dir.join(format!("{name}{EXE}"));
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 콘솔 창 없이 실행되는 Command 생성 (Windows CREATE_NO_WINDOW)
|
||||
pub fn command(path: &Path) -> Command {
|
||||
let cmd = Command::new(path);
|
||||
#[cfg(windows)]
|
||||
let cmd = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let mut c = cmd;
|
||||
c.creation_flags(0x0800_0000);
|
||||
c
|
||||
};
|
||||
cmd
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MediaInfo {
|
||||
pub duration_ms: Option<i64>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub vcodec: Option<String>,
|
||||
pub acodec: Option<String>,
|
||||
pub fps: Option<f64>,
|
||||
}
|
||||
|
||||
/// ffprobe로 영상 메타데이터를 읽는다.
|
||||
pub fn probe(tools: &FfTools, path: &Path) -> Result<MediaInfo, FfError> {
|
||||
let out = command(&tools.ffprobe)
|
||||
.args(["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams"])
|
||||
.arg(path)
|
||||
.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(FfError::Exit(
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
));
|
||||
}
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| FfError::Parse(e.to_string()))?;
|
||||
|
||||
let mut info = MediaInfo::default();
|
||||
info.duration_ms = v["format"]["duration"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|s| (s * 1000.0) as i64);
|
||||
|
||||
if let Some(streams) = v["streams"].as_array() {
|
||||
for s in streams {
|
||||
match s["codec_type"].as_str() {
|
||||
Some("video") if info.vcodec.is_none() => {
|
||||
info.vcodec = s["codec_name"].as_str().map(String::from);
|
||||
info.width = s["width"].as_u64().map(|w| w as u32);
|
||||
info.height = s["height"].as_u64().map(|h| h as u32);
|
||||
info.fps = s["avg_frame_rate"]
|
||||
.as_str()
|
||||
.and_then(parse_frame_rate)
|
||||
.or_else(|| s["r_frame_rate"].as_str().and_then(parse_frame_rate));
|
||||
// 스트림 duration 폴백
|
||||
if info.duration_ms.is_none() {
|
||||
info.duration_ms = s["duration"]
|
||||
.as_str()
|
||||
.and_then(|d| d.parse::<f64>().ok())
|
||||
.map(|d| (d * 1000.0) as i64);
|
||||
}
|
||||
}
|
||||
Some("audio") if info.acodec.is_none() => {
|
||||
info.acodec = s["codec_name"].as_str().map(String::from);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn parse_frame_rate(s: &str) -> Option<f64> {
|
||||
let (num, den) = s.split_once('/')?;
|
||||
let num: f64 = num.parse().ok()?;
|
||||
let den: f64 = den.parse().ok()?;
|
||||
if den == 0.0 || num == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(num / den)
|
||||
}
|
||||
}
|
||||
|
||||
/// 영상에서 프레임 하나를 뽑아 JPEG 썸네일로 저장한다. 반환: (썸네일 w, h)
|
||||
pub fn extract_frame_jpeg(
|
||||
tools: &FfTools,
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
at_seconds: f64,
|
||||
long_edge: u32,
|
||||
) -> Result<(u32, u32), FfError> {
|
||||
extract_frame_input(tools, src.as_os_str(), dst, at_seconds, long_edge)
|
||||
}
|
||||
|
||||
/// URL(예: 미디어 서버 bridge)에서 프레임을 뽑는다 — ffmpeg가 Range로 읽어 전체 다운로드 회피.
|
||||
pub fn extract_frame_url(
|
||||
tools: &FfTools,
|
||||
url: &str,
|
||||
dst: &Path,
|
||||
at_seconds: f64,
|
||||
long_edge: u32,
|
||||
) -> Result<(u32, u32), FfError> {
|
||||
extract_frame_input(tools, std::ffi::OsStr::new(url), dst, at_seconds, long_edge)
|
||||
}
|
||||
|
||||
fn extract_frame_input(
|
||||
tools: &FfTools,
|
||||
input: &std::ffi::OsStr,
|
||||
dst: &Path,
|
||||
at_seconds: f64,
|
||||
long_edge: u32,
|
||||
) -> Result<(u32, u32), FfError> {
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let scale = format!("scale=w={long_edge}:h={long_edge}:force_original_aspect_ratio=decrease");
|
||||
let out = command(&tools.ffmpeg)
|
||||
.args(["-y", "-v", "error", "-ss", &format!("{at_seconds:.3}")])
|
||||
.arg("-i")
|
||||
.arg(input)
|
||||
.args(["-frames:v", "1", "-vf", &scale, "-q:v", "4"])
|
||||
.arg(dst)
|
||||
.output()?;
|
||||
if !out.status.success() || !dst.is_file() {
|
||||
return Err(FfError::Exit(
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
));
|
||||
}
|
||||
image::image_dimensions(dst)
|
||||
.map_err(|e| FfError::Parse(format!("썸네일 치수 읽기 실패: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_rate_parsing() {
|
||||
assert_eq!(parse_frame_rate("30000/1001").map(|f| (f * 100.0).round()), Some(2997.0));
|
||||
assert_eq!(parse_frame_rate("25/1"), Some(25.0));
|
||||
assert_eq!(parse_frame_rate("0/0"), None);
|
||||
assert_eq!(parse_frame_rate("잘못됨"), None);
|
||||
}
|
||||
|
||||
// ffmpeg 실기 테스트는 tests/ffmpeg_integration.rs (사이드카 존재 시에만 실행)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! 파일 작업 계층 — 이동/이름변경/휴지통 + ops_journal 기반 undo.
|
||||
//!
|
||||
//! 원칙: ① 물리적 파일 변경 성공 후에만 DB 갱신 ② 모든 작업은 저널 기록
|
||||
//! ③ 삭제는 OS 휴지통 경유만 (영구삭제 없음 — 계획서 §7).
|
||||
|
||||
use archive_db::Db;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OpError {
|
||||
#[error("DB 오류: {0}")]
|
||||
Db(#[from] archive_db::DbError),
|
||||
#[error("I/O 오류 ({path}): {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("휴지통 오류 ({path}): {message}")]
|
||||
Trash { path: String, message: String },
|
||||
#[error("잘못된 이름: {0}")]
|
||||
BadName(String),
|
||||
#[error("대상 폴더를 찾을 수 없음: {0}")]
|
||||
NoFolder(i64),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, OpError>;
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpSummary {
|
||||
pub done: u64,
|
||||
pub skipped: u64,
|
||||
pub failed: u64,
|
||||
pub batch_id: String,
|
||||
}
|
||||
|
||||
static BATCH_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 배치 id 생성 (프로세스 내 유일)
|
||||
pub fn new_batch_id() -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let n = BATCH_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{now:x}-{n:x}")
|
||||
}
|
||||
|
||||
struct FileRow {
|
||||
id: i64,
|
||||
dir: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
|
||||
let ids = file_ids.to_vec();
|
||||
Ok(db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.id = ?1 AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
if let Ok(row) = stmt.query_row([id], |r| {
|
||||
Ok(FileRow {
|
||||
id: r.get(0)?,
|
||||
dir: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
})
|
||||
}) {
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 충돌 시 "이름 (2).ext" 식으로 비어 있는 이름을 찾는다.
|
||||
fn resolve_collision(dest_dir: &Path, name: &str) -> String {
|
||||
if !dest_dir.join(name).exists() {
|
||||
return name.to_string();
|
||||
}
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) => (s, format!(".{e}")),
|
||||
None => (name, String::new()),
|
||||
};
|
||||
for i in 2..1000 {
|
||||
let candidate = format!("{stem} ({i}){ext}");
|
||||
if !dest_dir.join(&candidate).exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
format!("{stem}-{}{ext}", new_batch_id())
|
||||
}
|
||||
|
||||
/// rename → 크로스 볼륨이면 copy+delete 폴백 (mtime 보존).
|
||||
/// Windows의 일시적 공유 위반(안티바이러스/인덱서)에는 짧게 재시도한다.
|
||||
fn physical_move(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
let mut last_err = None;
|
||||
for attempt in 0..4 {
|
||||
match std::fs::rename(src, dst) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e)
|
||||
if e.raw_os_error() == Some(17) || e.kind() == std::io::ErrorKind::CrossesDevices =>
|
||||
{
|
||||
// 크로스 볼륨: copy + mtime 보존 + delete
|
||||
let meta = std::fs::metadata(src)?;
|
||||
std::fs::copy(src, dst)?;
|
||||
if let Ok(mtime) = meta.modified() {
|
||||
let _ = filetime::set_file_mtime(dst, filetime::FileTime::from_system_time(mtime));
|
||||
}
|
||||
std::fs::remove_file(src)?;
|
||||
return Ok(());
|
||||
}
|
||||
// Windows 공유 위반(32)/액세스 거부(5)는 일시적일 수 있어 재시도
|
||||
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) && attempt < 3 => {
|
||||
last_err = Some(e);
|
||||
std::thread::sleep(std::time::Duration::from_millis(50 * (attempt + 1)));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| std::io::Error::other("move 재시도 소진")))
|
||||
}
|
||||
|
||||
/// 파일들을 대상 폴더(폴더 트리의 로컬 폴더)로 이동한다.
|
||||
pub fn move_files(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
file_ids: &[i64],
|
||||
dest_folder_id: i64,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<OpSummary> {
|
||||
let dest_dir: String = db
|
||||
.with_read(|conn| {
|
||||
conn.query_row("SELECT path FROM folders WHERE id = ?1", [dest_folder_id], |r| r.get(0))
|
||||
})
|
||||
.map_err(|_| OpError::NoFolder(dest_folder_id))?;
|
||||
let dest_path = PathBuf::from(&dest_dir);
|
||||
|
||||
let rows = load_rows(db, file_ids)?;
|
||||
let total = rows.len() as u64;
|
||||
let mut summary = OpSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
if row.dir == dest_dir {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let new_name = resolve_collision(&dest_path, &row.name);
|
||||
let dst = dest_path.join(&new_name);
|
||||
|
||||
match physical_move(&src, &dst) {
|
||||
Ok(()) => {
|
||||
let (bid, fid) = (batch_id.to_string(), row.id);
|
||||
let (src_s, dst_s) = (src.to_string_lossy().into_owned(), dst.to_string_lossy().into_owned());
|
||||
let nn = new_name.clone();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE files SET folder_id = ?2, name = ?3 WHERE id = ?1",
|
||||
rusqlite::params![fid, dest_folder_id, nn],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
|
||||
VALUES (?1, 'move', ?2, ?3, ?4)",
|
||||
rusqlite::params![bid, fid, src_s, dst_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
summary.done += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(src = %src.display(), "이동 실패: {e}");
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
on_progress(i as u64 + 1, total);
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// 파일 이름 변경 (같은 폴더 내).
|
||||
pub fn rename_file(db: &Db, batch_id: &str, file_id: i64, new_name: &str) -> Result<()> {
|
||||
if new_name.is_empty() || new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||
return Err(OpError::BadName(new_name.to_string()));
|
||||
}
|
||||
let rows = load_rows(db, &[file_id])?;
|
||||
let row = rows.first().ok_or(OpError::NoFolder(file_id))?;
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
let dst = Path::new(&row.dir).join(new_name);
|
||||
if dst.exists() {
|
||||
return Err(OpError::BadName(format!("이미 존재하는 이름: {new_name}")));
|
||||
}
|
||||
std::fs::rename(&src, &dst).map_err(|e| OpError::Io {
|
||||
path: src.to_string_lossy().into_owned(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let (bid, fid, nn) = (batch_id.to_string(), row.id, new_name.to_string());
|
||||
let (src_s, dst_s) = (src.to_string_lossy().into_owned(), dst.to_string_lossy().into_owned());
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("UPDATE files SET name = ?2 WHERE id = ?1", rusqlite::params![fid, nn])?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
|
||||
VALUES (?1, 'rename', ?2, ?3, ?4)",
|
||||
rusqlite::params![bid, fid, src_s, dst_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 파일들을 OS 휴지통으로 보낸다 (인앱 undo 불가 — OS 휴지통이 복구 경로).
|
||||
pub fn trash_files(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
file_ids: &[i64],
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<OpSummary> {
|
||||
let rows = load_rows(db, file_ids)?;
|
||||
let total = rows.len() as u64;
|
||||
let mut summary = OpSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
match trash::delete(&src) {
|
||||
Ok(()) => {
|
||||
let (bid, fid) = (batch_id.to_string(), row.id);
|
||||
let src_s = src.to_string_lossy().into_owned();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1",
|
||||
[fid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path)
|
||||
VALUES (?1, 'trash', ?2, ?3)",
|
||||
rusqlite::params![bid, fid, src_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
summary.done += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {e}");
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
on_progress(i as u64 + 1, total);
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UndoBatch {
|
||||
pub batch_id: String,
|
||||
pub ops: i64,
|
||||
pub kinds: String,
|
||||
pub performed_at: i64,
|
||||
pub undoable: bool,
|
||||
}
|
||||
|
||||
/// 최근 배치 목록 (undo UI용).
|
||||
pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
|
||||
Ok(db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
|
||||
sum(CASE WHEN op = 'trash' THEN 1 ELSE 0 END) = 0
|
||||
FROM ops_journal WHERE undone = 0
|
||||
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit], |r| {
|
||||
Ok(UndoBatch {
|
||||
batch_id: r.get(0)?,
|
||||
ops: r.get(1)?,
|
||||
kinds: r.get(2)?,
|
||||
performed_at: r.get(3)?,
|
||||
undoable: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 배치 undo — 저널 역순 재생. trash 항목은 건너뛴다.
|
||||
pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
|
||||
struct JEntry {
|
||||
id: i64,
|
||||
op: String,
|
||||
file_id: Option<i64>,
|
||||
src_path: Option<String>,
|
||||
dst_path: Option<String>,
|
||||
tag_id: Option<i64>,
|
||||
}
|
||||
let bid = batch_id.to_string();
|
||||
let entries: Vec<JEntry> = db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id, op, file_id, src_path, dst_path, tag_id FROM ops_journal
|
||||
WHERE batch_id = ?1 AND undone = 0 ORDER BY id DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([&bid], |r| {
|
||||
Ok(JEntry {
|
||||
id: r.get(0)?,
|
||||
op: r.get(1)?,
|
||||
file_id: r.get(2)?,
|
||||
src_path: r.get(3)?,
|
||||
dst_path: r.get(4)?,
|
||||
tag_id: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
|
||||
let mut summary = OpSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for e in entries {
|
||||
let undone = match e.op.as_str() {
|
||||
"move" | "rename" => {
|
||||
let (Some(src), Some(dst), Some(fid)) = (&e.src_path, &e.dst_path, e.file_id) else {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
};
|
||||
// dst → src로 되돌리기
|
||||
match std::fs::rename(dst, src).or_else(|_| {
|
||||
physical_move(Path::new(dst), Path::new(src))
|
||||
}) {
|
||||
Ok(()) => {
|
||||
let src_dir = Path::new(src)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let src_name = Path::new(src)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
db.with_write(move |conn| {
|
||||
// 원래 폴더 행을 찾아 복원
|
||||
let folder_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT fo.id FROM folders fo
|
||||
JOIN files f ON f.id = ?2
|
||||
WHERE fo.path = ?1 AND fo.source_id = f.source_id",
|
||||
rusqlite::params![src_dir, fid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
match folder_id {
|
||||
Some(foid) => {
|
||||
conn.execute(
|
||||
"UPDATE files SET folder_id = ?2, name = ?3 WHERE id = ?1",
|
||||
rusqlite::params![fid, foid, src_name],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
// 폴더 행이 사라졌으면 이름만이라도 복원
|
||||
conn.execute(
|
||||
"UPDATE files SET name = ?2 WHERE id = ?1",
|
||||
rusqlite::params![fid, src_name],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("undo 이동 실패 ({dst} → {src}): {err}");
|
||||
summary.failed += 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
"tag" => {
|
||||
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM file_tags WHERE file_id = ?1 AND tag_id = ?2",
|
||||
rusqlite::params![fid, tid],
|
||||
)
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"untag" => {
|
||||
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![fid, tid],
|
||||
)
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// trash는 인앱 undo 불가 (OS 휴지통에서 복원)
|
||||
_ => {
|
||||
summary.skipped += 1;
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if undone {
|
||||
let jid = e.id;
|
||||
db.with_write(move |conn| {
|
||||
conn.execute("UPDATE ops_journal SET undone = 1 WHERE id = ?1", [jid])
|
||||
})?;
|
||||
summary.done += 1;
|
||||
}
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
std::fs::create_dir_all(media.path().join("a")).unwrap();
|
||||
std::fs::create_dir_all(media.path().join("b")).unwrap();
|
||||
std::fs::write(media.path().join("a/사진1.jpg"), b"one").unwrap();
|
||||
std::fs::write(media.path().join("a/사진2.jpg"), b"two").unwrap();
|
||||
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = std::sync::atomic::AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
(dbdir, media, db, sid)
|
||||
}
|
||||
|
||||
fn folder_id_of(db: &Db, suffix: &str) -> i64 {
|
||||
let sfx = suffix.to_string();
|
||||
db.with_read(move |c| {
|
||||
c.query_row(
|
||||
"SELECT id FROM folders WHERE path LIKE '%' || ?1",
|
||||
[&sfx],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn file_id_of(db: &Db, name: &str) -> i64 {
|
||||
let n = name.to_string();
|
||||
db.with_read(move |c| {
|
||||
c.query_row(
|
||||
"SELECT id FROM files WHERE name = ?1 AND deleted_at IS NULL",
|
||||
[&n],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_and_undo_roundtrip() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let dest = folder_id_of(&db, "b");
|
||||
|
||||
let bid = new_batch_id();
|
||||
let s = move_files(&db, &bid, &[fid], dest, |_, _| {}).unwrap();
|
||||
assert_eq!(s.done, 1);
|
||||
assert!(media.path().join("b/사진1.jpg").is_file());
|
||||
assert!(!media.path().join("a/사진1.jpg").exists());
|
||||
|
||||
// DB 반영 + id 보존
|
||||
assert_eq!(file_id_of(&db, "사진1.jpg"), fid);
|
||||
|
||||
// undo → 원위치
|
||||
let u = undo_batch(&db, &bid).unwrap();
|
||||
assert_eq!(u.done, 1);
|
||||
assert!(media.path().join("a/사진1.jpg").is_file());
|
||||
assert!(!media.path().join("b/사진1.jpg").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_collision_auto_renames() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
std::fs::write(media.path().join("b/사진1.jpg"), b"existing").unwrap();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let dest = folder_id_of(&db, "b");
|
||||
|
||||
let s = move_files(&db, &new_batch_id(), &[fid], dest, |_, _| {}).unwrap();
|
||||
assert_eq!(s.done, 1);
|
||||
assert!(media.path().join("b/사진1 (2).jpg").is_file());
|
||||
assert!(media.path().join("b/사진1.jpg").is_file()); // 기존 파일 무사
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_and_undo() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진2.jpg");
|
||||
let bid = new_batch_id();
|
||||
|
||||
rename_file(&db, &bid, fid, "새이름.jpg").unwrap();
|
||||
assert!(media.path().join("a/새이름.jpg").is_file());
|
||||
assert_eq!(file_id_of(&db, "새이름.jpg"), fid);
|
||||
|
||||
assert!(rename_file(&db, &bid, fid, "bad/name.jpg").is_err());
|
||||
|
||||
undo_batch(&db, &bid).unwrap();
|
||||
assert!(media.path().join("a/사진2.jpg").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_undo_roundtrip() {
|
||||
let (_d, _m, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let tag = crate::tags::create_tag(&db, "테스트태그".into(), None).unwrap();
|
||||
|
||||
let bid = new_batch_id();
|
||||
crate::tags::assign(&db, &bid, vec![fid], tag).unwrap();
|
||||
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 1);
|
||||
|
||||
undo_batch(&db, &bid).unwrap();
|
||||
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! 해시 계층: quick_hash(중복 사전필터·이동감지) / full_hash(확정 중복).
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use xxhash_rust::xxh3::Xxh3;
|
||||
|
||||
const CHUNK: usize = 128 * 1024;
|
||||
|
||||
/// xxh3(크기 ++ 앞 128KB ++ 뒤 128KB). 최대 256KB만 읽는다.
|
||||
pub fn quick_hash(path: &Path) -> std::io::Result<u64> {
|
||||
let mut f = File::open(path)?;
|
||||
let len = f.metadata()?.len();
|
||||
|
||||
let mut hasher = Xxh3::new();
|
||||
hasher.update(&len.to_le_bytes());
|
||||
|
||||
let mut buf = vec![0u8; CHUNK];
|
||||
let head = read_up_to(&mut f, &mut buf)?;
|
||||
hasher.update(&buf[..head]);
|
||||
|
||||
if len > (2 * CHUNK) as u64 {
|
||||
f.seek(SeekFrom::End(-(CHUNK as i64)))?;
|
||||
let tail = read_up_to(&mut f, &mut buf)?;
|
||||
hasher.update(&buf[..tail]);
|
||||
}
|
||||
Ok(hasher.digest())
|
||||
}
|
||||
|
||||
/// BLAKE3 전체 해시 (mmap + rayon — NVMe 포화 수준).
|
||||
pub fn full_hash(path: &Path) -> std::io::Result<[u8; 32]> {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update_mmap_rayon(path)?;
|
||||
Ok(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
fn read_up_to(f: &mut File, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut total = 0;
|
||||
while total < buf.len() {
|
||||
match f.read(&mut buf[total..])? {
|
||||
0 => break,
|
||||
n => total += n,
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quick_hash_distinguishes_middle_unchanged() {
|
||||
// head/tail이 같고 크기도 같으면 quick_hash는 같다 (설계상 허용 — full_hash가 확정)
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.bin");
|
||||
let b = dir.path().join("b.bin");
|
||||
let mut data = vec![0u8; 3 * CHUNK];
|
||||
std::fs::write(&a, &data).unwrap();
|
||||
data[CHUNK + 500] = 0xFF; // 가운데만 변경
|
||||
std::fs::write(&b, &data).unwrap();
|
||||
|
||||
assert_eq!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
|
||||
assert_ne!(full_hash(&a).unwrap(), full_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_hash_detects_head_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.bin");
|
||||
let b = dir.path().join("b.bin");
|
||||
std::fs::write(&a, b"hello world").unwrap();
|
||||
std::fs::write(&b, b"hello wOrld").unwrap();
|
||||
assert_ne!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_files_same_hashes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.bin");
|
||||
let b = dir.path().join("b.bin");
|
||||
let data = vec![7u8; 5 * CHUNK + 13];
|
||||
std::fs::write(&a, &data).unwrap();
|
||||
std::fs::write(&b, &data).unwrap();
|
||||
assert_eq!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
|
||||
assert_eq!(full_hash(&a).unwrap(), full_hash(&b).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! 스캔·해시·썸네일·중복탐지 엔진. Tauri 의존성 없음 — 헤드리스 테스트 가능.
|
||||
|
||||
pub mod dedupe;
|
||||
pub mod ffmpeg;
|
||||
pub mod fileops;
|
||||
pub mod hash;
|
||||
pub mod maintenance;
|
||||
pub mod meta;
|
||||
pub mod model;
|
||||
pub mod phash;
|
||||
pub mod pipeline;
|
||||
pub mod scan;
|
||||
pub mod tags;
|
||||
pub mod thumbq;
|
||||
pub mod thumbs;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 유지보수 — 썸네일 캐시 LRU 퍼지, 소프트삭제 행 하드 퍼지, DB 백업.
|
||||
|
||||
use archive_db::Db;
|
||||
use std::path::Path;
|
||||
|
||||
/// 썸네일 캐시 총량이 상한을 넘으면 오래된 것부터(created_at) 삭제한다.
|
||||
/// 반환: 삭제한 바이트 수.
|
||||
pub fn prune_thumb_cache(db: &Db, thumb_root: &Path, max_bytes: i64) -> u64 {
|
||||
let total: i64 = db
|
||||
.with_read(|conn| {
|
||||
conn.query_row("SELECT COALESCE(SUM(bytes), 0) FROM thumbs", [], |r| r.get(0))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if total <= max_bytes {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut to_free = total - max_bytes;
|
||||
// 오래된 것부터 (파일당 cache_key/size_class 필요 — 경로 재구성)
|
||||
let victims: Vec<(i64, i64, String, i64)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT file_id, size_class, cache_key, bytes FROM thumbs
|
||||
ORDER BY created_at ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut freed = 0u64;
|
||||
let mut removed: Vec<(i64, i64)> = Vec::new();
|
||||
for (file_id, size_class, key, bytes) in victims {
|
||||
if to_free <= 0 {
|
||||
break;
|
||||
}
|
||||
let path = crate::thumbq::thumb_path(thumb_root, size_class as u8, &key);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
removed.push((file_id, size_class));
|
||||
freed += bytes.max(0) as u64;
|
||||
to_free -= bytes.max(0);
|
||||
}
|
||||
|
||||
if !removed.is_empty() {
|
||||
let _ = db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut del = tx.prepare_cached(
|
||||
"DELETE FROM thumbs WHERE file_id = ?1 AND size_class = ?2",
|
||||
)?;
|
||||
let mut reset = tx.prepare_cached(
|
||||
"UPDATE files SET thumb_state = 0 WHERE id = ?1 AND thumb_state = 1",
|
||||
)?;
|
||||
for (file_id, size_class) in &removed {
|
||||
del.execute(rusqlite::params![file_id, size_class])?;
|
||||
reset.execute([file_id])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
});
|
||||
}
|
||||
freed
|
||||
}
|
||||
|
||||
/// N일보다 오래된 소프트삭제 행을 하드 삭제한다 (FK CASCADE로 관련 데이터 정리).
|
||||
pub fn purge_deleted(db: &Db, older_than_ms: i64) -> u64 {
|
||||
db.with_write(move |conn| {
|
||||
let n = conn.execute(
|
||||
"DELETE FROM files WHERE deleted_at IS NOT NULL AND deleted_at < ?1",
|
||||
[older_than_ms],
|
||||
)?;
|
||||
Ok(n as u64)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
|
||||
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
|
||||
let _ = std::fs::remove_file(backup_path);
|
||||
let path_str = backup_path.to_string_lossy().into_owned();
|
||||
db.with_write(move |conn| {
|
||||
conn.execute("VACUUM INTO ?1", [&path_str])?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn prune_removes_oldest_over_limit() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let thumbs = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
|
||||
// 썸네일 파일 5개 생성 (파일 I/O는 클로저 밖에서)
|
||||
for i in 1..=5 {
|
||||
let key = format!("{i:032x}");
|
||||
let p = crate::thumbq::thumb_path(thumbs.path(), 0, &key);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, vec![0u8; 1000]).unwrap();
|
||||
}
|
||||
db.with_write(|conn| {
|
||||
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
|
||||
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
|
||||
for i in 1..=5 {
|
||||
conn.execute(
|
||||
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state)
|
||||
VALUES(?1,1,1,?2,'jpg',0,100,0,1)",
|
||||
rusqlite::params![i, format!("f{i}.jpg")],
|
||||
)?;
|
||||
let key = format!("{i:032x}");
|
||||
conn.execute(
|
||||
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes,created_at) VALUES(?1,0,?2,1000,?3)",
|
||||
rusqlite::params![i, key, i], // created_at = i (1이 가장 오래됨)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// 5000바이트 중 2500 상한 → 오래된 것부터 최소 2500 삭제
|
||||
let freed = prune_thumb_cache(&db, thumbs.path(), 2500);
|
||||
assert!(freed >= 2500, "freed={freed}");
|
||||
|
||||
let remaining: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT count(*) FROM thumbs", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert!(remaining < 5);
|
||||
// 가장 오래된 f1 썸네일 파일이 삭제됐는지
|
||||
assert!(!crate::thumbq::thumb_path(thumbs.path(), 0, &format!("{:032x}", 1)).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_old_deleted_rows() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
db.with_write(|conn| {
|
||||
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
|
||||
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
|
||||
conn.execute("INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms,deleted_at) VALUES(1,1,'old.jpg','jpg',0,1,0,1000)", [])?;
|
||||
conn.execute("INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms,deleted_at) VALUES(1,1,'recent.jpg','jpg',0,1,0,999999999999)", [])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
|
||||
let purged = purge_deleted(&db, 100000);
|
||||
assert_eq!(purged, 1);
|
||||
let count: i64 = db.with_read(|c| c.query_row("SELECT count(*) FROM files", [], |r| r.get(0))).unwrap();
|
||||
assert_eq!(count, 1); // recent만 남음
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_creates_valid_db() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
db.with_write(|conn| {
|
||||
conn.execute("INSERT INTO tags(name) VALUES('백업테스트')", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let backup = dbdir.path().join("backup.db");
|
||||
backup_db(&db, &backup).unwrap();
|
||||
assert!(backup.exists());
|
||||
|
||||
// 백업 파일을 열어 데이터 확인
|
||||
let restored = Db::open(&backup).unwrap();
|
||||
let name: String = restored
|
||||
.with_read(|c| c.query_row("SELECT name FROM tags", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(name, "백업테스트");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! 스캔 2단계: 이미지 메타데이터 추출 (치수는 헤더만 읽는 imagesize, EXIF 촬영일시/회전).
|
||||
//! 영상 메타데이터(ffprobe)는 M2에서 추가.
|
||||
|
||||
use archive_db::Db;
|
||||
use rayon::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
const BATCH: usize = 512;
|
||||
|
||||
struct MetaResult {
|
||||
id: i64,
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
orientation: u32,
|
||||
taken_at: Option<i64>,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
fn exif_of(path: &Path) -> (u32, Option<i64>) {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return (1, None);
|
||||
};
|
||||
let mut reader = std::io::BufReader::new(&file);
|
||||
let Ok(ex) = exif::Reader::new().read_from_container(&mut reader) else {
|
||||
return (1, None);
|
||||
};
|
||||
let orientation = ex
|
||||
.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
.and_then(|f| f.value.get_uint(0))
|
||||
.unwrap_or(1);
|
||||
let taken_at = ex
|
||||
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
|
||||
.or_else(|| ex.get_field(exif::Tag::DateTime, exif::In::PRIMARY))
|
||||
.and_then(|f| match &f.value {
|
||||
exif::Value::Ascii(v) => v.first().map(|b| String::from_utf8_lossy(b).into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.and_then(|s| parse_exif_datetime(&s));
|
||||
(orientation, taken_at)
|
||||
}
|
||||
|
||||
/// "YYYY:MM:DD HH:MM:SS" → epoch ms (로컬 시간대 무시, UTC로 근사 — 정렬 용도)
|
||||
fn parse_exif_datetime(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
if s.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let y: i64 = s.get(0..4)?.parse().ok()?;
|
||||
let mo: i64 = s.get(5..7)?.parse().ok()?;
|
||||
let d: i64 = s.get(8..10)?.parse().ok()?;
|
||||
let h: i64 = s.get(11..13)?.parse().ok()?;
|
||||
let mi: i64 = s.get(14..16)?.parse().ok()?;
|
||||
let sec: i64 = s.get(17..19)?.parse().ok()?;
|
||||
if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
// days since epoch (civil) — Howard Hinnant 알고리즘
|
||||
let y_adj = if mo <= 2 { y - 1 } else { y };
|
||||
let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400;
|
||||
let yoe = y_adj - era * 400;
|
||||
let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
Some(((days * 86400) + h * 3600 + mi * 60 + sec) * 1000)
|
||||
}
|
||||
|
||||
/// meta_state=0인 이미지들의 메타데이터를 추출한다. 처리한 개수를 반환.
|
||||
pub fn extract_image_meta(
|
||||
db: &Arc<Db>,
|
||||
source_id: i64,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<u64, archive_db::DbError> {
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.meta_state = 0 AND f.kind = 0 AND f.deleted_at IS NULL
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![source_id, BATCH as i64], |r| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok((r.get::<_, i64>(0)?, Path::new(&dir).join(name)))
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let results: Vec<MetaResult> = batch
|
||||
.par_iter()
|
||||
.map(|(id, path)| {
|
||||
let dims = imagesize::size(path).ok();
|
||||
let (orientation, taken_at) = exif_of(path);
|
||||
MetaResult {
|
||||
id: *id,
|
||||
width: dims.map(|d| d.width as u32),
|
||||
height: dims.map(|d| d.height as u32),
|
||||
orientation,
|
||||
taken_at,
|
||||
ok: dims.is_some(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
total += results.len() as u64;
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"UPDATE files SET width=?2, height=?3, orientation=?4, taken_at=?5, meta_state=?6
|
||||
WHERE id = ?1",
|
||||
)?;
|
||||
for r in &results {
|
||||
stmt.execute(rusqlite::params![
|
||||
r.id,
|
||||
r.width,
|
||||
r.height,
|
||||
r.orientation,
|
||||
r.taken_at,
|
||||
if r.ok { 1 } else { 2 },
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// meta_state=0인 영상들의 메타데이터를 ffprobe로 추출한다. 처리한 개수를 반환.
|
||||
pub fn extract_video_meta(
|
||||
db: &Arc<Db>,
|
||||
source_id: i64,
|
||||
cancel: &AtomicBool,
|
||||
tools: &crate::ffmpeg::FfTools,
|
||||
) -> Result<u64, archive_db::DbError> {
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.meta_state = 0 AND f.kind = 1 AND f.deleted_at IS NULL
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![source_id, BATCH as i64], |r| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok((r.get::<_, i64>(0)?, Path::new(&dir).join(name)))
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let results: Vec<(i64, Option<crate::ffmpeg::MediaInfo>)> = batch
|
||||
.par_iter()
|
||||
.map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok()))
|
||||
.collect();
|
||||
|
||||
total += results.len() as u64;
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"UPDATE files SET width=?2, height=?3, duration_ms=?4, vcodec=?5, acodec=?6,
|
||||
fps=?7, meta_state=?8 WHERE id = ?1",
|
||||
)?;
|
||||
for (id, info) in &results {
|
||||
match info {
|
||||
Some(i) => stmt.execute(rusqlite::params![
|
||||
id, i.width, i.height, i.duration_ms, i.vcodec, i.acodec, i.fps, 1
|
||||
])?,
|
||||
None => stmt.execute(rusqlite::params![
|
||||
id,
|
||||
Option::<u32>::None,
|
||||
Option::<u32>::None,
|
||||
Option::<i64>::None,
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
Option::<f64>::None,
|
||||
2
|
||||
])?,
|
||||
};
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exif_datetime_parsing() {
|
||||
// 2020-01-01 00:00:00 UTC = 1577836800000
|
||||
assert_eq!(parse_exif_datetime("2020:01:01 00:00:00"), Some(1_577_836_800_000));
|
||||
assert_eq!(parse_exif_datetime("잘못된값"), None);
|
||||
assert_eq!(parse_exif_datetime("2020:13:01 00:00:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_dimensions_for_pending_images() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
|
||||
let img = image::RgbImage::from_pixel(320, 200, image::Rgb([1, 2, 3]));
|
||||
img.save(media.path().join("x.png")).unwrap();
|
||||
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write({
|
||||
let root = root.clone();
|
||||
move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
|
||||
let n = extract_image_meta(&db, sid, &cancel).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
let (w, h, state): (u32, u32, i64) = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT width, height, meta_state FROM files WHERE name='x.png'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((w, h, state), (320, 200, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 파일 분류 모델.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum FileKind {
|
||||
Image = 0,
|
||||
Video = 1,
|
||||
Other = 2,
|
||||
}
|
||||
|
||||
const IMAGE_EXTS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "heic", "heif", "tif", "tiff", "jfif",
|
||||
// RAW — v1은 내장 JPEG 프리뷰만 표시
|
||||
"cr2", "cr3", "nef", "arw", "raf", "dng", "orf", "rw2",
|
||||
];
|
||||
|
||||
const VIDEO_EXTS: &[&str] = &[
|
||||
"mp4", "m4v", "mov", "mkv", "avi", "wmv", "webm", "flv", "ts", "m2ts", "mts", "mpg", "mpeg",
|
||||
"3gp", "ogv", "vob",
|
||||
];
|
||||
|
||||
/// 소문자 확장자(점 제외)로 파일 종류를 분류한다.
|
||||
pub fn classify_ext(ext: &str) -> FileKind {
|
||||
if IMAGE_EXTS.contains(&ext) {
|
||||
FileKind::Image
|
||||
} else if VIDEO_EXTS.contains(&ext) {
|
||||
FileKind::Video
|
||||
} else {
|
||||
FileKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// 경로에서 소문자 확장자를 뽑는다.
|
||||
pub fn ext_of(path: &std::path::Path) -> Option<String> {
|
||||
path.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
/// 인덱싱 대상인지 (이미지/영상만 인덱싱)
|
||||
pub fn is_media_ext(ext: &str) -> bool {
|
||||
classify_ext(ext) != FileKind::Other
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify() {
|
||||
assert_eq!(classify_ext("jpg"), FileKind::Image);
|
||||
assert_eq!(classify_ext("heic"), FileKind::Image);
|
||||
assert_eq!(classify_ext("mkv"), FileKind::Video);
|
||||
assert_eq!(classify_ext("txt"), FileKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_extraction() {
|
||||
use std::path::Path;
|
||||
assert_eq!(ext_of(Path::new("C:\\a\\사진.JPG")).as_deref(), Some("jpg"));
|
||||
assert_eq!(ext_of(Path::new("C:\\a\\noext")), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 지각 해시 (perceptual hash) — 64비트 dHash + 해밍 거리 클러스터링.
|
||||
|
||||
use image_hasher::{HashAlg, HasherConfig};
|
||||
use std::path::Path;
|
||||
|
||||
/// 64비트 dHash(gradient) 계산 — 리사이즈/재인코딩에 강건하다.
|
||||
/// 썸네일(256px)에서 계산해도 원본과 거의 동일한 해시가 나온다.
|
||||
pub fn dhash(img: &image::DynamicImage) -> u64 {
|
||||
let hasher = HasherConfig::new()
|
||||
.hash_size(8, 8)
|
||||
.hash_alg(HashAlg::Gradient)
|
||||
.to_hasher();
|
||||
let h = hasher.hash_image(img);
|
||||
let bytes = h.as_bytes();
|
||||
let mut v = 0u64;
|
||||
for (i, b) in bytes.iter().take(8).enumerate() {
|
||||
v |= (*b as u64) << (i * 8);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 파일에서 직접 dHash (썸네일 우선, 없으면 원본).
|
||||
pub fn dhash_file(path: &Path) -> Option<u64> {
|
||||
let img = image::ImageReader::open(path).ok()?.with_guessed_format().ok()?.decode().ok()?;
|
||||
Some(dhash(&img))
|
||||
}
|
||||
|
||||
/// 64비트 해밍 거리 (XOR + popcount — SIMD 자동 벡터화)
|
||||
#[inline]
|
||||
pub fn hamming(a: u64, b: u64) -> u32 {
|
||||
(a ^ b).count_ones()
|
||||
}
|
||||
|
||||
/// (id, phash) 목록에서 임계값 이하 근접쌍을 찾는다.
|
||||
/// 병렬 브루트포스 상삼각 스캔 — 10만개 ≈ 5×10⁹ 비교, 8코어 1–2초.
|
||||
/// 반환: (id_a, id_b, distance) — 항상 id_a < id_b.
|
||||
pub fn find_near_pairs(items: &[(i64, u64)], threshold: u32) -> Vec<(i64, i64, u32)> {
|
||||
use rayon::prelude::*;
|
||||
let n = items.len();
|
||||
if n < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..n)
|
||||
.into_par_iter()
|
||||
.flat_map_iter(|i| {
|
||||
let (id_a, ha) = items[i];
|
||||
let mut local = Vec::new();
|
||||
for j in (i + 1)..n {
|
||||
let (id_b, hb) = items[j];
|
||||
let d = hamming(ha, hb);
|
||||
if d <= threshold {
|
||||
let (lo, hi) = if id_a < id_b { (id_a, id_b) } else { (id_b, id_a) };
|
||||
local.push((lo, hi, d));
|
||||
}
|
||||
}
|
||||
local
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Union-Find로 근접쌍을 그룹으로 묶는다.
|
||||
pub fn cluster(ids: &[i64], pairs: &[(i64, i64, u32)]) -> Vec<Vec<i64>> {
|
||||
use std::collections::HashMap;
|
||||
let mut index: HashMap<i64, usize> = HashMap::with_capacity(ids.len());
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
index.insert(*id, i);
|
||||
}
|
||||
let mut parent: Vec<usize> = (0..ids.len()).collect();
|
||||
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
while parent[x] != x {
|
||||
parent[x] = parent[parent[x]];
|
||||
x = parent[x];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
for (a, b, _) in pairs {
|
||||
let (Some(&ia), Some(&ib)) = (index.get(a), index.get(b)) else {
|
||||
continue;
|
||||
};
|
||||
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
|
||||
if ra != rb {
|
||||
parent[ra] = rb;
|
||||
}
|
||||
}
|
||||
|
||||
let mut groups: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let root = find(&mut parent, i);
|
||||
groups.entry(root).or_default().push(*id);
|
||||
}
|
||||
groups.into_values().filter(|g| g.len() >= 2).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hamming_distance() {
|
||||
assert_eq!(hamming(0b1010, 0b1010), 0);
|
||||
assert_eq!(hamming(0b1010, 0b1011), 1);
|
||||
assert_eq!(hamming(0, u64::MAX), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_pairs_and_clustering() {
|
||||
// 0,1은 가깝고(거리1), 2는 멀다
|
||||
let items = vec![
|
||||
(10i64, 0b0000u64),
|
||||
(20i64, 0b0001u64),
|
||||
(30i64, 0b1111_1111_1111u64),
|
||||
];
|
||||
let pairs = find_near_pairs(&items, 5);
|
||||
assert_eq!(pairs.len(), 1);
|
||||
assert_eq!(pairs[0], (10, 20, 1));
|
||||
|
||||
let ids: Vec<i64> = items.iter().map(|(id, _)| *id).collect();
|
||||
let groups = cluster(&ids, &pairs);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitive_clustering() {
|
||||
// A-B 가깝고 B-C 가까우면 A,B,C 한 그룹
|
||||
let pairs = vec![(1i64, 2i64, 2u32), (2, 3, 3)];
|
||||
let ids = vec![1i64, 2, 3, 4];
|
||||
let mut groups = cluster(&ids, &pairs);
|
||||
groups.sort_by_key(|g| g.len());
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_image_has_close_hash() {
|
||||
// 같은 이미지의 원본과 축소본은 dHash가 거의 같아야 한다
|
||||
let img = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(400, 300, |x, y| {
|
||||
image::Rgb([((x * 255) / 400) as u8, ((y * 255) / 300) as u8, 128])
|
||||
}));
|
||||
let small = img.resize_exact(120, 90, image::imageops::FilterType::Lanczos3);
|
||||
let h1 = dhash(&img);
|
||||
let h2 = dhash(&small);
|
||||
assert!(hamming(h1, h2) <= 5, "리사이즈 후 해밍 거리 {}", hamming(h1, h2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
//! 스캔 파이프라인 — 열거(jwalk) → 스냅샷 비교 → 배치 upsert → 이동 감지 → 소프트 삭제.
|
||||
//!
|
||||
//! 핵심 성능 설계: 스캔 시작 시 소스의 활성 행 전체를 (folder_id, name) 키의
|
||||
//! 메모리 스냅샷으로 로드하고, size+mtime이 같은 파일은 **DB 쓰기를 완전히
|
||||
//! 생략**한다. 무변경 재스캔의 비용은 디렉터리 열거 + 스냅샷 로드뿐이다.
|
||||
|
||||
use crate::model::{classify_ext, ext_of};
|
||||
use crate::scan::{enumerate_items, RawEntry, WalkItem};
|
||||
use archive_db::Db;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ScanError {
|
||||
#[error("DB 오류: {0}")]
|
||||
Db(#[from] archive_db::DbError),
|
||||
#[error("취소됨")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct ScanStats {
|
||||
pub seen: u64,
|
||||
pub added: u64,
|
||||
pub changed: u64,
|
||||
pub removed: u64,
|
||||
pub moved: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScanProgress {
|
||||
pub scan_id: i64,
|
||||
pub phase: String, // enumerate | metadata | done
|
||||
pub seen: u64,
|
||||
pub current_dir: String,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
/// 스냅샷/이동감지용 기존 행 정보
|
||||
#[derive(Clone)]
|
||||
struct SnapRow {
|
||||
id: i64,
|
||||
size: i64,
|
||||
mtime_ms: i64,
|
||||
name: String,
|
||||
quick_hash: Option<i64>,
|
||||
}
|
||||
|
||||
const BATCH: usize = 1000;
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 폴더 행을 보장하고 id를 돌려준다 (조상 폴더 포함, 캐시 사용).
|
||||
fn ensure_folder(
|
||||
db: &Db,
|
||||
cache: &mut HashMap<PathBuf, i64>,
|
||||
source_id: i64,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
) -> Result<i64, ScanError> {
|
||||
if let Some(id) = cache.get(dir) {
|
||||
return Ok(*id);
|
||||
}
|
||||
let parent_id = if dir == root {
|
||||
None
|
||||
} else {
|
||||
let parent = dir.parent().unwrap_or(root);
|
||||
Some(ensure_folder(db, cache, source_id, root, parent)?)
|
||||
};
|
||||
|
||||
let path_str = dir.to_string_lossy().into_owned();
|
||||
let name = dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| dir.to_string_lossy().into_owned());
|
||||
|
||||
let id = db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO folders(source_id, parent_id, path, name) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(source_id, path) DO UPDATE SET name = excluded.name",
|
||||
rusqlite::params![source_id, parent_id, path_str, name],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT id FROM folders WHERE source_id = ?1 AND path = ?2",
|
||||
rusqlite::params![source_id, path_str],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})?;
|
||||
cache.insert(dir.to_path_buf(), id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 소스의 활성 파일 행을 (folder_id, name) 키로 로드한다.
|
||||
fn load_snapshot(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
) -> Result<HashMap<(i64, String), SnapRow>, ScanError> {
|
||||
let rows: Vec<(i64, SnapRow)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT folder_id, id, size, mtime_ms, name, quick_hash
|
||||
FROM files WHERE source_id = ?1 AND deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([source_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
SnapRow {
|
||||
id: r.get(1)?,
|
||||
size: r.get(2)?,
|
||||
mtime_ms: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
quick_hash: r.get(5)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(folder_id, row)| ((folder_id, row.name.clone()), row))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn flush_batch(db: &Db, scan_id: i64, batch: Vec<(i64, RawEntry)>) -> Result<(), ScanError> {
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO files(source_id, folder_id, name, ext, kind, size, mtime_ms, scan_id)
|
||||
SELECT fo.source_id, ?1, ?2, ?3, ?4, ?5, ?6, ?7 FROM folders fo WHERE fo.id = ?1
|
||||
ON CONFLICT(folder_id, name) DO UPDATE SET
|
||||
scan_id = excluded.scan_id,
|
||||
deleted_at = NULL,
|
||||
meta_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.meta_state ELSE 0 END,
|
||||
thumb_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.thumb_state ELSE 0 END,
|
||||
quick_hash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.quick_hash ELSE NULL END,
|
||||
full_hash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.full_hash ELSE NULL END,
|
||||
phash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.phash ELSE NULL END,
|
||||
vhash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.vhash ELSE NULL END,
|
||||
size = excluded.size,
|
||||
mtime_ms = excluded.mtime_ms",
|
||||
)?;
|
||||
for (folder_id, e) in &batch {
|
||||
let name = e
|
||||
.path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let ext = ext_of(&e.path).unwrap_or_default();
|
||||
let kind = classify_ext(&ext) as u8;
|
||||
stmt.execute(rusqlite::params![
|
||||
folder_id,
|
||||
name,
|
||||
ext,
|
||||
kind,
|
||||
e.size as i64,
|
||||
e.mtime_ms,
|
||||
scan_id
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 이동 감지: 사라진 행(missing)과 이번 스캔에서 새로 추가된 행을
|
||||
/// ① (size, name) ② (size, quick_hash 저장분) 기준으로 매칭해
|
||||
/// 행 id를 보존한 채 경로만 갱신한다 (태그·썸네일 보존).
|
||||
/// 반환: (moved, 소프트삭제 대상 id 목록)
|
||||
fn detect_moves(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
scan_id: i64,
|
||||
scan_start: i64,
|
||||
missing: Vec<SnapRow>,
|
||||
) -> Result<(u64, Vec<i64>), ScanError> {
|
||||
if missing.is_empty() {
|
||||
return Ok((0, Vec::new()));
|
||||
}
|
||||
|
||||
struct NewRow {
|
||||
id: i64,
|
||||
size: i64,
|
||||
name: String,
|
||||
folder_id: i64,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
let added: Vec<NewRow> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.id, f.size, f.name, f.folder_id, fo.path FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.scan_id = ?2 AND f.added_at >= ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![source_id, scan_id, scan_start], |r| {
|
||||
let dir: String = r.get(4)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok(NewRow {
|
||||
id: r.get(0)?,
|
||||
size: r.get(1)?,
|
||||
name: name.clone(),
|
||||
folder_id: r.get(3)?,
|
||||
path: Path::new(&dir).join(&name),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
|
||||
if added.is_empty() {
|
||||
return Ok((0, missing.into_iter().map(|m| m.id).collect()));
|
||||
}
|
||||
|
||||
let mut by_size_name: HashMap<(i64, &str), &SnapRow> = HashMap::new();
|
||||
let mut by_size_hash: HashMap<(i64, i64), &SnapRow> = HashMap::new();
|
||||
for m in &missing {
|
||||
by_size_name.entry((m.size, m.name.as_str())).or_insert(m);
|
||||
if let Some(qh) = m.quick_hash {
|
||||
by_size_hash.entry((m.size, qh)).or_insert(m);
|
||||
}
|
||||
}
|
||||
|
||||
let mut moves: Vec<(i64, i64, String)> = Vec::new(); // (old_id, new_folder_id, new_name)
|
||||
let mut new_ids_to_delete: Vec<i64> = Vec::new();
|
||||
let mut matched_old: std::collections::HashSet<i64> = Default::default();
|
||||
|
||||
for n in &added {
|
||||
let matched = by_size_name
|
||||
.get(&(n.size, n.name.as_str()))
|
||||
.copied()
|
||||
.filter(|m| !matched_old.contains(&m.id))
|
||||
.or_else(|| {
|
||||
if by_size_hash.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let qh = crate::hash::quick_hash(&n.path).ok()? as i64;
|
||||
by_size_hash
|
||||
.get(&(n.size, qh))
|
||||
.copied()
|
||||
.filter(|m| !matched_old.contains(&m.id))
|
||||
});
|
||||
|
||||
if let Some(m) = matched {
|
||||
matched_old.insert(m.id);
|
||||
new_ids_to_delete.push(n.id);
|
||||
moves.push((m.id, n.folder_id, n.name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let moved = moves.len() as u64;
|
||||
if moved > 0 {
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut del = tx.prepare_cached("DELETE FROM files WHERE id = ?1")?;
|
||||
for id in &new_ids_to_delete {
|
||||
del.execute([id])?;
|
||||
}
|
||||
let mut upd = tx.prepare_cached(
|
||||
"UPDATE files SET folder_id = ?2, name = ?3, scan_id = ?4 WHERE id = ?1",
|
||||
)?;
|
||||
for (old_id, folder_id, name) in &moves {
|
||||
upd.execute(rusqlite::params![old_id, folder_id, name, scan_id])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
}
|
||||
|
||||
let to_delete = missing
|
||||
.into_iter()
|
||||
.filter(|m| !matched_old.contains(&m.id))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
Ok((moved, to_delete))
|
||||
}
|
||||
|
||||
fn prune_stale_folders(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
seen: &HashMap<PathBuf, i64>,
|
||||
) -> Result<(), ScanError> {
|
||||
let seen_ids: std::collections::HashSet<i64> = seen.values().copied().collect();
|
||||
let all: Vec<(i64, String)> = db.with_read(|conn| {
|
||||
let mut stmt =
|
||||
conn.prepare_cached("SELECT id, path FROM folders WHERE source_id = ?1")?;
|
||||
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect()
|
||||
})?;
|
||||
|
||||
let mut stale: Vec<(i64, String)> = all
|
||||
.into_iter()
|
||||
.filter(|(id, _)| !seen_ids.contains(id))
|
||||
.collect();
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// 자식(더 긴 경로) 먼저 삭제 — parent_id FK 위반 방지
|
||||
stale.sort_by_key(|(_, p)| std::cmp::Reverse(p.len()));
|
||||
|
||||
db.with_write(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"DELETE FROM folders WHERE id = ?1
|
||||
AND NOT EXISTS (SELECT 1 FROM files WHERE folder_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM folders c WHERE c.parent_id = ?1)",
|
||||
)?;
|
||||
for (id, _) in &stale {
|
||||
let _ = stmt.execute([id]);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn soft_delete(db: &Db, ids: Vec<i64>) -> Result<u64, ScanError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = ids.len() as u64;
|
||||
let ts = now_ms();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached("UPDATE files SET deleted_at = ?2 WHERE id = ?1")?;
|
||||
for id in &ids {
|
||||
stmt.execute(rusqlite::params![id, ts])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 소스 하나를 스캔한다. 블로킹 — 전용 스레드에서 호출할 것.
|
||||
pub fn scan_source(
|
||||
db: &Arc<Db>,
|
||||
source_id: i64,
|
||||
root: &Path,
|
||||
cancel: &AtomicBool,
|
||||
mut on_progress: impl FnMut(ScanProgress),
|
||||
) -> Result<ScanStats, ScanError> {
|
||||
let scan_start = now_ms();
|
||||
let scan_id: i64 = db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO scans(source_id, started_at, status) VALUES (?1, ?2, 0)",
|
||||
rusqlite::params![source_id, scan_start],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})?;
|
||||
|
||||
let mut stats = ScanStats::default();
|
||||
let mut snapshot = load_snapshot(db, source_id)?;
|
||||
let mut folder_cache: HashMap<PathBuf, i64> = HashMap::new();
|
||||
let mut batch: Vec<(i64, RawEntry)> = Vec::with_capacity(BATCH);
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
for item in enumerate_items(root) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE scans SET status = 2, finished_at = ?2 WHERE id = ?1",
|
||||
rusqlite::params![scan_id, now_ms()],
|
||||
)
|
||||
})?;
|
||||
return Err(ScanError::Cancelled);
|
||||
}
|
||||
|
||||
let entry = match item {
|
||||
WalkItem::Dir(dir) => {
|
||||
// 빈 폴더도 트리에 포함 (이동 대상으로 쓰인다)
|
||||
ensure_folder(db, &mut folder_cache, source_id, root, &dir)?;
|
||||
continue;
|
||||
}
|
||||
WalkItem::File(e) => e,
|
||||
};
|
||||
|
||||
let dir = entry.path.parent().unwrap_or(root).to_path_buf();
|
||||
let folder_id = ensure_folder(db, &mut folder_cache, source_id, root, &dir)?;
|
||||
stats.seen += 1;
|
||||
|
||||
let name = entry
|
||||
.path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 스냅샷 비교 — 무변경이면 DB 쓰기 생략 (remove로 '발견됨' 표시)
|
||||
match snapshot.remove(&(folder_id, name)) {
|
||||
Some(prev) if prev.size == entry.size as i64 && prev.mtime_ms == entry.mtime_ms => {
|
||||
// unchanged
|
||||
}
|
||||
Some(_) => {
|
||||
stats.changed += 1;
|
||||
batch.push((folder_id, entry));
|
||||
}
|
||||
None => {
|
||||
stats.added += 1;
|
||||
batch.push((folder_id, entry));
|
||||
}
|
||||
}
|
||||
|
||||
if batch.len() >= BATCH {
|
||||
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
|
||||
}
|
||||
if last_emit.elapsed().as_millis() >= 100 {
|
||||
last_emit = Instant::now();
|
||||
on_progress(ScanProgress {
|
||||
scan_id,
|
||||
phase: "enumerate".into(),
|
||||
seen: stats.seen,
|
||||
current_dir: dir.to_string_lossy().into_owned(),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
|
||||
}
|
||||
|
||||
// 스냅샷에 남은 행 = 디스크에서 사라진 파일 → 이동 감지 후 소프트 삭제
|
||||
let missing: Vec<SnapRow> = snapshot.into_values().collect();
|
||||
let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
|
||||
stats.moved = moved;
|
||||
stats.removed = soft_delete(db, to_delete)?;
|
||||
|
||||
// 디스크에서 사라진 폴더 행 정리 (파일 참조가 없는 것만, 자식 먼저)
|
||||
prune_stale_folders(db, source_id, &folder_cache)?;
|
||||
|
||||
let stats_json = serde_json::to_string(&stats).unwrap_or_default();
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE scans SET status = 1, finished_at = ?2, stats = ?3 WHERE id = ?1",
|
||||
rusqlite::params![scan_id, now_ms(), stats_json],
|
||||
)
|
||||
})?;
|
||||
|
||||
on_progress(ScanProgress {
|
||||
scan_id,
|
||||
phase: "done".into(),
|
||||
seen: stats.seen,
|
||||
current_dir: String::new(),
|
||||
done: true,
|
||||
});
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let source_id = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sources(kind, name, root) VALUES ('local', '테스트', ?1)",
|
||||
[&root],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
(dbdir, media, db, source_id)
|
||||
}
|
||||
|
||||
fn count_active(db: &Db) -> i64 {
|
||||
db.with_read(|c| {
|
||||
c.query_row("SELECT count(*) FROM files WHERE deleted_at IS NULL", [], |r| r.get(0))
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_scan_then_incremental() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join("여행/제주")).unwrap();
|
||||
std::fs::write(root.join("a.jpg"), b"aaa").unwrap();
|
||||
std::fs::write(root.join("여행/b.png"), b"bbbb").unwrap();
|
||||
std::fs::write(root.join("여행/제주/c.mp4"), b"ccccc").unwrap();
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.seen, 3);
|
||||
assert_eq!(stats.added, 3);
|
||||
assert_eq!(count_active(&db), 3);
|
||||
|
||||
// 변경 없는 재스캔: added/changed/removed 전부 0
|
||||
let stats2 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats2.seen, 3);
|
||||
assert_eq!(stats2.added, 0);
|
||||
assert_eq!(stats2.changed, 0);
|
||||
assert_eq!(stats2.removed, 0);
|
||||
assert_eq!(count_active(&db), 3);
|
||||
|
||||
// 내용 변경(크기 변화) → changed
|
||||
std::fs::write(root.join("a.jpg"), b"aaaa-longer").unwrap();
|
||||
let stats3 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats3.changed, 1);
|
||||
assert_eq!(stats3.added, 0);
|
||||
|
||||
// 파일 삭제 후 재스캔 → 소프트 삭제
|
||||
std::fs::remove_file(root.join("a.jpg")).unwrap();
|
||||
let stats4 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats4.removed, 1);
|
||||
assert_eq!(count_active(&db), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_detection_preserves_row_id() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join("old")).unwrap();
|
||||
std::fs::create_dir_all(root.join("new")).unwrap();
|
||||
std::fs::write(root.join("old/사진.jpg"), b"unique-content-123").unwrap();
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
let old_id: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT id FROM files WHERE name='사진.jpg'", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
|
||||
std::fs::rename(root.join("old/사진.jpg"), root.join("new/사진.jpg")).unwrap();
|
||||
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.moved, 1);
|
||||
assert_eq!(stats.removed, 0);
|
||||
|
||||
let (new_id, folder_path): (i64, String) = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT f.id, fo.path FROM files f JOIN folders fo ON fo.id=f.folder_id
|
||||
WHERE f.name='사진.jpg' AND f.deleted_at IS NULL",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(new_id, old_id, "행 id가 보존되어야 태그/썸네일이 유지된다");
|
||||
assert!(folder_path.ends_with("new"));
|
||||
assert_eq!(count_active(&db), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_deleted_file_resurrects_on_return() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
std::fs::write(root.join("x.jpg"), b"data").unwrap();
|
||||
let cancel = AtomicBool::new(false);
|
||||
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
std::fs::remove_file(root.join("x.jpg")).unwrap();
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(count_active(&db), 0);
|
||||
|
||||
// 같은 이름 파일이 돌아옴 → deleted_at 해제
|
||||
std::fs::write(root.join("x.jpg"), b"data").unwrap();
|
||||
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.added, 1);
|
||||
assert_eq!(count_active(&db), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 스캔 파이프라인 1단계: 병렬 디렉터리 열거.
|
||||
//! (증분 조정·이동감지 로직은 M1에서 이 위에 얹는다)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawEntry {
|
||||
pub path: PathBuf,
|
||||
pub size: u64,
|
||||
pub mtime_ms: i64,
|
||||
}
|
||||
|
||||
fn mtime_ms(meta: &std::fs::Metadata) -> i64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 워크 항목: 디렉터리(빈 폴더 포함 — 이동 대상용) 또는 미디어 파일.
|
||||
pub enum WalkItem {
|
||||
Dir(PathBuf),
|
||||
File(RawEntry),
|
||||
}
|
||||
|
||||
/// jwalk 기반 병렬 열거 — 디렉터리와 미디어 파일을 모두 반환한다.
|
||||
pub fn enumerate_items(root: &Path) -> impl Iterator<Item = WalkItem> {
|
||||
jwalk::WalkDir::new(root)
|
||||
.skip_hidden(true)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
if e.file_type().is_dir() {
|
||||
return Some(WalkItem::Dir(e.path()));
|
||||
}
|
||||
if !e.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let path = e.path();
|
||||
let ext = crate::model::ext_of(&path)?;
|
||||
if !crate::model::is_media_ext(&ext) {
|
||||
return None;
|
||||
}
|
||||
let meta = e.metadata().ok()?;
|
||||
Some(WalkItem::File(RawEntry {
|
||||
path,
|
||||
size: meta.len(),
|
||||
mtime_ms: mtime_ms(&meta),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// 미디어 파일만 열거 (테스트/유틸용).
|
||||
pub fn enumerate(root: &Path) -> impl Iterator<Item = RawEntry> {
|
||||
enumerate_items(root).filter_map(|i| match i {
|
||||
WalkItem::File(e) => Some(e),
|
||||
WalkItem::Dir(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enumerates_media_files_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("sub/깊은폴더")).unwrap();
|
||||
std::fs::write(dir.path().join("a.jpg"), b"1").unwrap();
|
||||
std::fs::write(dir.path().join("sub/b.mkv"), b"22").unwrap();
|
||||
std::fs::write(dir.path().join("sub/깊은폴더/한글 이름.mp4"), b"333").unwrap();
|
||||
std::fs::write(dir.path().join("ignore.txt"), b"x").unwrap();
|
||||
|
||||
let mut entries: Vec<_> = enumerate(dir.path()).collect();
|
||||
entries.sort_by_key(|e| e.size);
|
||||
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(entries[0].size, 1);
|
||||
assert!(entries[2].path.ends_with("한글 이름.mp4"));
|
||||
assert!(entries.iter().all(|e| e.mtime_ms > 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 태그 CRUD + 파일-태그 할당. 모든 변경은 ops_journal에 기록되어 undo 가능하다.
|
||||
|
||||
use archive_db::Db;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, archive_db::DbError>;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tag {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
pub file_count: i64,
|
||||
}
|
||||
|
||||
pub fn list_tags(db: &Db) -> Result<Vec<Tag>> {
|
||||
db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT t.id, t.name, t.color,
|
||||
(SELECT count(*) FROM file_tags ft
|
||||
JOIN files f ON f.id = ft.file_id
|
||||
WHERE ft.tag_id = t.id AND f.deleted_at IS NULL)
|
||||
FROM tags t ORDER BY t.sort_order, t.name",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(Tag {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
file_count: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_tag(db: &Db, name: String, color: Option<String>) -> Result<i64> {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO tags(name, color) VALUES (?1, ?2)
|
||||
ON CONFLICT(name) DO UPDATE SET color = COALESCE(excluded.color, tags.color)",
|
||||
rusqlite::params![name, color],
|
||||
)?;
|
||||
conn.query_row("SELECT id FROM tags WHERE name = ?1", [&name], |r| r.get(0))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rename_tag(db: &Db, tag_id: i64, new_name: String) -> Result<()> {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ?2 WHERE id = ?1",
|
||||
rusqlite::params![tag_id, new_name],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_tag(db: &Db, tag_id: i64) -> Result<()> {
|
||||
db.with_write(move |conn| {
|
||||
// file_tags는 FK CASCADE로 정리된다
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", [tag_id])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// 파일들에 태그 할당. 반환: 새로 할당된 개수 (이미 있던 것 제외).
|
||||
pub fn assign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Result<u64> {
|
||||
let batch_id = batch_id.to_string();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut added = 0u64;
|
||||
{
|
||||
let mut ins = tx.prepare_cached(
|
||||
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
|
||||
)?;
|
||||
let mut journal = tx.prepare_cached(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'tag', ?2, ?3)",
|
||||
)?;
|
||||
for fid in &file_ids {
|
||||
if ins.execute(rusqlite::params![fid, tag_id])? > 0 {
|
||||
journal.execute(rusqlite::params![batch_id, fid, tag_id])?;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(added)
|
||||
})
|
||||
}
|
||||
|
||||
/// 파일들에서 태그 제거.
|
||||
pub fn unassign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Result<u64> {
|
||||
let batch_id = batch_id.to_string();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut removed = 0u64;
|
||||
{
|
||||
let mut del = tx.prepare_cached(
|
||||
"DELETE FROM file_tags WHERE file_id = ?1 AND tag_id = ?2",
|
||||
)?;
|
||||
let mut journal = tx.prepare_cached(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'untag', ?2, ?3)",
|
||||
)?;
|
||||
for fid in &file_ids {
|
||||
if del.execute(rusqlite::params![fid, tag_id])? > 0 {
|
||||
journal.execute(rusqlite::params![batch_id, fid, tag_id])?;
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(removed)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn file_tags(db: &Db, file_id: i64) -> Result<Vec<Tag>> {
|
||||
db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT t.id, t.name, t.color, 0 FROM tags t
|
||||
JOIN file_tags ft ON ft.tag_id = t.id WHERE ft.file_id = ?1
|
||||
ORDER BY t.name",
|
||||
)?;
|
||||
let rows = stmt.query_map([file_id], |r| {
|
||||
Ok(Tag {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
file_count: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// 태그 전체를 JSON으로 내보낸다 (재해 복구 보험 — 계획서 §7).
|
||||
/// 형식: [{ tag, color, files: ["<절대경로>", ...] }, ...]
|
||||
pub fn export_json(db: &Db) -> Result<String> {
|
||||
#[derive(serde::Serialize)]
|
||||
struct TagExport {
|
||||
tag: String,
|
||||
color: Option<String>,
|
||||
files: Vec<String>,
|
||||
}
|
||||
let exports: Vec<TagExport> = db.with_read(|conn| {
|
||||
let mut tag_stmt = conn.prepare("SELECT id, name, color FROM tags ORDER BY name")?;
|
||||
let tags: Vec<(i64, String, Option<String>)> = tag_stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
|
||||
let mut file_stmt = conn.prepare(
|
||||
"SELECT fo.path, f.name FROM file_tags ft
|
||||
JOIN files f ON f.id = ft.file_id
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let mut out = Vec::with_capacity(tags.len());
|
||||
for (id, name, color) in tags {
|
||||
let files: Vec<String> = file_stmt
|
||||
.query_map([id], |r| {
|
||||
let dir: String = r.get(0)?;
|
||||
let name: String = r.get(1)?;
|
||||
Ok(format!("{dir}{}{name}", std::path::MAIN_SEPARATOR))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
out.push(TagExport { tag: name, color, files });
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&exports).unwrap_or_else(|_| "[]".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, Arc<archive_db::Db>, Vec<i64>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(archive_db::Db::open(&dir.path().join("t.db")).unwrap());
|
||||
let ids = db
|
||||
.with_write(|conn| {
|
||||
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
|
||||
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms)
|
||||
VALUES(1,1,?1,'jpg',0,100,0)",
|
||||
[format!("f{i}.jpg")],
|
||||
)?;
|
||||
ids.push(conn.last_insert_rowid());
|
||||
}
|
||||
Ok(ids)
|
||||
})
|
||||
.unwrap();
|
||||
(dir, db, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_crud_and_assignment() {
|
||||
let (_d, db, ids) = setup();
|
||||
let tag = create_tag(&db, "가족".into(), Some("#ff0000".into())).unwrap();
|
||||
|
||||
let added = assign(&db, "b1", ids.clone(), tag).unwrap();
|
||||
assert_eq!(added, 3);
|
||||
// 중복 할당은 0
|
||||
assert_eq!(assign(&db, "b2", ids.clone(), tag).unwrap(), 0);
|
||||
|
||||
let tags = list_tags(&db).unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].file_count, 3);
|
||||
|
||||
let ft = file_tags(&db, ids[0]).unwrap();
|
||||
assert_eq!(ft[0].name, "가족");
|
||||
|
||||
let removed = unassign(&db, "b3", vec![ids[0]], tag).unwrap();
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(list_tags(&db).unwrap()[0].file_count, 2);
|
||||
|
||||
// 저널 기록 확인
|
||||
let journal_count: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT count(*) FROM ops_journal", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(journal_count, 4); // tag 3 + untag 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_contains_paths() {
|
||||
let (_d, db, ids) = setup();
|
||||
let tag = create_tag(&db, "여행".into(), None).unwrap();
|
||||
assign(&db, "b1", vec![ids[0]], tag).unwrap();
|
||||
|
||||
let json = export_json(&db).unwrap();
|
||||
assert!(json.contains("여행"));
|
||||
assert!(json.contains("f0.jpg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! 썸네일 생성 큐 — 2밴드 우선순위(뷰포트 hi / 백그라운드 lo) 워커 풀.
|
||||
//! 완료 id는 150ms 배치로 콜백에 전달된다 (프론트 셀 갱신 이벤트용).
|
||||
|
||||
use crate::thumbs;
|
||||
use archive_db::Db;
|
||||
use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct ThumbQueue {
|
||||
hi_tx: Sender<i64>,
|
||||
lo_tx: Sender<i64>,
|
||||
}
|
||||
|
||||
struct Ctx {
|
||||
db: Arc<Db>,
|
||||
thumb_root: PathBuf,
|
||||
tools: Option<crate::ffmpeg::FfTools>,
|
||||
in_flight: Mutex<HashSet<i64>>,
|
||||
done_tx: Sender<i64>,
|
||||
}
|
||||
|
||||
impl ThumbQueue {
|
||||
/// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다.
|
||||
/// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리).
|
||||
pub fn start(
|
||||
db: Arc<Db>,
|
||||
thumb_root: PathBuf,
|
||||
tools: Option<crate::ffmpeg::FfTools>,
|
||||
workers: usize,
|
||||
on_done: impl Fn(Vec<i64>) + Send + 'static,
|
||||
) -> ThumbQueue {
|
||||
let (hi_tx, hi_rx) = unbounded::<i64>();
|
||||
let (lo_tx, lo_rx) = unbounded::<i64>();
|
||||
let (done_tx, done_rx) = unbounded::<i64>();
|
||||
|
||||
let ctx = Arc::new(Ctx {
|
||||
db,
|
||||
thumb_root,
|
||||
tools,
|
||||
in_flight: Mutex::new(HashSet::new()),
|
||||
done_tx,
|
||||
});
|
||||
|
||||
for i in 0..workers.max(1) {
|
||||
let ctx = ctx.clone();
|
||||
let hi_rx = hi_rx.clone();
|
||||
let lo_rx = lo_rx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("thumb-{i}"))
|
||||
.spawn(move || worker_loop(ctx, hi_rx, lo_rx))
|
||||
.expect("썸네일 워커 생성 실패");
|
||||
}
|
||||
|
||||
// 완료 배치 이미터
|
||||
std::thread::Builder::new()
|
||||
.name("thumb-emit".into())
|
||||
.spawn(move || {
|
||||
let mut batch: Vec<i64> = Vec::new();
|
||||
loop {
|
||||
match done_rx.recv_timeout(Duration::from_millis(150)) {
|
||||
Ok(id) => {
|
||||
batch.push(id);
|
||||
if batch.len() >= 256 {
|
||||
on_done(std::mem::take(&mut batch));
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if !batch.is_empty() {
|
||||
on_done(std::mem::take(&mut batch));
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
if !batch.is_empty() {
|
||||
on_done(batch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("썸네일 이미터 생성 실패");
|
||||
|
||||
ThumbQueue { hi_tx, lo_tx }
|
||||
}
|
||||
|
||||
/// 뷰포트 우선 생성 요청 (프론트가 스크롤 시 호출).
|
||||
pub fn prioritize(&self, ids: &[i64]) {
|
||||
for id in ids {
|
||||
let _ = self.hi_tx.send(*id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 대기 중인 이미지 전체를 백그라운드 밴드에 넣는다.
|
||||
pub fn enqueue_pending(&self, db: &Db, source_id: Option<i64>) {
|
||||
let ids: Vec<i64> = db
|
||||
.with_read(|conn| match source_id {
|
||||
Some(sid) => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL AND source_id=?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([sid], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for id in ids {
|
||||
let _ = self.lo_tx.send(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_key(source_id: i64, path: &str, size: i64, mtime_ms: i64) -> String {
|
||||
let key = xxhash_rust::xxh3::xxh3_128(format!("{source_id}|{path}|{size}|{mtime_ms}").as_bytes());
|
||||
format!("{key:032x}")
|
||||
}
|
||||
|
||||
/// cache_key → 썸네일 파일 경로 (256샤딩)
|
||||
pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> PathBuf {
|
||||
thumb_root
|
||||
.join(size_class.to_string())
|
||||
.join(&key[0..2])
|
||||
.join(format!("{key}.jpg"))
|
||||
}
|
||||
|
||||
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
|
||||
loop {
|
||||
// hi 우선, 없으면 lo를 짧게 대기
|
||||
let id = match hi_rx.try_recv() {
|
||||
Ok(id) => id,
|
||||
Err(_) => match lo_rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(id) => id,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
// hi 채널이 닫혔으면 종료
|
||||
if hi_rx.is_empty() && lo_rx.is_empty() && hi_rx.try_recv().is_err() {
|
||||
match hi_rx.recv_timeout(Duration::from_millis(1)) {
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
Ok(id) => id,
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
},
|
||||
};
|
||||
|
||||
if !ctx.in_flight.lock().unwrap().insert(id) {
|
||||
continue; // 다른 워커가 처리 중
|
||||
}
|
||||
let result = process_one(&ctx, id);
|
||||
ctx.in_flight.lock().unwrap().remove(&id);
|
||||
if let Ok(true) = result {
|
||||
let _ = ctx.done_tx.send(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// true = 새로 생성/등록됨, false = 스킵
|
||||
fn process_one(ctx: &Ctx, id: i64) -> Result<bool, archive_db::DbError> {
|
||||
struct Row {
|
||||
dir: String,
|
||||
name: String,
|
||||
ext: String,
|
||||
size: i64,
|
||||
mtime_ms: i64,
|
||||
source_id: i64,
|
||||
thumb_state: i64,
|
||||
kind: i64,
|
||||
duration_ms: Option<i64>,
|
||||
}
|
||||
let row = ctx.db.with_read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT fo.path, f.name, f.ext, f.size, f.mtime_ms, f.source_id, f.thumb_state, f.kind, f.duration_ms
|
||||
FROM files f JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.id = ?1 AND f.deleted_at IS NULL",
|
||||
[id],
|
||||
|r| {
|
||||
Ok(Row {
|
||||
dir: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
ext: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||
size: r.get(3)?,
|
||||
mtime_ms: r.get(4)?,
|
||||
source_id: r.get(5)?,
|
||||
thumb_state: r.get(6)?,
|
||||
kind: r.get(7)?,
|
||||
duration_ms: r.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
});
|
||||
let Ok(row) = row else { return Ok(false) };
|
||||
if row.thumb_state != 0 || row.kind > 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
// HEIC/HEIF는 image 크레이트가 못 읽음 — ffmpeg(HEVC 네이티브 디코더) 경로 사용
|
||||
let needs_ffmpeg = row.kind == 1 || matches!(row.ext.as_str(), "heic" | "heif");
|
||||
// ffmpeg가 없으면 보류 (thumb_state 0 유지 — 사이드카 생기면 재시도)
|
||||
if needs_ffmpeg && ctx.tools.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let src = std::path::Path::new(&row.dir).join(&row.name);
|
||||
let src_str = src.to_string_lossy().into_owned();
|
||||
let key = cache_key(row.source_id, &src_str, row.size, row.mtime_ms);
|
||||
let dst = thumb_path(&ctx.thumb_root, 0, &key);
|
||||
|
||||
let generated = if dst.exists() {
|
||||
// 캐시 히트 — 크기만 읽어 등록
|
||||
image::image_dimensions(&dst).ok()
|
||||
} else if needs_ffmpeg {
|
||||
// 영상: 길이의 10% 지점 프레임 (인트로/블랙프레임 회피), 미상이면 1초. HEIC: 0초.
|
||||
let at = if row.kind == 1 {
|
||||
row.duration_ms
|
||||
.map(|d| (d as f64 / 1000.0) * 0.1)
|
||||
.filter(|t| *t > 0.1)
|
||||
.unwrap_or(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let tools = ctx.tools.as_ref().unwrap();
|
||||
match crate::ffmpeg::extract_frame_jpeg(tools, &src, &dst, at, thumbs::GRID_LONG_EDGE) {
|
||||
Ok(dims) => Some(dims),
|
||||
Err(e) => {
|
||||
tracing::debug!(file = %src_str, "ffmpeg 썸네일 실패: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match thumbs::make_image_thumb(&src, &dst, thumbs::GRID_LONG_EDGE, thumbs::JPEG_QUALITY) {
|
||||
Ok((_, _, dw, dh)) => Some((dw, dh)),
|
||||
Err(e) => {
|
||||
tracing::debug!(file = %src_str, "썸네일 생성 실패: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match generated {
|
||||
Some((dw, dh)) => {
|
||||
let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0);
|
||||
ctx.db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO thumbs(file_id, size_class, cache_key, width, height, bytes)
|
||||
VALUES (?1, 0, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![id, key, dw, dh, bytes],
|
||||
)?;
|
||||
tx.execute("UPDATE files SET thumb_state = 1 WHERE id = ?1", [id])?;
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
ctx.db
|
||||
.with_write(move |conn| conn.execute("UPDATE files SET thumb_state = 2 WHERE id = ?1", [id]))?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn generates_thumbs_for_scanned_images() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let thumb_root = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
|
||||
for i in 0..5 {
|
||||
let img = image::RgbImage::from_pixel(600, 400, image::Rgb([i as u8 * 40, 10, 200]));
|
||||
img.save(media.path().join(format!("img{i}.png"))).unwrap();
|
||||
}
|
||||
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write({
|
||||
let root = root.clone();
|
||||
move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = std::sync::atomic::AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
|
||||
let done_count = Arc::new(AtomicUsize::new(0));
|
||||
let dc = done_count.clone();
|
||||
let q = ThumbQueue::start(db.clone(), thumb_root.path().to_path_buf(), None, 2, move |ids| {
|
||||
dc.fetch_add(ids.len(), Ordering::SeqCst);
|
||||
});
|
||||
q.enqueue_pending(&db, Some(sid));
|
||||
|
||||
// 완료 대기 (최대 10초)
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while done_count.load(Ordering::SeqCst) < 5 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert_eq!(done_count.load(Ordering::SeqCst), 5);
|
||||
|
||||
let ready: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT count(*) FROM files WHERE thumb_state=1", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(ready, 5);
|
||||
|
||||
// thumbs 테이블의 cache_key로 실제 파일 존재 확인
|
||||
let keys: Vec<String> = db
|
||||
.with_read(|c| {
|
||||
let mut stmt = c.prepare("SELECT cache_key FROM thumbs")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(keys.len(), 5);
|
||||
for k in keys {
|
||||
assert!(thumb_path(thumb_root.path(), 0, &k).exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 썸네일 생성 — 이미지: image 디코드 → EXIF 회전 → SIMD 리사이즈 → JPEG.
|
||||
//! (영상 썸네일은 M2에서 ffmpeg 사이드카로 추가)
|
||||
|
||||
use fast_image_resize::images::Image as FirImage;
|
||||
use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer};
|
||||
use image::DynamicImage;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ThumbError {
|
||||
#[error("I/O 오류: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("이미지 디코드 오류: {0}")]
|
||||
Image(#[from] image::ImageError),
|
||||
#[error("리사이즈 오류: {0}")]
|
||||
Resize(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ThumbError>;
|
||||
|
||||
/// 썸네일 크기 클래스 (계획서 §5)
|
||||
pub const GRID_LONG_EDGE: u32 = 256;
|
||||
pub const PREVIEW_LONG_EDGE: u32 = 1280;
|
||||
pub const JPEG_QUALITY: u8 = 80;
|
||||
|
||||
/// EXIF Orientation 값(1–8)을 읽는다. 없으면 1(정상).
|
||||
pub fn read_orientation(path: &Path) -> u32 {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return 1;
|
||||
};
|
||||
let mut reader = std::io::BufReader::new(&file);
|
||||
exif::Reader::new()
|
||||
.read_from_container(&mut reader)
|
||||
.ok()
|
||||
.and_then(|ex| {
|
||||
ex.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
.and_then(|f| f.value.get_uint(0))
|
||||
})
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
fn apply_orientation(img: DynamicImage, orientation: u32) -> DynamicImage {
|
||||
match orientation {
|
||||
2 => img.fliph(),
|
||||
3 => img.rotate180(),
|
||||
4 => img.flipv(),
|
||||
5 => img.rotate90().fliph(),
|
||||
6 => img.rotate90(),
|
||||
7 => img.rotate270().fliph(),
|
||||
8 => img.rotate270(),
|
||||
_ => img,
|
||||
}
|
||||
}
|
||||
|
||||
fn target_dims(w: u32, h: u32, long_edge: u32) -> (u32, u32) {
|
||||
if w.max(h) <= long_edge {
|
||||
return (w, h);
|
||||
}
|
||||
if w >= h {
|
||||
let nh = ((h as u64 * long_edge as u64) / w as u64).max(1) as u32;
|
||||
(long_edge, nh)
|
||||
} else {
|
||||
let nw = ((w as u64 * long_edge as u64) / h as u64).max(1) as u32;
|
||||
(nw, long_edge)
|
||||
}
|
||||
}
|
||||
|
||||
/// 이미지 파일에서 JPEG 썸네일을 생성한다. 반환값은 (원본 w, 원본 h, 썸네일 w, 썸네일 h).
|
||||
/// (HEIC/HEIF는 image 크레이트가 못 읽으므로 썸네일 큐가 ffmpeg 경로로 라우팅한다)
|
||||
pub fn make_image_thumb(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
) -> Result<(u32, u32, u32, u32)> {
|
||||
let decoded = image::ImageReader::open(src)?
|
||||
.with_guessed_format()?
|
||||
.decode()?;
|
||||
let oriented = apply_orientation(decoded, read_orientation(src));
|
||||
let (w, h) = (oriented.width(), oriented.height());
|
||||
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
|
||||
Ok((w, h, dw, dh))
|
||||
}
|
||||
|
||||
/// 이미 디코드된 이미지에서 썸네일 생성(원격 EXIF 내장 썸네일 등). 반환: (썸네일 w, h).
|
||||
pub fn make_image_thumb_from(
|
||||
img: &DynamicImage,
|
||||
dst: &Path,
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
) -> Result<(u32, u32)> {
|
||||
resize_encode(img, dst, long_edge, quality)
|
||||
}
|
||||
|
||||
/// JPEG/PNG 등 인메모리 바이트에서 썸네일 생성. `min_long_edge`보다 작으면 거부(None).
|
||||
/// 원격 이미지 전체 다운로드 후, 또는 EXIF 내장 썸네일 바이트에서 사용.
|
||||
pub fn make_thumb_from_bytes(
|
||||
bytes: &[u8],
|
||||
dst: &Path,
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
min_long_edge: u32,
|
||||
) -> Option<(u32, u32)> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
if img.width().max(img.height()) < min_long_edge {
|
||||
return None;
|
||||
}
|
||||
resize_encode(&img, dst, long_edge, quality).ok()
|
||||
}
|
||||
|
||||
/// EXIF APP1 내 내장 썸네일(두 번째 JPEG SOI…EOI)을 스캔한다.
|
||||
pub fn scan_embedded_jpeg(data: &[u8]) -> Option<&[u8]> {
|
||||
let mut starts = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 1 < data.len() {
|
||||
if data[i] == 0xFF && data[i + 1] == 0xD8 {
|
||||
starts.push(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let start = *starts.get(1)?; // 첫째=본 이미지, 둘째=내장 썸네일
|
||||
let mut j = start + 2;
|
||||
while j + 1 < data.len() {
|
||||
if data[j] == 0xFF && data[j + 1] == 0xD9 {
|
||||
return Some(&data[start..j + 2]);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resize_encode(img: &DynamicImage, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let (dw, dh) = target_dims(w, h, long_edge);
|
||||
|
||||
let rgb = img.to_rgb8();
|
||||
let src_img = FirImage::from_vec_u8(w, h, rgb.into_raw(), PixelType::U8x3)
|
||||
.map_err(|e| ThumbError::Resize(e.to_string()))?;
|
||||
let mut dst_img = FirImage::new(dw, dh, PixelType::U8x3);
|
||||
let mut resizer = Resizer::new();
|
||||
resizer
|
||||
.resize(
|
||||
&src_img,
|
||||
&mut dst_img,
|
||||
&ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3)),
|
||||
)
|
||||
.map_err(|e| ThumbError::Resize(e.to_string()))?;
|
||||
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(dst)?);
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
|
||||
encoder.encode(dst_img.buffer(), dw, dh, image::ExtendedColorType::Rgb8)?;
|
||||
Ok((dw, dh))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_dims_math() {
|
||||
assert_eq!(target_dims(4000, 3000, 256), (256, 192));
|
||||
assert_eq!(target_dims(3000, 4000, 256), (192, 256));
|
||||
assert_eq!(target_dims(100, 50, 256), (100, 50)); // 업스케일 안 함
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_thumb_from_generated_png() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("src.png");
|
||||
let dst = dir.path().join("thumbs/aa/key.jpg");
|
||||
|
||||
// 1000x600 그라데이션 이미지 생성
|
||||
let img = image::RgbImage::from_fn(1000, 600, |x, y| {
|
||||
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||
});
|
||||
img.save(&src).unwrap();
|
||||
|
||||
let (w, h, dw, dh) = make_image_thumb(&src, &dst, 256, 80).unwrap();
|
||||
assert_eq!((w, h), (1000, 600));
|
||||
assert_eq!((dw, dh), (256, 153));
|
||||
|
||||
let reopened = image::ImageReader::open(&dst).unwrap().decode().unwrap();
|
||||
assert_eq!(reopened.width(), 256);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user