Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램
Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼 사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드, 인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포. - archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram - archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트) - archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수 - archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡 - 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널 Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user