diff --git a/src-tauri/crates/archive-indexer/src/dedupe.rs b/src-tauri/crates/archive-indexer/src/dedupe.rs index 5e77344..22c2ae3 100644 --- a/src-tauri/crates/archive-indexer/src/dedupe.rs +++ b/src-tauri/crates/archive-indexer/src/dedupe.rs @@ -59,7 +59,18 @@ struct FileMeta { duration_ms: Option, } -fn load_files(db: &Db, kind: u8, scope: Option) -> Result> { +/// 중복 탐지 범위. `folder_snapshot`과 동일한 스코핑 규칙을 따른다. +#[derive(Clone, Copy, Debug)] +pub enum Scope { + /// 전체 라이브러리(모든 소스) + All, + /// 특정 소스 + Source(i64), + /// 특정 폴더 (recursive=true면 하위 폴더 전체 포함) + Folder { id: i64, recursive: bool }, +} + +fn load_files(db: &Db, kind: u8, scope: Scope) -> Result> { 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 @@ -78,14 +89,33 @@ fn load_files(db: &Db, kind: u8, scope: Option) -> Result> { }) }; match scope { - Some(sid) => { + Scope::All => { + let mut stmt = conn.prepare(base)?; + let rows = stmt.query_map([kind], map)?; + rows.collect() + } + Scope::Source(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)?; + Scope::Folder { id, recursive: false } => { + let mut stmt = conn.prepare(&format!("{base} AND f.folder_id = ?2"))?; + let rows = stmt.query_map(rusqlite::params![kind, id], map)?; + rows.collect() + } + Scope::Folder { id, recursive: true } => { + // 폴더 경로 프리픽스로 하위 전체 (folder_snapshot과 동일) + let (src, path): (i64, String) = conn.query_row( + "SELECT source_id, path FROM folders WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + let prefix = format!("{}{}%", path, std::path::MAIN_SEPARATOR); + let mut stmt = conn.prepare(&format!( + "{base} AND fo.source_id = ?2 AND (fo.path = ?3 OR fo.path LIKE ?4)" + ))?; + let rows = stmt.query_map(rusqlite::params![kind, src, path, prefix], map)?; rows.collect() } } @@ -385,7 +415,7 @@ fn detect_similar_videos( pub fn run( db: &Arc, tools: Option<&FfTools>, - scope_source: Option, + scope: Scope, image_threshold: u32, include_video: bool, cancel: &AtomicBool, @@ -397,14 +427,14 @@ pub fn run( let mut grouped: std::collections::HashSet = std::collections::HashSet::new(); // 완전 동일 (이미지+영상 모두) - let mut all_files = load_files(db, 0, scope_source)?; - all_files.extend(load_files(db, 1, scope_source)?); + let mut all_files = load_files(db, 0, scope)?; + all_files.extend(load_files(db, 1, scope)?); 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)?; + let images = load_files(db, 0, scope)?; 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 }); })?; @@ -412,7 +442,7 @@ pub fn run( // 유사 영상 (opt-in + ffmpeg 필요) if include_video { if let Some(tools) = tools { - let videos = load_files(db, 1, scope_source)?; + let videos = load_files(db, 1, scope)?; stats.video_groups = detect_similar_videos(db, &videos, tools, 8, &dismissed, cancel, |c, t| { on_progress(DedupeProgress { phase: "video".into(), current: c, total: t }); })?; @@ -471,7 +501,7 @@ mod tests { 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(); + let stats = run(&db, None, Scope::Source(sid), 5, false, &cancel, |_| {}).unwrap(); // 완전 동일 그룹 1개 (original + exact_copy) assert_eq!(stats.exact_groups, 1, "완전 동일 그룹"); @@ -493,6 +523,56 @@ mod tests { assert_eq!(keepers, 1); } + #[test] + fn folder_scope_restricts_detection() { + let (_d, media, db, sid) = setup(); + let root = media.path(); + std::fs::create_dir_all(root.join("folderA")).unwrap(); + std::fs::create_dir_all(root.join("folderB")).unwrap(); + + // folderA: 완전 동일 쌍 + let a = image::RgbImage::from_fn(200, 200, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, 10]) + }); + a.save(root.join("folderA/a1.png")).unwrap(); + std::fs::copy(root.join("folderA/a1.png"), root.join("folderA/a2.png")).unwrap(); + // folderB: 다른 완전 동일 쌍 + let b = image::RgbImage::from_fn(200, 200, |x, y| { + image::Rgb([10, (x % 256) as u8, (y % 256) as u8]) + }); + b.save(root.join("folderB/b1.png")).unwrap(); + std::fs::copy(root.join("folderB/b1.png"), root.join("folderB/b2.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 fa: i64 = db + .with_read(|c| c.query_row("SELECT id FROM folders WHERE name='folderA'", [], |r| r.get(0))) + .unwrap(); + + // folderA 범위: 완전 동일 그룹 1개 + folderA 외 파일 없음 + let sa = run(&db, None, Scope::Folder { id: fa, recursive: true }, 5, false, &cancel, |_| {}).unwrap(); + assert_eq!(sa.exact_groups, 1, "folderA 범위는 그룹 1개"); + let outside: i64 = db + .with_read(|c| { + c.query_row( + "SELECT count(*) FROM dupe_members dm + JOIN files f ON f.id = dm.file_id + JOIN folders fo ON fo.id = f.folder_id + WHERE fo.name != 'folderA'", + [], + |r| r.get(0), + ) + }) + .unwrap(); + assert_eq!(outside, 0, "folderA 범위인데 다른 폴더 파일이 포함됨"); + + // 전체 범위: 그룹 2개 (folderA + folderB) + let sall = run(&db, None, Scope::All, 5, false, &cancel, |_| {}).unwrap(); + assert_eq!(sall.exact_groups, 2, "전체 범위는 그룹 2개"); + } + #[test] fn signature_distance_median() { let a = vec![0u64, 0, 0, 0, 0]; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e9f064a..e667de8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1039,6 +1039,8 @@ pub fn start_dedupe( app: tauri::AppHandle, state: tauri::State<'_, AppState>, source_id: Option, + folder_id: Option, + recursive: Option, image_threshold: u32, include_video: bool, on_progress: Channel, @@ -1048,6 +1050,13 @@ pub fn start_dedupe( } state.dedupe_cancel.store(false, Ordering::SeqCst); + // 범위 결정: 폴더 > 소스 > 전체 (folder_snapshot과 동일 우선순위) + let scope = match (folder_id, source_id) { + (Some(fid), _) => dedupe::Scope::Folder { id: fid, recursive: recursive.unwrap_or(true) }, + (None, Some(sid)) => dedupe::Scope::Source(sid), + (None, None) => dedupe::Scope::All, + }; + let db = state.db.clone(); let tools = state.tools.clone(); let cancel = state.dedupe_cancel.clone(); @@ -1059,7 +1068,7 @@ pub fn start_dedupe( let result = dedupe::run( &db, tools.as_ref(), - source_id, + scope, image_threshold, include_video, &cancel, diff --git a/src/features/dedupe/DedupePanel.tsx b/src/features/dedupe/DedupePanel.tsx index 502bd11..dc3a039 100644 --- a/src/features/dedupe/DedupePanel.tsx +++ b/src/features/dedupe/DedupePanel.tsx @@ -1,5 +1,7 @@ // 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통. import { + createEffect, + createMemo, createResource, createSignal, For, @@ -26,6 +28,7 @@ import { refreshSidebar, refreshSnapshot, toast, + view, } from "../../state/store"; function fmtSize(bytes: number): string { @@ -48,13 +51,29 @@ export const DedupePanel: Component = () => { () => listDupeGroups(), ); + // 현재 뷰가 폴더/소스면 그 범위로 좁힐 수 있다 (그 외 전체/검색/태그/스마트는 전체만). + const currentScope = createMemo< + { label: string; args: { folderId?: number; recursive?: boolean; sourceId?: number } } | null + >(() => { + const v = view(); + if (v.type === "folder") return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } }; + if (v.type === "source") return { label: ko.dedupe.scopeSource, args: { sourceId: v.sourceId } }; + return null; + }); + // 패널을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로. + const [useCurrentScope, setUseCurrentScope] = createSignal(true); + createEffect(() => { + if (dedupeOpen()) setUseCurrentScope(currentScope() != null); + }); + const runScan = async () => { setRunning(true); setProgress(undefined); + const scoped = useCurrentScope() ? currentScope() : null; try { await new Promise((resolve, reject) => { startDedupe( - { imageThreshold: threshold(), includeVideo: includeVideo() }, + { ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() }, (p) => { setProgress(p); if (p.phase === "done") resolve(); @@ -129,6 +148,37 @@ export const DedupePanel: Component = () => { /> {ko.dedupe.includeVideo} + {/* 범위: 현재 뷰가 폴더/소스일 때만 선택 가능, 아니면 전체 라이브러리 고정 */} + {ko.dedupe.scopeAll}} + > +
+ {ko.dedupe.scopeLabel} +
+ + +
+
+
diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 390eb90..3e255c1 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -122,6 +122,10 @@ export const ko = { }, threshold: "유사도 임계값", includeVideo: "영상 포함", + scopeLabel: "범위:", + scopeFolder: "현재 폴더 (하위 포함)", + scopeSource: "현재 소스", + scopeAll: "전체 라이브러리", empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.", groupCount: (n: number) => `${n}개 그룹`, memberCount: (n: number) => `${n}개 항목`, diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 0383d9b..22ac73b 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -278,13 +278,21 @@ export interface DupeGroup { } export function startDedupe( - args: { sourceId?: number; imageThreshold?: number; includeVideo?: boolean }, + args: { + sourceId?: number; + folderId?: number; + recursive?: boolean; + imageThreshold?: number; + includeVideo?: boolean; + }, onProgress: (p: DedupeProgress) => void, ): Promise { const ch = new Channel(); ch.onmessage = onProgress; return invoke("start_dedupe", { sourceId: args.sourceId ?? null, + folderId: args.folderId ?? null, + recursive: args.recursive ?? null, imageThreshold: args.imageThreshold ?? 5, includeVideo: args.includeVideo ?? false, onProgress: ch,