From e95772e79b06f37127c0e3be0476b29a9cceac65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=20=ED=95=9C?= Date: Sun, 19 Jul 2026 20:18:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=8B=9C=20=EB=A9=94?= =?UTF-8?q?=ED=83=80=20=EB=B0=B1=ED=95=84=20+=20=EB=B0=B1=EA=B7=B8?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=B2=98=EB=A6=AC=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=ED=91=9C=EC=8B=9C(=EC=83=81=ED=83=9C=EB=B0=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메타 백필: 이전 스캔이 중단돼 meta_state=0으로 남은 파일을 시작 시 (전체 재스캔 없이) 저우선순위로 이어서 분석. 썸네일 백필(enqueue_pending)과 짝. - pending_counts 커맨드 + 상태바 표시: "썸네일 생성 중 N / 메타 분석 중 M". thumbs-ready·library-changed·시작 시 트리거하고, 남은 작업이 있으면 2초 폴링, 0이 되면 자동으로 숨김. 검증: 영상 200개 스캔 중 상태바가 200→0으로 감소 후 사라짐. 메타 도중 강제 종료 후 재실행 시 재스캔 없이(scanning=false) 메타/썸네일 백필만 재개됨. Co-Authored-By: Claude Fable 5 --- src-tauri/src/commands.rs | 30 ++++++++++++++++++++++++++++++ src-tauri/src/main.rs | 29 +++++++++++++++++++++++++++++ src/App.tsx | 13 ++++++++++--- src/i18n/ko.ts | 2 ++ src/ipc/commands.ts | 5 +++++ src/shell/StatusBar.tsx | 12 +++++++++++- src/state/store.ts | 25 +++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e667de8..ca38c82 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -48,6 +48,36 @@ pub struct SourceDto { pub file_count: i64, } +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingCounts { + /// 썸네일 생성 대기(thumb_state=0) 이미지/영상 수 + pub thumbs: i64, + /// 메타데이터 분석 대기(meta_state=0) 수 + pub meta: i64, +} + +/// 백그라운드 처리 진행 표시용 — 대기 중인 썸네일/메타 개수. +#[tauri::command] +pub fn pending_counts(state: tauri::State<'_, AppState>) -> CmdResult { + state + .db + .with_read(|conn| { + let thumbs: i64 = conn.query_row( + "SELECT count(*) FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL", + [], + |r| r.get(0), + )?; + let meta: i64 = conn.query_row( + "SELECT count(*) FROM files WHERE meta_state=0 AND deleted_at IS NULL", + [], + |r| r.get(0), + )?; + Ok(PendingCounts { thumbs, meta }) + }) + .map_err(err_str) +} + #[tauri::command] pub fn list_sources(state: tauri::State<'_, AppState>) -> CmdResult> { state diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b128f4..cef5538 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -218,6 +218,34 @@ fn main() { .ok(); } + // 시작 시 메타데이터 백필 — 이전 스캔이 중단돼 meta_state=0으로 남은 파일을 + // (전체 재스캔 없이) 이어서 분석한다. 썸네일 백필(enqueue_pending)과 짝. 저우선순위. + { + let state = app.state::(); + let db = state.db.clone(); + let tools = state.tools.clone(); + std::thread::Builder::new() + .name("meta-backfill".into()) + .spawn(move || { + archive_indexer::prio::set_current_thread_low(); + let cancel = std::sync::atomic::AtomicBool::new(false); + let sources: Vec = db + .with_read(|c| { + let mut s = c.prepare("SELECT id FROM sources WHERE kind='local'")?; + let rows = s.query_map([], |r| r.get(0))?; + rows.collect() + }) + .unwrap_or_default(); + for sid in sources { + let _ = archive_indexer::meta::extract_image_meta(&db, sid, &cancel); + if let Some(t) = &tools { + let _ = archive_indexer::meta::extract_video_meta(&db, sid, &cancel, t); + } + } + }) + .ok(); + } + // dev 편의: ARCHIVE_AUTO_SOURCE=<폴더> → 시작 시 소스 등록 + 스캔 #[cfg(debug_assertions)] if let Ok(auto) = std::env::var("ARCHIVE_AUTO_SOURCE") { @@ -258,6 +286,7 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ commands::app_info, + commands::pending_counts, commands::list_sources, commands::add_local_source, commands::remove_source, diff --git a/src/App.tsx b/src/App.tsx index facf5e7..d2cdb5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,16 +9,23 @@ import { MoveDialog } from "./features/fileops/MoveDialog"; import { DedupePanel } from "./features/dedupe/DedupePanel"; import { RemoteDialog } from "./features/sources/RemoteDialog"; import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands"; -import { bumpThumbVersions, loadAppInfo, refreshSnapshot } from "./state/store"; +import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store"; const App: Component = () => { onMount(() => { void loadAppInfo(); void refreshSnapshot(); + refreshPendingCounts(); const unlisteners: Promise[] = [ - onThumbsReady((ids) => bumpThumbVersions(ids)), - onLibraryChanged(() => onLibraryChangedRefresh()), + onThumbsReady((ids) => { + bumpThumbVersions(ids); + refreshPendingCounts(); + }), + onLibraryChanged(() => { + onLibraryChangedRefresh(); + refreshPendingCounts(); + }), ]; onCleanup(() => { for (const u of unlisteners) void u.then((f) => f()); diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 3e255c1..ed823e8 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -48,6 +48,8 @@ export const ko = { dataDir: "데이터 위치", portable: "포터블 모드", installed: "일반 모드", + thumbPending: (n: number) => `썸네일 생성 중 ${n.toLocaleString("ko-KR")}개`, + metaPending: (n: number) => `메타 분석 중 ${n.toLocaleString("ko-KR")}개`, }, search: { placeholder: "파일명 검색…", diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index 22ac73b..c671979 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -49,6 +49,11 @@ export interface ScanProgress { } export const appInfo = () => invoke("app_info"); +export interface PendingCounts { + thumbs: number; + meta: number; +} +export const pendingCounts = () => invoke("pending_counts"); export const listSources = () => invoke("list_sources"); export const addLocalSource = (path: string) => invoke("add_local_source", { path }); diff --git a/src/shell/StatusBar.tsx b/src/shell/StatusBar.tsx index f410245..cc4d825 100644 --- a/src/shell/StatusBar.tsx +++ b/src/shell/StatusBar.tsx @@ -1,6 +1,6 @@ import { Show, type Component } from "solid-js"; import { ko } from "../i18n/ko"; -import { info, scanProgress, selected, snapshot, statusMessage } from "../state/store"; +import { info, pending, scanProgress, selected, snapshot, statusMessage } from "../state/store"; export const StatusBar: Component = () => { return ( @@ -25,6 +25,16 @@ export const StatusBar: Component = () => { )} + {/* 백그라운드 처리 진행 — 썸네일/메타 대기 수 (있을 때만) */} + 0 || pending().meta > 0}> + + + 0}>{ko.statusBar.thumbPending(pending().thumbs)} + 0}> + {ko.statusBar.metaPending(pending().meta)} + + + {(i) => ( <> diff --git a/src/state/store.ts b/src/state/store.ts index c70596c..f696412 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -4,11 +4,13 @@ import { createStore, produce } from "solid-js/store"; import { appInfo, folderSnapshot, + pendingCounts as fetchPendingCounts, searchSnapshot, smartSnapshot, tagSnapshot, Snapshot, type AppInfo, + type PendingCounts, type ScanProgress, } from "../ipc/commands"; @@ -134,6 +136,29 @@ export function updateScanProgress(p: ScanProgress): void { setScanProgress(p.done ? undefined : p); } +// ── 백그라운드 처리 진행(썸네일/메타 대기 수) ──────────── +const [pending, setPending] = createSignal({ thumbs: 0, meta: 0 }); +export { pending }; + +let pendingTimer: ReturnType | undefined; +async function doRefreshPending(): Promise { + try { + const c = await fetchPendingCounts(); + setPending(c); + clearTimeout(pendingTimer); + // 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타는 이벤트가 없어 폴링 필요) + if (c.thumbs > 0 || c.meta > 0) pendingTimer = setTimeout(() => void doRefreshPending(), 2000); + } catch { + /* 무시 */ + } +} + +/** 대기 수 갱신 트리거(디바운스). 시작·thumbs-ready·library-changed에서 호출. */ +export function refreshPendingCounts(): void { + clearTimeout(pendingTimer); + pendingTimer = setTimeout(() => void doRefreshPending(), 400); +} + // ── 사이드바 새로고침 트리거 ───────────────────────────── const [sidebarVersion, setSidebarVersion] = createSignal(0); export { sidebarVersion };