Files
Archive/src-tauri/src/commands.rs
T
강 한andClaude Fable 5 2072f9f27e 재생바 총길이 안정화 — ffprobe 메타 우선 + 없으면 즉석 ffprobe
증상: 영상 재생 시 재생바가 실시간으로 늘어나며 요동침. 총 재생시간이
고정되지 않음.
원인: durationSec가 직접 모드에서 <video>.duration을 우선 사용했는데, 스트림/
일부 컨테이너(리먹스 fMP4 등)에선 재생 중 요소 duration이 계속 커져 재생바가
current/duration 비율로 요동쳤다. 또 메타(duration_ms)가 아직 추출 안 된 영상은
playback_info가 null을 반환해 요소 duration에 의존.

수정:
- VideoPlayer.durationSec: ffprobe 메타 총길이를 항상 우선, 없을 때만 요소 duration
  폴백. (고정 총길이라 재생바 안정)
- playback_info: 영상인데 duration_ms가 null이면 즉석 ffprobe로 채우고 DB에 캐시
  (SMB 등 메타 미추출 상태에서도 정확한 총길이/코덱 보장, 다음 재생부터 재프로브 없음).

검증(VP9 20s): playback_info durationMs=20000, 재생 중 표시 0:00→ 진행하며 총길이는
0:20 고정, 진행바 0%→4%→8% 단조 증가(요동 없음).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:17:28 +09:00

1390 lines
48 KiB
Rust

