- 사이드바 소스 행 hover 시 ✕ 버튼 → 확인 후 인덱스/폴더/태그/썸네일 캐시 제거. 디스크의 실제 파일은 삭제하지 않음. - 로컬: 파일 워처 해제. 원격: 연결 해제 + 자격증명(키체인/볼트) 삭제. - maintenance::remove_source (defer_foreign_keys로 자기참조 FK 처리), 스캔 중 제외 방지, 제외한 소스를 보던 중이면 전체 뷰로 전환. Rust 테스트: 실제 파일 보존 + 인덱스/썸네일 제거 검증 통과. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1278 lines
42 KiB
Rust
1278 lines
42 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,
|
|
}
|
|
|
|
#[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),
|
|
) {
|
|
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<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 || {
|
|
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<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);
|
|
let flags = if thumb_state == 1 { 1u8 } else { 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>,
|
|
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);
|
|
|
|
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(),
|
|
source_id,
|
|
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();
|
|
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<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);
|
|
}
|