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:
강 한
2026-07-16 22:04:06 +09:00
co-authored by Claude Opus 4.8
commit 394d1b9805
132 changed files with 23468 additions and 0 deletions
@@ -0,0 +1,28 @@
[package]
name = "archive-indexer"
version = "0.1.0"
edition = "2021"
[dependencies]
archive-db = { path = "../archive-db" }
archive-vfs = { path = "../archive-vfs" }
jwalk = "0.8"
rayon = "1"
crossbeam-channel = { workspace = true }
xxhash-rust = { version = "0.8", features = ["xxh3"] }
blake3 = { version = "1", features = ["mmap", "rayon"] }
image = "0.25"
image_hasher = "3"
fast_image_resize = "6"
imagesize = "0.14"
kamadak-exif = "0.6"
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { version = "0.40", features = ["bundled"] }
trash = "5"
filetime = "0.2"
[dev-dependencies]
tempfile = "3"
@@ -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 값(18)을 읽는다. 없으면 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);
}
}
@@ -0,0 +1,68 @@
//! ffmpeg 사이드카 실기 테스트 — src-tauri/binaries에 사이드카가 있을 때만 실행된다.
//! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
use archive_indexer::ffmpeg::{self, FfTools};
use std::path::{Path, PathBuf};
fn tools() -> Option<FfTools> {
// crates/archive-indexer → ../../binaries
let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../binaries");
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
"x86_64-pc-windows-msvc"
} else if cfg!(all(windows, target_arch = "aarch64")) {
"aarch64-pc-windows-msvc"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else {
"x86_64-apple-darwin"
};
let ext = if cfg!(windows) { ".exe" } else { "" };
let ffmpeg = bin.join(format!("ffmpeg-{triple}{ext}"));
let ffprobe = bin.join(format!("ffprobe-{triple}{ext}"));
if ffmpeg.is_file() && ffprobe.is_file() {
Some(FfTools { ffmpeg, ffprobe })
} else {
None
}
}
fn make_test_video(t: &FfTools, dir: &Path) -> PathBuf {
let out = dir.join("테스트영상.mp4");
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
let status = ffmpeg::command(&t.ffmpeg)
.args([
"-y", "-v", "error",
"-f", "lavfi", "-i", "testsrc2=duration=2:size=320x240:rate=25",
"-c:v", vcodec, "-b:v", "300k",
])
.arg(&out)
.status()
.expect("ffmpeg 실행 실패");
assert!(status.success(), "테스트 영상 생성 실패");
out
}
#[test]
fn probe_and_thumbnail_roundtrip() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀 (scripts/fetch-ffmpeg.ps1 실행 후 재시도)");
return;
};
let dir = tempfile::tempdir().unwrap();
let video = make_test_video(&t, dir.path());
// ffprobe 메타
let info = ffmpeg::probe(&t, &video).unwrap();
assert_eq!(info.vcodec.as_deref(), Some("h264"));
assert_eq!(info.width, Some(320));
assert_eq!(info.height, Some(240));
let dur = info.duration_ms.expect("duration 없음");
assert!((1500..=2500).contains(&dur), "duration {dur}ms");
assert!(info.fps.unwrap() > 20.0);
// 프레임 썸네일
let thumb = dir.path().join("thumbs/aa/key.jpg");
let (w, h) = ffmpeg::extract_frame_jpeg(&t, &video, &thumb, 0.5, 256).unwrap();
assert!(thumb.is_file());
assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}");
}
@@ -0,0 +1,51 @@
//! 성능 측정 (기본 무시). 실행:
//! ARCHIVE_PERF_DIR=<50k폴더> cargo test -p archive-indexer --release --test perf_scan -- --ignored --nocapture
use archive_db::Db;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Instant;
#[test]
#[ignore]
fn scan_perf() {
let Ok(dir) = std::env::var("ARCHIVE_PERF_DIR") else {
eprintln!("ARCHIVE_PERF_DIR 미설정 — 건너뜀");
return;
};
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("perf.db")).unwrap());
let root = std::path::Path::new(&dir);
let cancel = AtomicBool::new(false);
let dir_owned = dir.clone();
db.with_write(move |c| {
c.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','perf',?1)", [&dir_owned])?;
Ok(())
})
.unwrap();
// 1차 스캔 (전체)
let t0 = Instant::now();
let stats = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
let scan1 = t0.elapsed();
// 메타데이터
let t1 = Instant::now();
let meta = archive_indexer::meta::extract_image_meta(&db, 1, &cancel).unwrap();
let meta_dur = t1.elapsed();
// 2차 스캔 (무변경 — 스냅샷 비교)
let t2 = Instant::now();
let stats2 = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
let scan2 = t2.elapsed();
eprintln!("=== 성능 (릴리스) ===");
eprintln!("파일 수: {}", stats.seen);
eprintln!("1차 스캔: {:.2}s ({} added)", scan1.as_secs_f64(), stats.added);
eprintln!("메타 추출: {:.2}s ({} 이미지)", meta_dur.as_secs_f64(), meta);
eprintln!("무변경 재스캔: {:.2}s (added={}, changed={})", scan2.as_secs_f64(), stats2.added, stats2.changed);
assert_eq!(stats2.added, 0);
assert_eq!(stats2.changed, 0);
}