중복 탐지 범위 선택 추가 — 현재 폴더/소스 또는 전체
기존엔 항상 라이브러리 전체를 대상으로 했다. 이제 중복 검토 패널에서
현재 보고 있는 뷰(폴더=하위 포함 / 소스)로 범위를 좁힐 수 있고,
폴더/소스 뷰에서는 기본값이 '현재 위치'다. 전체/검색/태그/스마트 뷰는
전체 라이브러리로 고정.
- dedupe::Scope { All | Source | Folder{recursive} } 도입, load_files/run에 적용
(folder_snapshot과 동일한 경로 프리픽스 스코핑)
- start_dedupe에 folder_id/recursive 파라미터 추가
- DedupePanel: view() 기반 범위 세그먼트 컨트롤
- 테스트: folder_scope_restricts_detection (folderA=1그룹, 전체=2그룹, 누출 없음)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,18 @@ struct FileMeta {
|
|||||||
duration_ms: Option<i64>,
|
duration_ms: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_files(db: &Db, kind: u8, scope: Option<i64>) -> Result<Vec<FileMeta>> {
|
/// 중복 탐지 범위. `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<Vec<FileMeta>> {
|
||||||
Ok(db.with_read(move |conn| {
|
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
|
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
|
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<i64>) -> Result<Vec<FileMeta>> {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
match scope {
|
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 mut stmt = conn.prepare(&format!("{base} AND f.source_id = ?2"))?;
|
||||||
let rows = stmt.query_map(rusqlite::params![kind, sid], map)?;
|
let rows = stmt.query_map(rusqlite::params![kind, sid], map)?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
None => {
|
Scope::Folder { id, recursive: false } => {
|
||||||
let mut stmt = conn.prepare(base)?;
|
let mut stmt = conn.prepare(&format!("{base} AND f.folder_id = ?2"))?;
|
||||||
let rows = stmt.query_map([kind], map)?;
|
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()
|
rows.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,7 +415,7 @@ fn detect_similar_videos(
|
|||||||
pub fn run(
|
pub fn run(
|
||||||
db: &Arc<Db>,
|
db: &Arc<Db>,
|
||||||
tools: Option<&FfTools>,
|
tools: Option<&FfTools>,
|
||||||
scope_source: Option<i64>,
|
scope: Scope,
|
||||||
image_threshold: u32,
|
image_threshold: u32,
|
||||||
include_video: bool,
|
include_video: bool,
|
||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
@@ -397,14 +427,14 @@ pub fn run(
|
|||||||
let mut grouped: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
let mut grouped: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||||
|
|
||||||
// 완전 동일 (이미지+영상 모두)
|
// 완전 동일 (이미지+영상 모두)
|
||||||
let mut all_files = load_files(db, 0, scope_source)?;
|
let mut all_files = load_files(db, 0, scope)?;
|
||||||
all_files.extend(load_files(db, 1, scope_source)?);
|
all_files.extend(load_files(db, 1, scope)?);
|
||||||
stats.exact_groups = detect_exact(db, &all_files, &mut grouped, cancel, |c, t| {
|
stats.exact_groups = detect_exact(db, &all_files, &mut grouped, cancel, |c, t| {
|
||||||
on_progress(DedupeProgress { phase: "exact".into(), current: c, total: 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| {
|
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 });
|
on_progress(DedupeProgress { phase: "image".into(), current: c, total: t });
|
||||||
})?;
|
})?;
|
||||||
@@ -412,7 +442,7 @@ pub fn run(
|
|||||||
// 유사 영상 (opt-in + ffmpeg 필요)
|
// 유사 영상 (opt-in + ffmpeg 필요)
|
||||||
if include_video {
|
if include_video {
|
||||||
if let Some(tools) = tools {
|
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| {
|
stats.video_groups = detect_similar_videos(db, &videos, tools, 8, &dismissed, cancel, |c, t| {
|
||||||
on_progress(DedupeProgress { phase: "video".into(), current: c, total: 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::pipeline::scan_source(&db, sid, root, &cancel, |_| {}).unwrap();
|
||||||
crate::meta::extract_image_meta(&db, sid, &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)
|
// 완전 동일 그룹 1개 (original + exact_copy)
|
||||||
assert_eq!(stats.exact_groups, 1, "완전 동일 그룹");
|
assert_eq!(stats.exact_groups, 1, "완전 동일 그룹");
|
||||||
@@ -493,6 +523,56 @@ mod tests {
|
|||||||
assert_eq!(keepers, 1);
|
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]
|
#[test]
|
||||||
fn signature_distance_median() {
|
fn signature_distance_median() {
|
||||||
let a = vec![0u64, 0, 0, 0, 0];
|
let a = vec![0u64, 0, 0, 0, 0];
|
||||||
|
|||||||
@@ -1039,6 +1039,8 @@ pub fn start_dedupe(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
source_id: Option<i64>,
|
source_id: Option<i64>,
|
||||||
|
folder_id: Option<i64>,
|
||||||
|
recursive: Option<bool>,
|
||||||
image_threshold: u32,
|
image_threshold: u32,
|
||||||
include_video: bool,
|
include_video: bool,
|
||||||
on_progress: Channel<DedupeProgress>,
|
on_progress: Channel<DedupeProgress>,
|
||||||
@@ -1048,6 +1050,13 @@ pub fn start_dedupe(
|
|||||||
}
|
}
|
||||||
state.dedupe_cancel.store(false, Ordering::SeqCst);
|
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 db = state.db.clone();
|
||||||
let tools = state.tools.clone();
|
let tools = state.tools.clone();
|
||||||
let cancel = state.dedupe_cancel.clone();
|
let cancel = state.dedupe_cancel.clone();
|
||||||
@@ -1059,7 +1068,7 @@ pub fn start_dedupe(
|
|||||||
let result = dedupe::run(
|
let result = dedupe::run(
|
||||||
&db,
|
&db,
|
||||||
tools.as_ref(),
|
tools.as_ref(),
|
||||||
source_id,
|
scope,
|
||||||
image_threshold,
|
image_threshold,
|
||||||
include_video,
|
include_video,
|
||||||
&cancel,
|
&cancel,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
|
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
|
||||||
import {
|
import {
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
createSignal,
|
createSignal,
|
||||||
For,
|
For,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
refreshSidebar,
|
refreshSidebar,
|
||||||
refreshSnapshot,
|
refreshSnapshot,
|
||||||
toast,
|
toast,
|
||||||
|
view,
|
||||||
} from "../../state/store";
|
} from "../../state/store";
|
||||||
|
|
||||||
function fmtSize(bytes: number): string {
|
function fmtSize(bytes: number): string {
|
||||||
@@ -48,13 +51,29 @@ export const DedupePanel: Component = () => {
|
|||||||
() => listDupeGroups(),
|
() => 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 () => {
|
const runScan = async () => {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setProgress(undefined);
|
setProgress(undefined);
|
||||||
|
const scoped = useCurrentScope() ? currentScope() : null;
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
startDedupe(
|
startDedupe(
|
||||||
{ imageThreshold: threshold(), includeVideo: includeVideo() },
|
{ ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() },
|
||||||
(p) => {
|
(p) => {
|
||||||
setProgress(p);
|
setProgress(p);
|
||||||
if (p.phase === "done") resolve();
|
if (p.phase === "done") resolve();
|
||||||
@@ -129,6 +148,37 @@ export const DedupePanel: Component = () => {
|
|||||||
/>
|
/>
|
||||||
{ko.dedupe.includeVideo}
|
{ko.dedupe.includeVideo}
|
||||||
</label>
|
</label>
|
||||||
|
{/* 범위: 현재 뷰가 폴더/소스일 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
|
||||||
|
<Show
|
||||||
|
when={currentScope()}
|
||||||
|
fallback={<span class="text-[var(--text-muted)]">{ko.dedupe.scopeAll}</span>}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span>{ko.dedupe.scopeLabel}</span>
|
||||||
|
<div class="flex overflow-hidden rounded border border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
class="px-2 py-0.5"
|
||||||
|
classList={{
|
||||||
|
"bg-[var(--bg-active)] text-[var(--text-primary)]": useCurrentScope(),
|
||||||
|
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !useCurrentScope(),
|
||||||
|
}}
|
||||||
|
onClick={() => setUseCurrentScope(true)}
|
||||||
|
>
|
||||||
|
{currentScope()!.label}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="border-l border-[var(--border)] px-2 py-0.5"
|
||||||
|
classList={{
|
||||||
|
"bg-[var(--bg-active)] text-[var(--text-primary)]": !useCurrentScope(),
|
||||||
|
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": useCurrentScope(),
|
||||||
|
}}
|
||||||
|
onClick={() => setUseCurrentScope(false)}
|
||||||
|
>
|
||||||
|
{ko.dedupe.scopeAll}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<Show when={running()}>
|
<Show when={running()}>
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export const ko = {
|
|||||||
},
|
},
|
||||||
threshold: "유사도 임계값",
|
threshold: "유사도 임계값",
|
||||||
includeVideo: "영상 포함",
|
includeVideo: "영상 포함",
|
||||||
|
scopeLabel: "범위:",
|
||||||
|
scopeFolder: "현재 폴더 (하위 포함)",
|
||||||
|
scopeSource: "현재 소스",
|
||||||
|
scopeAll: "전체 라이브러리",
|
||||||
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
|
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
|
||||||
groupCount: (n: number) => `${n}개 그룹`,
|
groupCount: (n: number) => `${n}개 그룹`,
|
||||||
memberCount: (n: number) => `${n}개 항목`,
|
memberCount: (n: number) => `${n}개 항목`,
|
||||||
|
|||||||
+9
-1
@@ -278,13 +278,21 @@ export interface DupeGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startDedupe(
|
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,
|
onProgress: (p: DedupeProgress) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ch = new Channel<DedupeProgress>();
|
const ch = new Channel<DedupeProgress>();
|
||||||
ch.onmessage = onProgress;
|
ch.onmessage = onProgress;
|
||||||
return invoke<void>("start_dedupe", {
|
return invoke<void>("start_dedupe", {
|
||||||
sourceId: args.sourceId ?? null,
|
sourceId: args.sourceId ?? null,
|
||||||
|
folderId: args.folderId ?? null,
|
||||||
|
recursive: args.recursive ?? null,
|
||||||
imageThreshold: args.imageThreshold ?? 5,
|
imageThreshold: args.imageThreshold ?? 5,
|
||||||
includeVideo: args.includeVideo ?? false,
|
includeVideo: args.includeVideo ?? false,
|
||||||
onProgress: ch,
|
onProgress: ch,
|
||||||
|
|||||||
Reference in New Issue
Block a user