//! Tauri 커맨드 — 프론트엔드 `src/ipc/commands.ts`와 1:1 대응. use crate::AppState; use archive_indexer::pipeline::ScanProgress; use std::path::Path; use std::sync::atomic::Ordering; use tauri::ipc::{Channel, Response}; use tauri::Emitter; type CmdResult = Result; fn err_str(e: E) -> String { e.to_string() } // ── 앱 정보 ────────────────────────────────────────────── #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AppInfoDto { pub version: String, pub data_dir: String, pub portable: bool, pub media_port: u16, pub media_token: String, } #[tauri::command] pub fn app_info(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> AppInfoDto { AppInfoDto { version: app.package_info().version.to_string(), data_dir: state.data_dir.display().to_string(), portable: state.portable, media_port: state.media.port, media_token: state.media.token.clone(), } } // ── 소스 관리 ──────────────────────────────────────────── #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct SourceDto { pub id: i64, pub kind: String, pub name: String, pub root: String, pub file_count: i64, } #[tauri::command] pub fn list_sources(state: tauri::State<'_, AppState>) -> CmdResult> { state .db .with_read(|conn| { let mut stmt = conn.prepare_cached( "SELECT s.id, s.kind, s.name, s.root, (SELECT count(*) FROM files f WHERE f.source_id = s.id AND f.deleted_at IS NULL) FROM sources s ORDER BY s.id", )?; let rows = stmt.query_map([], |r| { Ok(SourceDto { id: r.get(0)?, kind: r.get(1)?, name: r.get(2)?, root: r.get(3)?, file_count: r.get(4)?, }) })?; rows.collect() }) .map_err(err_str) } #[tauri::command] pub fn add_local_source(state: tauri::State<'_, AppState>, path: String) -> CmdResult { let p = Path::new(&path); if !p.is_dir() { return Err(format!("폴더가 아닙니다: {path}")); } let name = p .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.clone()); let (id, name, root) = state .db .with_write({ let path = path.clone(); let name = name.clone(); move |conn| { conn.execute( "INSERT INTO sources(kind, name, root) VALUES ('local', ?1, ?2)", rusqlite::params![name, path], )?; Ok((conn.last_insert_rowid(), name, path)) } }) .map_err(err_str)?; // 새 소스도 워치 대상에 포함 if let Some(w) = state.watchers.get() { w.refresh(&state.db); } Ok(SourceDto { id, kind: "local".into(), name, root, file_count: 0, }) } /// 소스를 인덱스에서 제외한다 — **디스크의 실제 파일은 삭제하지 않는다.** /// 워처 해제, (원격이면) 연결 해제·자격증명 삭제, 썸네일 캐시 정리 포함. #[tauri::command] pub async fn remove_source( app: tauri::AppHandle, state: tauri::State<'_, AppState>, source_id: i64, ) -> CmdResult { if state.scan_running.load(Ordering::SeqCst) { return Err("스캔이 진행 중입니다. 완료 후 다시 시도하세요".into()); } let kind: String = state .db .with_read(|conn| { conn.query_row("SELECT kind FROM sources WHERE id = ?1", [source_id], |r| r.get(0)) }) .map_err(|_| "소스를 찾을 수 없습니다".to_string())?; if kind == "local" { if let Some(w) = state.watchers.get() { w.remove(source_id); } } else { state.remote.disconnect(source_id).await; let key = remote::cred_key(&kind, source_id); if let Err(e) = state.creds.lock().unwrap().delete(&key) { tracing::warn!("자격증명 삭제 실패: {e}"); } } let db = state.db.clone(); let thumb_root = state.data_dir.join("thumbs"); let removed = tauri::async_runtime::spawn_blocking(move || { archive_indexer::maintenance::remove_source(&db, &thumb_root, source_id) }) .await .map_err(err_str)? .map_err(err_str)?; tracing::info!(source_id, removed, "소스 제외 완료"); let _ = app.emit("library-changed", source_id); Ok(removed) } // ── 스캔 ──────────────────────────────────────────────── #[tauri::command] pub fn start_scan( app: tauri::AppHandle, state: tauri::State<'_, AppState>, source_id: i64, on_progress: Channel, ) -> CmdResult<()> { if state.scan_running.swap(true, Ordering::SeqCst) { return Err("이미 스캔이 진행 중입니다".into()); } state.scan_cancel.store(false, Ordering::SeqCst); let root: String = match state.db.with_read(|conn| { conn.query_row( "SELECT root FROM sources WHERE id = ?1 AND kind = 'local'", [source_id], |r| r.get(0), ) }) { Ok(r) => r, Err(e) => { state.scan_running.store(false, Ordering::SeqCst); return Err(err_str(e)); } }; spawn_scan_thread( app, &state, source_id, root, Some(on_progress), ) } /// 스캔+메타+썸네일 파이프라인 본체 (블로킹). scan_running 관리는 호출자 책임. pub fn run_scan_pipeline( db: &std::sync::Arc, tools: &Option, thumbs: &archive_indexer::thumbq::ThumbQueue, source_id: i64, root: &Path, cancel: &std::sync::atomic::AtomicBool, mut on_progress: impl FnMut(ScanProgress), ) { let result = archive_indexer::pipeline::scan_source(db, source_id, root, cancel, &mut on_progress); match result { Ok(stats) => { tracing::info!(?stats, source_id, "스캔 완료 — 메타데이터 추출 시작"); match archive_indexer::meta::extract_image_meta(db, source_id, cancel) { Ok(n) => tracing::info!(count = n, "이미지 메타 추출 완료"), Err(e) => tracing::error!("이미지 메타 추출 오류: {e}"), } if let Some(t) = tools { match archive_indexer::meta::extract_video_meta(db, source_id, cancel, t) { Ok(n) => tracing::info!(count = n, "영상 메타 추출 완료"), Err(e) => tracing::error!("영상 메타 추출 오류: {e}"), } } thumbs.enqueue_pending(db, Some(source_id)); } Err(e) => tracing::warn!("스캔 중단: {e}"), } } /// 스캔 스레드 — start_scan 커맨드, 자동 소스 추가(dev), 워처가 공유한다. pub fn spawn_scan_thread( app: tauri::AppHandle, state: &AppState, source_id: i64, root: String, on_progress: Option>, ) -> CmdResult<()> { let db = state.db.clone(); let cancel = state.scan_cancel.clone(); let running = state.scan_running.clone(); let thumbs = state.thumbs.clone(); let tools = state.tools.clone(); std::thread::Builder::new() .name("scan".into()) .spawn(move || { // 스캔/메타 오케스트레이션 스레드도 저우선순위로 — UI 응답성 우선 archive_indexer::prio::set_current_thread_low(); run_scan_pipeline(&db, &tools, &thumbs, source_id, Path::new(&root), &cancel, |p| { if let Some(ch) = &on_progress { let _ = ch.send(p); } }); running.store(false, Ordering::SeqCst); let _ = app.emit("library-changed", source_id); }) .map_err(err_str)?; Ok(()) } #[tauri::command] pub fn cancel_scan(state: tauri::State<'_, AppState>) { state.scan_cancel.store(true, Ordering::SeqCst); } // ── 폴더 트리 ──────────────────────────────────────────── #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct FolderNode { pub id: i64, pub parent_id: Option, pub name: String, pub file_count: i64, } #[tauri::command] pub fn folder_tree(state: tauri::State<'_, AppState>, source_id: i64) -> CmdResult> { state .db .with_read(|conn| { let mut stmt = conn.prepare_cached( "SELECT fo.id, fo.parent_id, fo.name, (SELECT count(*) FROM files f WHERE f.folder_id = fo.id AND f.deleted_at IS NULL) FROM folders fo WHERE fo.source_id = ?1 ORDER BY fo.path", )?; let rows = stmt.query_map([source_id], |r| { Ok(FolderNode { id: r.get(0)?, parent_id: r.get(1)?, name: r.get(2)?, file_count: r.get(3)?, }) })?; rows.collect() }) .map_err(err_str) } // ── 바이너리 스냅샷 (항목당 10바이트: id u32, w u16, h u16, kind u8, flags u8) ── fn pack_snapshot(rows: Vec<(i64, Option, Option, Option, i64, i64)>) -> Vec { let mut buf = Vec::with_capacity(rows.len() * 10); for (id, w, h, orientation, kind, thumb_state) in rows { let (mut w, mut h) = (w.unwrap_or(0), h.unwrap_or(0)); // EXIF 회전 5–8은 표시 치수가 뒤바뀐다 if matches!(orientation, Some(5..=8)) { std::mem::swap(&mut w, &mut h); } buf.extend_from_slice(&(id as u32).to_le_bytes()); buf.extend_from_slice(&(w.min(65535) as u16).to_le_bytes()); buf.extend_from_slice(&(h.min(65535) as u16).to_le_bytes()); buf.push(kind as u8); // 비트0 = 썸네일 준비됨(state 1), 비트1 = 생성 시도했으나 실패(state 2). // 둘 다 0이면 생성 대기(state 0) — 프론트에서 스켈레톤/실패표시 구분용. let flags = match thumb_state { 1 => 1u8, 2 => 2u8, _ => 0u8, }; buf.push(flags); } buf } type SnapRow = (i64, Option, Option, Option, i64, i64); const SNAP_COLS: &str = "f.id, f.width, f.height, f.orientation, f.kind, f.thumb_state"; const SNAP_ORDER: &str = "ORDER BY COALESCE(f.taken_at, f.mtime_ms) DESC, f.id DESC"; fn map_snap_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) } #[tauri::command] pub fn folder_snapshot( state: tauri::State<'_, AppState>, source_id: Option, folder_id: Option, recursive: bool, ) -> CmdResult { let rows: Vec = state .db .with_read(|conn| { match (folder_id, recursive) { (Some(fid), false) => { let sql = format!( "SELECT {SNAP_COLS} FROM files f WHERE f.folder_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([fid], |r| map_snap_row(r))?; rows.collect() } (Some(fid), true) => { // 폴더 경로 프리픽스로 하위 전체 let (src, path): (i64, String) = conn.query_row( "SELECT source_id, path FROM folders WHERE id = ?1", [fid], |r| Ok((r.get(0)?, r.get(1)?)), )?; let prefix = format!("{}{}%", path, std::path::MAIN_SEPARATOR); let sql = format!( "SELECT {SNAP_COLS} FROM files f JOIN folders fo ON fo.id = f.folder_id WHERE fo.source_id = ?1 AND (fo.path = ?2 OR fo.path LIKE ?3) AND f.deleted_at IS NULL {SNAP_ORDER}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map(rusqlite::params![src, path, prefix], |r| map_snap_row(r))?; rows.collect() } (None, _) => match source_id { Some(sid) => { let sql = format!( "SELECT {SNAP_COLS} FROM files f WHERE f.source_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([sid], |r| map_snap_row(r))?; rows.collect() } None => { let sql = format!( "SELECT {SNAP_COLS} FROM files f WHERE f.deleted_at IS NULL {SNAP_ORDER}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([], |r| map_snap_row(r))?; rows.collect() } }, } }) .map_err(err_str)?; Ok(Response::new(pack_snapshot(rows))) } #[tauri::command] pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdResult { let q = query.trim().to_string(); if q.is_empty() { return Ok(Response::new(Vec::new())); } let use_fts = state.db.fts_enabled && q.chars().count() >= 3; let rows: Vec = state .db .with_read(|conn| { if use_fts { let sql = format!( "SELECT {SNAP_COLS} FROM files_fts ft JOIN files f ON f.id = ft.rowid WHERE files_fts MATCH ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" ); let mut stmt = conn.prepare_cached(&sql)?; // MATCH 구문 특수문자 무력화를 위해 따옴표로 감싼다 let fts_q = format!("\"{}\"", q.replace('"', "\"\"")); let rows = stmt.query_map([fts_q], |r| map_snap_row(r))?; rows.collect() } else { let sql = format!( "SELECT {SNAP_COLS} FROM files f WHERE f.name LIKE '%' || ?1 || '%' ESCAPE '\\' AND f.deleted_at IS NULL {SNAP_ORDER}" ); let mut stmt = conn.prepare_cached(&sql)?; let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); let rows = stmt.query_map([escaped], |r| map_snap_row(r))?; rows.collect() } }) .map_err(err_str)?; Ok(Response::new(pack_snapshot(rows))) } // ── 파일 상세 (가시 셀 지연 로드) ───────────────────────── #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct FileDetails { pub id: i64, pub name: String, pub dir: String, pub size: i64, pub mtime_ms: i64, pub width: Option, pub height: Option, pub duration_ms: Option, pub kind: i64, pub taken_at: Option, } #[tauri::command] pub fn file_details(state: tauri::State<'_, AppState>, ids: Vec) -> CmdResult> { let ids: Vec = ids.into_iter().take(500).collect(); state .db .with_read(|conn| { let mut stmt = conn.prepare_cached( "SELECT f.id, f.name, fo.path, f.size, f.mtime_ms, f.width, f.height, f.duration_ms, f.kind, f.taken_at FROM files f JOIN folders fo ON fo.id = f.folder_id WHERE f.id = ?1", )?; let mut out = Vec::with_capacity(ids.len()); for id in &ids { if let Ok(d) = stmt.query_row([id], |r| { Ok(FileDetails { id: r.get(0)?, name: r.get(1)?, dir: r.get(2)?, size: r.get(3)?, mtime_ms: r.get(4)?, width: r.get(5)?, height: r.get(6)?, duration_ms: r.get(7)?, kind: r.get(8)?, taken_at: r.get(9)?, }) }) { out.push(d); } } Ok(out) }) .map_err(err_str) } // ── 썸네일 우선순위 ────────────────────────────────────── #[tauri::command] pub fn set_thumb_priority(state: tauri::State<'_, AppState>, ids: Vec) { state.thumbs.prioritize(&ids); } // ── 태그 ──────────────────────────────────────────────── use archive_indexer::fileops::{self, OpSummary}; use archive_indexer::tags::{self, Tag}; #[tauri::command] pub fn list_tags(state: tauri::State<'_, AppState>) -> CmdResult> { tags::list_tags(&state.db).map_err(err_str) } #[tauri::command] pub fn create_tag( state: tauri::State<'_, AppState>, name: String, color: Option, ) -> CmdResult { let name = name.trim().to_string(); if name.is_empty() { return Err("태그 이름이 비었습니다".into()); } tags::create_tag(&state.db, name, color).map_err(err_str) } #[tauri::command] pub fn rename_tag(state: tauri::State<'_, AppState>, tag_id: i64, name: String) -> CmdResult<()> { tags::rename_tag(&state.db, tag_id, name).map_err(err_str) } #[tauri::command] pub fn delete_tag(state: tauri::State<'_, AppState>, tag_id: i64) -> CmdResult<()> { tags::delete_tag(&state.db, tag_id).map_err(err_str) } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct TagOpResult { pub batch_id: String, pub count: u64, } #[tauri::command] pub fn assign_tags( state: tauri::State<'_, AppState>, file_ids: Vec, tag_id: i64, ) -> CmdResult { let batch_id = fileops::new_batch_id(); let count = tags::assign(&state.db, &batch_id, file_ids, tag_id).map_err(err_str)?; Ok(TagOpResult { batch_id, count }) } #[tauri::command] pub fn unassign_tags( state: tauri::State<'_, AppState>, file_ids: Vec, tag_id: i64, ) -> CmdResult { let batch_id = fileops::new_batch_id(); let count = tags::unassign(&state.db, &batch_id, file_ids, tag_id).map_err(err_str)?; Ok(TagOpResult { batch_id, count }) } #[tauri::command] pub fn file_tags_of(state: tauri::State<'_, AppState>, file_id: i64) -> CmdResult> { tags::file_tags(&state.db, file_id).map_err(err_str) } /// 태그 JSON 내보내기 — 경로를 반환한다. #[tauri::command] pub fn export_tags(state: tauri::State<'_, AppState>, path: Option) -> CmdResult { let json = tags::export_json(&state.db).map_err(err_str)?; let dest = match path { Some(p) => std::path::PathBuf::from(p), None => state.data_dir.join("tags-export.json"), }; std::fs::write(&dest, json).map_err(err_str)?; Ok(dest.display().to_string()) } #[tauri::command] pub fn tag_snapshot(state: tauri::State<'_, AppState>, tag_id: i64) -> CmdResult { let rows: Vec = state .db .with_read(|conn| { let sql = format!( "SELECT {SNAP_COLS} FROM files f JOIN file_tags ft ON ft.file_id = f.id WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([tag_id], |r| map_snap_row(r))?; rows.collect() }) .map_err(err_str)?; Ok(Response::new(pack_snapshot(rows))) } // ── 파일 작업 ─────────────────────────────────────────── #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct OpProgress { pub done: u64, pub total: u64, } #[tauri::command] pub async fn move_files( app: tauri::AppHandle, state: tauri::State<'_, AppState>, file_ids: Vec, dest_folder_id: i64, on_progress: Channel, ) -> CmdResult { let db = state.db.clone(); let result = tauri::async_runtime::spawn_blocking(move || { let batch_id = fileops::new_batch_id(); fileops::move_files(&db, &batch_id, &file_ids, dest_folder_id, |done, total| { let _ = on_progress.send(OpProgress { done, total }); }) }) .await .map_err(err_str)? .map_err(err_str)?; let _ = app.emit("library-changed", 0i64); Ok(result) } #[tauri::command] pub fn rename_file( app: tauri::AppHandle, state: tauri::State<'_, AppState>, file_id: i64, new_name: String, ) -> CmdResult<()> { let batch_id = fileops::new_batch_id(); fileops::rename_file(&state.db, &batch_id, file_id, &new_name).map_err(err_str)?; let _ = app.emit("library-changed", 0i64); Ok(()) } #[tauri::command] pub async fn trash_files( app: tauri::AppHandle, state: tauri::State<'_, AppState>, file_ids: Vec, on_progress: Channel, ) -> CmdResult { let db = state.db.clone(); let result = tauri::async_runtime::spawn_blocking(move || { let batch_id = fileops::new_batch_id(); fileops::trash_files(&db, &batch_id, &file_ids, |done, total| { let _ = on_progress.send(OpProgress { done, total }); }) }) .await .map_err(err_str)? .map_err(err_str)?; let _ = app.emit("library-changed", 0i64); Ok(result) } /// 가장 최근 undo 가능 배치를 되돌린다. #[tauri::command] pub async fn undo_last( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> CmdResult> { let db = state.db.clone(); let result = tauri::async_runtime::spawn_blocking(move || { let batches = fileops::list_batches(&db, 10)?; let Some(target) = batches.into_iter().find(|b| b.undoable) else { return Ok::, archive_indexer::fileops::OpError>(None); }; Ok(Some(fileops::undo_batch(&db, &target.batch_id)?)) }) .await .map_err(err_str)? .map_err(err_str)?; let _ = app.emit("library-changed", 0i64); Ok(result) } // ── 스마트 폴더 ───────────────────────────────────────── #[derive(Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SmartQuery { #[serde(default)] pub kind: Option, #[serde(default)] pub tag_ids: Vec, #[serde(default)] pub untagged: bool, } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct SmartFolder { pub id: i64, pub name: String, pub query: SmartQuery, } #[tauri::command] pub fn list_smart_folders(state: tauri::State<'_, AppState>) -> CmdResult> { state .db .with_read(|conn| { let mut stmt = conn.prepare_cached("SELECT id, name, query FROM smart_folders ORDER BY id")?; let rows = stmt.query_map([], |r| { let q: String = r.get(2)?; Ok(SmartFolder { id: r.get(0)?, name: r.get(1)?, query: serde_json::from_str(&q).unwrap_or(SmartQuery { kind: None, tag_ids: vec![], untagged: false, }), }) })?; rows.collect() }) .map_err(err_str) } #[tauri::command] pub fn create_smart_folder( state: tauri::State<'_, AppState>, name: String, query: SmartQuery, ) -> CmdResult { let q = serde_json::to_string(&query).map_err(err_str)?; state .db .with_write(move |conn| { conn.execute( "INSERT INTO smart_folders(name, query) VALUES (?1, ?2)", rusqlite::params![name, q], )?; Ok(conn.last_insert_rowid()) }) .map_err(err_str) } #[tauri::command] pub fn delete_smart_folder(state: tauri::State<'_, AppState>, id: i64) -> CmdResult<()> { state .db .with_write(move |conn| { conn.execute("DELETE FROM smart_folders WHERE id = ?1", [id])?; Ok(()) }) .map_err(err_str) } #[tauri::command] pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult { let query: SmartQuery = state .db .with_read(|conn| { conn.query_row("SELECT query FROM smart_folders WHERE id = ?1", [id], |r| { let q: String = r.get(0)?; Ok(serde_json::from_str(&q).unwrap_or(SmartQuery { kind: None, tag_ids: vec![], untagged: false, })) }) }) .map_err(err_str)?; let mut where_clauses = vec!["f.deleted_at IS NULL".to_string()]; if let Some(k) = query.kind { where_clauses.push(format!("f.kind = {}", k.min(2))); } if query.untagged { where_clauses.push("NOT EXISTS (SELECT 1 FROM file_tags ft WHERE ft.file_id = f.id)".into()); } else if !query.tag_ids.is_empty() { let ids = query .tag_ids .iter() .map(|i| i.to_string()) .collect::>() .join(","); where_clauses.push(format!( "EXISTS (SELECT 1 FROM file_tags ft WHERE ft.file_id = f.id AND ft.tag_id IN ({ids}))" )); } let where_sql = where_clauses.join(" AND "); let rows: Vec = state .db .with_read(|conn| { let sql = format!("SELECT {SNAP_COLS} FROM files f WHERE {where_sql} {SNAP_ORDER}"); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map([], |r| map_snap_row(r))?; rows.collect() }) .map_err(err_str)?; Ok(Response::new(pack_snapshot(rows))) } // ── 원격 소스 (SFTP/WebDAV/FTP) ───────────────────────── use crate::remote::{self, RemoteConfig}; #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteInput { pub kind: String, // sftp | ftp | webdav pub name: String, pub host: String, #[serde(default)] pub port: u16, pub username: String, pub password: String, #[serde(default)] pub base_path: String, #[serde(default)] pub url: String, } /// 원격 소스를 등록한다 (비밀번호는 CredStore에, config는 DB에). #[tauri::command] pub fn add_remote_source(state: tauri::State<'_, AppState>, input: RemoteInput) -> CmdResult { if !matches!(input.kind.as_str(), "sftp" | "ftp" | "webdav") { return Err(format!("지원하지 않는 종류: {}", input.kind)); } let config = RemoteConfig { host: input.host.clone(), port: input.port, username: input.username.clone(), base_path: input.base_path.clone(), url: input.url.clone(), }; let config_json = serde_json::to_string(&config).map_err(err_str)?; let root = if input.kind == "webdav" { input.url.clone() } else { format!("{}://{}@{}", input.kind, input.username, input.host) }; let source_id = state .db .with_write({ let kind = input.kind.clone(); let name = input.name.clone(); move |conn| { conn.execute( "INSERT INTO sources(kind, name, root, config) VALUES (?1, ?2, ?3, ?4)", rusqlite::params![kind, name, root, config_json], )?; Ok(conn.last_insert_rowid()) } }) .map_err(err_str)?; // 비밀번호 저장 (키체인 또는 볼트) let key = remote::cred_key(&input.kind, source_id); state .creds .lock() .unwrap() .set(&key, &input.password) .map_err(|e| format!("자격증명 저장 실패: {e}"))?; Ok(source_id) } /// 원격 소스에 연결 (자격증명 조회 → VFS 연결). #[tauri::command] pub async fn connect_remote_source( state: tauri::State<'_, AppState>, source_id: i64, ) -> CmdResult { let (kind, config_json): (String, Option) = state .db .with_read(|conn| { conn.query_row( "SELECT kind, config FROM sources WHERE id = ?1", [source_id], |r| Ok((r.get(0)?, r.get(1)?)), ) }) .map_err(err_str)?; let config: RemoteConfig = serde_json::from_str(&config_json.unwrap_or_default()).map_err(err_str)?; let key = remote::cred_key(&kind, source_id); let password = state .creds .lock() .unwrap() .get(&key) .map_err(|e| format!("자격증명 조회 실패: {e}"))? .ok_or("저장된 비밀번호가 없습니다")?; state .remote .connect(source_id, &kind, &config, &password) .await .map_err(|e| format!("연결 실패: {e}"))?; Ok(true) } /// 원격 소스를 인덱싱한다 (연결되어 있어야 함). #[tauri::command] pub async fn scan_remote_source( app: tauri::AppHandle, state: tauri::State<'_, AppState>, source_id: i64, ) -> CmdResult { let provider = state .remote .get(source_id) .await .ok_or("먼저 연결하세요")?; let db = state.db.clone(); let cancel = state.scan_cancel.clone(); cancel.store(false, Ordering::SeqCst); let count = remote::index_remote(&db, &provider, source_id, &cancel, |_| {}) .await .map_err(|e| format!("원격 인덱싱 실패: {e}"))?; let _ = app.emit("library-changed", source_id); // 원격 썸네일 백그라운드 생성 (VFS 다운로드 / 영상은 bridge URL) let thumb_root = state.data_dir.join("thumbs"); let media_base = format!( "http://127.0.0.1:{}/media/{}", state.media.port, state.media.token ); let tools = state.tools.clone(); let emit = app.clone(); tauri::async_runtime::spawn(async move { archive_indexer_generate_remote(&db, &provider, source_id, &thumb_root, &media_base, &tools, &cancel, &emit).await; }); Ok(count) } async fn archive_indexer_generate_remote( db: &std::sync::Arc, provider: &std::sync::Arc, source_id: i64, thumb_root: &std::path::PathBuf, media_base: &str, tools: &Option, cancel: &std::sync::atomic::AtomicBool, app: &tauri::AppHandle, ) { crate::remote_thumbs::generate(db, provider, source_id, thumb_root, media_base, tools, cancel, |ids| { let _ = app.emit("thumbs-ready", ids); }) .await; } /// 원격 파일을 앱 레벨 휴지통으로 이동한다 (OS 휴지통 없음 → 서버측 rename). /// 소스 루트의 `.archive_trash/{yyyymm}/` 로 옮기고 DB 소프트 삭제. #[tauri::command] pub async fn trash_remote_files( app: tauri::AppHandle, state: tauri::State<'_, AppState>, source_id: i64, file_ids: Vec, ) -> CmdResult { let provider = state.remote.get(source_id).await.ok_or("먼저 연결하세요")?; let batch_id = fileops::new_batch_id(); let mut summary = OpSummary { batch_id: batch_id.clone(), ..Default::default() }; // yyyymm 폴더 보장 (호출자가 실제 날짜를 몰라도 되도록 스캔 세션 기준 근사) let trash_dir = ".archive_trash"; let _ = provider.mkdir(trash_dir).await; for file_id in file_ids { let loc: Option<(String, String)> = state .db .with_read(|conn| { conn.query_row( "SELECT 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", [file_id], |r| Ok((r.get(0)?, r.get(1)?)), ) }) .ok(); let Some((dir, name)) = loc else { summary.failed += 1; continue; }; let rel = if dir == "/" || dir.is_empty() { name.clone() } else { format!("{}/{}", dir.trim_start_matches('/'), name) }; // 충돌 방지: file_id 접두 let dest = format!("{trash_dir}/{file_id}_{name}"); match provider.rename(&rel, &dest).await { Ok(()) => { let bid = batch_id.clone(); state .db .with_write(move |conn| { conn.execute( "UPDATE files SET deleted_at = unixepoch('now','subsec')*1000 WHERE id = ?1", [file_id], )?; conn.execute( "INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path) VALUES (?1, 'trash', ?2, ?3, ?4)", rusqlite::params![bid, file_id, rel, dest], )?; Ok(()) }) .map_err(err_str)?; summary.done += 1; } Err(e) => { tracing::warn!(file_id, "원격 휴지통 실패: {e}"); summary.failed += 1; } } } let _ = app.emit("library-changed", source_id); Ok(summary) } /// 포터블 볼트 잠금 해제 (패스프레이즈로 CredStore 교체). #[tauri::command] pub fn unlock_vault(state: tauri::State<'_, AppState>, passphrase: String) -> CmdResult<()> { let vault_path = state.data_dir.join("vault.bin"); let store = remote::make_cred_store(Some(vault_path), Some(&passphrase)); match &store { archive_vfs::creds::CredStore::Vault(_) => { *state.creds.lock().unwrap() = store; Ok(()) } archive_vfs::creds::CredStore::Keychain => Err("볼트 열기 실패".into()), } } // ── 중복 탐지 ─────────────────────────────────────────── use archive_indexer::dedupe::{self, DedupeProgress}; #[tauri::command] 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, ) -> CmdResult<()> { if state.dedupe_running.swap(true, Ordering::SeqCst) { return Err("이미 중복 탐지가 진행 중입니다".into()); } 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(); let running = state.dedupe_running.clone(); std::thread::Builder::new() .name("dedupe".into()) .spawn(move || { let result = dedupe::run( &db, tools.as_ref(), scope, image_threshold, include_video, &cancel, |p| { let _ = on_progress.send(p); }, ); match &result { Ok(stats) => tracing::info!(?stats, "중복 탐지 완료"), Err(e) => tracing::warn!("중복 탐지 중단: {e}"), } running.store(false, Ordering::SeqCst); let _ = app.emit("dedupe-done", result.map(|s| s).ok()); }) .map_err(err_str)?; Ok(()) } #[tauri::command] pub fn cancel_dedupe(state: tauri::State<'_, AppState>) { state.dedupe_cancel.store(true, Ordering::SeqCst); } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct DupeMember { pub file_id: i64, pub name: String, pub dir: String, pub size: i64, pub width: Option, pub height: Option, pub duration_ms: Option, pub kind: i64, pub is_keeper: bool, pub distance: Option, } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct DupeGroup { pub id: i64, pub kind: i64, pub members: Vec, } #[tauri::command] pub fn list_dupe_groups(state: tauri::State<'_, AppState>) -> CmdResult> { state .db .with_read(|conn| { let mut gstmt = conn.prepare_cached( "SELECT id, kind FROM dupe_groups WHERE status = 'open' ORDER BY kind, id", )?; let group_rows: Vec<(i64, i64)> = gstmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect::>()?; let mut mstmt = conn.prepare_cached( "SELECT dm.file_id, f.name, fo.path, f.size, f.width, f.height, f.duration_ms, f.kind, dm.is_keeper, dm.distance FROM dupe_members dm JOIN files f ON f.id = dm.file_id JOIN folders fo ON fo.id = f.folder_id WHERE dm.group_id = ?1 AND f.deleted_at IS NULL ORDER BY dm.is_keeper DESC, f.width * f.height DESC", )?; let mut out = Vec::with_capacity(group_rows.len()); for (gid, kind) in group_rows { let members: Vec = mstmt .query_map([gid], |r| { Ok(DupeMember { file_id: r.get(0)?, name: r.get(1)?, dir: r.get(2)?, size: r.get(3)?, width: r.get(4)?, height: r.get(5)?, duration_ms: r.get(6)?, kind: r.get(7)?, is_keeper: r.get::<_, i64>(8)? != 0, distance: r.get(9)?, }) })? .collect::>()?; // 멤버가 1개 이하로 줄면(이미 삭제됨) 스킵 if members.len() >= 2 { out.push(DupeGroup { id: gid, kind, members }); } } Ok(out) }) .map_err(err_str) } /// 그룹을 해결됨으로 표시 (검토 완료). #[tauri::command] pub fn resolve_dupe_group(state: tauri::State<'_, AppState>, group_id: i64) -> CmdResult<()> { state .db .with_write(move |conn| { conn.execute("UPDATE dupe_groups SET status = 'resolved' WHERE id = ?1", [group_id])?; Ok(()) }) .map_err(err_str) } /// 그룹의 특정 두 파일을 "다른 파일"로 표시 (재탐지 시 제외). #[tauri::command] pub fn dismiss_dupe_pair( state: tauri::State<'_, AppState>, file_a: i64, file_b: i64, ) -> CmdResult<()> { let (lo, hi) = if file_a < file_b { (file_a, file_b) } else { (file_b, file_a) }; state .db .with_write(move |conn| { conn.execute( "INSERT OR IGNORE INTO dupe_dismissed(file_a, file_b) VALUES (?1, ?2)", rusqlite::params![lo, hi], )?; Ok(()) }) .map_err(err_str) } // ── 재생 ──────────────────────────────────────────────── fn file_abs_path(state: &AppState, file_id: i64) -> CmdResult { state .db .with_read(|conn| { conn.query_row( "SELECT fo.path, f.name FROM files f JOIN folders fo ON fo.id = f.folder_id JOIN sources s ON s.id = f.source_id WHERE f.id = ?1 AND s.kind = 'local' AND f.deleted_at IS NULL", [file_id], |r| { let dir: String = r.get(0)?; let name: String = r.get(1)?; Ok(std::path::Path::new(&dir).join(name)) }, ) }) .map_err(err_str) } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct PlaybackInfo { pub ext: Option, pub vcodec: Option, pub acodec: Option, pub duration_ms: Option, pub width: Option, pub height: Option, pub stream_available: bool, } #[tauri::command] pub fn playback_info(state: tauri::State<'_, AppState>, file_id: i64) -> CmdResult { let stream_available = state.tools.is_some(); state .db .with_read(move |conn| { conn.query_row( "SELECT ext, vcodec, acodec, duration_ms, width, height FROM files WHERE id = ?1", [file_id], |r| { Ok(PlaybackInfo { ext: r.get(0)?, vcodec: r.get(1)?, acodec: r.get(2)?, duration_ms: r.get(3)?, width: r.get(4)?, height: r.get(5)?, stream_available, }) }, ) }) .map_err(err_str) } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct StreamStart { pub job_id: u64, pub url: String, } /// ffmpeg 스트림 잡 생성 (리먹스/트랜스코딩). video: copy|copyhvc1|transcode, audio: copy|aac #[tauri::command] pub fn start_stream( state: tauri::State<'_, AppState>, file_id: i64, start_seconds: f64, video: archive_media::stream::VideoMode, audio: archive_media::stream::AudioMode, ) -> CmdResult { if !state.media.streams.available() { return Err("ffmpeg 사이드카가 없어 스트리밍을 사용할 수 없습니다".into()); } let input = file_abs_path(&state, file_id)?; let job_id = state.media.streams.create(archive_media::stream::StreamSpec { input, start_seconds, video, audio, }); Ok(StreamStart { job_id, url: state.media.stream_url(job_id), }) } #[tauri::command] pub fn stop_stream(state: tauri::State<'_, AppState>, job_id: u64) { state.media.streams.stop(job_id); }