//! 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<T> = Result<T, String>;
fn err_str<E: std::fmt::Display>(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,
}
#[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<PendingCounts> {
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<Vec<SourceDto>> {
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<SourceDto> {
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<u64> {
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<ScanProgress>,
) -> 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<archive_db::Db>,
tools: &Option<archive_indexer::ffmpeg::FfTools>,
thumbs: &archive_indexer::thumbq::ThumbQueue,
source_id: i64,
root: &Path,
cancel: &std::sync::atomic::AtomicBool,
mut on_progress: impl FnMut(ScanProgress),
on_refresh: impl Fn(),
) {
let result = archive_indexer::pipeline::scan_source(db, source_id, root, cancel, &mut on_progress);
match result {
Ok(stats) => {
tracing::info!(?stats, source_id, "스캔(열거) 완료 — 즉시 표시 후 메타 진행");
// 열거가 끝나면 파일 행은 이미 DB에 있다. 느린 메타(특히 영상 ffprobe)를
// 기다리지 않고 ① 썸네일 생성을 시작하고 ② 그리드를 바로 띄운다.
thumbs.enqueue_pending(db, Some(source_id));
on_refresh();
// 이미지 메타는 헤더만 읽어 빠름 → 끝나면 사진 종횡비 재배치.
match archive_indexer::meta::extract_image_meta(db, source_id, cancel) {
Ok(n) => tracing::info!(count = n, "이미지 메타 추출 완료"),
Err(e) => tracing::error!("이미지 메타 추출 오류: {e}"),
}
on_refresh();
// 영상 메타(ffprobe)는 파일당 프로세스라 느림 → 백그라운드에서 진행.
// 완료 후 재배치는 호출자의 최종 library-changed가 담당.
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}"),
}
}
}
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<Channel<ScanProgress>>,
) -> 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();
let app_refresh = app.clone();
run_scan_pipeline(
&db,
&tools,
&thumbs,
source_id,
Path::new(&root),
&cancel,
|p| {
if let Some(ch) = &on_progress {
let _ = ch.send(p);
}
},
// 열거 직후·이미지 메타 후 그리드를 즉시 갱신 (영상 메타를 기다리지 않음)
move || {
let _ = app_refresh.emit("library-changed", source_id);
},
);
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<i64>,
pub name: String,
pub file_count: i64,
}
#[tauri::command]
pub fn folder_tree(state: tauri::State<'_, AppState>, source_id: i64) -> CmdResult<Vec<FolderNode>> {
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<u32>, Option<u32>, Option<u32>, i64, i64)>) -> Vec<u8> {
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<u32>, Option<u32>, Option<u32>, 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<SnapRow> {
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<i64>,
folder_id: Option<i64>,
recursive: bool,
) -> CmdResult<Response> {
let rows: Vec<SnapRow> = 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<Response> {
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<SnapRow> = 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<u32>,
pub height: Option<u32>,
pub duration_ms: Option<i64>,
pub kind: i64,
pub taken_at: Option<i64>,
}
#[tauri::command]
pub fn file_details(state: tauri::State<'_, AppState>, ids: Vec<i64>) -> CmdResult<Vec<FileDetails>> {
let ids: Vec<i64> = 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<i64>) {
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<Vec<Tag>> {
tags::list_tags(&state.db).map_err(err_str)
}
#[tauri::command]
pub fn create_tag(
state: tauri::State<'_, AppState>,
name: String,
color: Option<String>,
) -> CmdResult<i64> {
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<i64>,
tag_id: i64,
) -> CmdResult<TagOpResult> {
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<i64>,
tag_id: i64,
) -> CmdResult<TagOpResult> {
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<Vec<Tag>> {
tags::file_tags(&state.db, file_id).map_err(err_str)
}
/// 태그 JSON 내보내기 — 경로를 반환한다.
#[tauri::command]
pub fn export_tags(state: tauri::State<'_, AppState>, path: Option<String>) -> CmdResult<String> {
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<Response> {
let rows: Vec<SnapRow> = 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<i64>,
dest_folder_id: i64,
on_progress: Channel<OpProgress>,
) -> CmdResult<OpSummary> {
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<i64>,
on_progress: Channel<OpProgress>,
) -> CmdResult<OpSummary> {
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<Option<OpSummary>> {
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::<Option<OpSummary>, 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<u8>,
#[serde(default)]
pub tag_ids: Vec<i64>,
#[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<Vec<SmartFolder>> {
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<i64> {
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<Response> {
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::<Vec<_>>()
.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<SnapRow> = 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<i64> {
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<bool> {
let (kind, config_json): (String, Option<String>) = 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<u64> {
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<archive_db::Db>,
provider: &std::sync::Arc<dyn archive_vfs::VfsProvider>,
source_id: i64,
thumb_root: &std::path::PathBuf,
media_base: &str,
tools: &Option<archive_indexer::ffmpeg::FfTools>,
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<i64>,
) -> CmdResult<OpSummary> {
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<i64>,
folder_id: Option<i64>,
recursive: Option<bool>,
image_threshold: u32,
include_video: bool,
on_progress: Channel<DedupeProgress>,
) -> 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<u32>,
pub height: Option<u32>,
pub duration_ms: Option<i64>,
pub kind: i64,
pub is_keeper: bool,
pub distance: Option<i64>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DupeGroup {
pub id: i64,
pub kind: i64,
pub members: Vec<DupeMember>,
}
#[tauri::command]
pub fn list_dupe_groups(state: tauri::State<'_, AppState>) -> CmdResult<Vec<DupeGroup>> {
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::<rusqlite::Result<_>>()?;
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<DupeMember> = 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::<rusqlite::Result<_>>()?;
// 멤버가 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<std::path::PathBuf> {
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<String>,
pub vcodec: Option<String>,
pub acodec: Option<String>,
pub duration_ms: Option<i64>,
pub width: Option<u32>,
pub height: Option<u32>,
pub stream_available: bool,
}
#[tauri::command]
pub fn playback_info(state: tauri::State<'_, AppState>, file_id: i64) -> CmdResult<PlaybackInfo> {
let stream_available = state.tools.is_some();
// (info, kind, 파일경로) — 경로는 메타 미추출 시 즉석 ffprobe용
let (mut info, kind, path): (PlaybackInfo, i64, std::path::PathBuf) = state
.db
.with_read(move |conn| {
conn.query_row(
"SELECT f.ext, f.vcodec, f.acodec, f.duration_ms, f.width, f.height, f.kind, fo.path, f.name
FROM files f JOIN folders fo ON fo.id = f.folder_id WHERE f.id = ?1",
[file_id],
|r| {
let dir: String = r.get(7)?;
let name: String = r.get(8)?;
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,
},
r.get::<_, i64>(6)?,
std::path::Path::new(&dir).join(name),
))
},
)
})
.map_err(err_str)?;
// 영상인데 메타(재생시간)가 아직 없으면 즉석 ffprobe로 채운다.
// 재생바 총길이가 요소 duration(스트림 중 증가)에 의존하지 않도록 정확한 총길이를 보장.
if info.duration_ms.is_none() && kind == 1 {
if let Some(tools) = &state.tools {
if let Ok(mi) = archive_indexer::ffmpeg::probe(tools, &path) {
info.duration_ms = mi.duration_ms;
if info.vcodec.is_none() {
info.vcodec = mi.vcodec.clone();
}
if info.acodec.is_none() {
info.acodec = mi.acodec.clone();
}
if info.width.is_none() {
info.width = mi.width;
}
if info.height.is_none() {
info.height = mi.height;
}
// DB에 캐시 (같은 영상 재생 시 SMB 재프로브 방지). meta_state 미완이면 완료 표시.
let (d, vc, ac, w, h) = (mi.duration_ms, mi.vcodec, mi.acodec, mi.width, mi.height);
let _ = state.db.with_write(move |conn| {
conn.execute(
"UPDATE files SET duration_ms=COALESCE(duration_ms,?2),
vcodec=COALESCE(vcodec,?3), acodec=COALESCE(acodec,?4),
width=COALESCE(width,?5), height=COALESCE(height,?6),
meta_state=CASE WHEN meta_state=0 THEN 1 ELSE meta_state END
WHERE id=?1",
rusqlite::params![file_id, d, vc, ac, w, h],
)
});
}
}
}
Ok(info)
}
#[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<StreamStart> {
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);
}