Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램
Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼 사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드, 인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포. - archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram - archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트) - archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수 - archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡 - 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널 Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
// 릴리스 빌드에서 콘솔 창 숨김 (Windows)
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod commands;
|
||||
mod paths;
|
||||
mod remote;
|
||||
mod remote_thumbs;
|
||||
mod resolver;
|
||||
mod watcher;
|
||||
|
||||
use archive_db::Db;
|
||||
use archive_indexer::thumbq::ThumbQueue;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<Db>,
|
||||
pub data_dir: PathBuf,
|
||||
pub portable: bool,
|
||||
/// 서버 핸들 — drop 시 graceful shutdown 되므로 앱 수명 동안 보관
|
||||
pub media: archive_media::MediaServer,
|
||||
pub thumbs: Arc<ThumbQueue>,
|
||||
pub tools: Option<archive_indexer::ffmpeg::FfTools>,
|
||||
pub watchers: std::sync::OnceLock<Arc<watcher::WatcherManager>>,
|
||||
pub scan_cancel: Arc<AtomicBool>,
|
||||
pub scan_running: Arc<AtomicBool>,
|
||||
pub dedupe_cancel: Arc<AtomicBool>,
|
||||
pub dedupe_running: Arc<AtomicBool>,
|
||||
pub remote: Arc<remote::RemotePool>,
|
||||
/// 자격증명 저장소 (포터블 볼트 잠금 해제 후 교체 가능)
|
||||
pub creds: Arc<std::sync::Mutex<archive_vfs::creds::CredStore>>,
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
// 두 번째 실행 시 기존 창을 앞으로
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.setup(|app| {
|
||||
let data = paths::resolve_data_dir()?;
|
||||
tracing::info!(dir = %data.path.display(), portable = data.portable, "데이터 디렉터리");
|
||||
|
||||
let thumb_root = data.path.join("thumbs");
|
||||
std::fs::create_dir_all(&thumb_root)?;
|
||||
std::fs::create_dir_all(data.path.join("logs"))?;
|
||||
|
||||
let db = Arc::new(Db::open(&data.path.join("archive.db"))?);
|
||||
if !db.fts_enabled {
|
||||
tracing::warn!("FTS5 trigram 비활성 — 파일명 검색은 LIKE 폴백 사용");
|
||||
}
|
||||
|
||||
// ffmpeg/ffprobe 사이드카 (없으면 영상 메타/썸네일/트랜스코딩 비활성)
|
||||
let tools = archive_indexer::ffmpeg::locate();
|
||||
match &tools {
|
||||
Some(t) => tracing::info!(ffmpeg = %t.ffmpeg.display(), "ffmpeg 사이드카 사용"),
|
||||
None => tracing::warn!("ffmpeg 사이드카 없음 — 영상 썸네일/재생 폴백 비활성 (scripts/fetch-ffmpeg.ps1)"),
|
||||
}
|
||||
|
||||
let remote = Arc::new(remote::RemotePool::new());
|
||||
let media = tauri::async_runtime::block_on(archive_media::start(
|
||||
Arc::new(resolver::DbResolver {
|
||||
db: db.clone(),
|
||||
thumb_root: thumb_root.clone(),
|
||||
remote: remote.clone(),
|
||||
}),
|
||||
tools.as_ref().map(|t| t.ffmpeg.clone()),
|
||||
))?;
|
||||
|
||||
// 썸네일 워커: 코어 수 - 1
|
||||
let workers = std::thread::available_parallelism()
|
||||
.map(|n| n.get().saturating_sub(1).max(1))
|
||||
.unwrap_or(2);
|
||||
let emit_handle = app.handle().clone();
|
||||
let thumbs = Arc::new(ThumbQueue::start(
|
||||
db.clone(),
|
||||
thumb_root,
|
||||
tools.clone(),
|
||||
workers,
|
||||
move |ids| {
|
||||
let _ = emit_handle.emit("thumbs-ready", ids);
|
||||
},
|
||||
));
|
||||
// 앱 시작 시 미처리 썸네일 백필
|
||||
thumbs.enqueue_pending(&db, None);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::info!(
|
||||
"미디어 서버(dev): http://127.0.0.1:{}/thumb/{}/<id>?s=0",
|
||||
media.port,
|
||||
media.token
|
||||
);
|
||||
|
||||
app.manage(AppState {
|
||||
db,
|
||||
data_dir: data.path,
|
||||
portable: data.portable,
|
||||
media,
|
||||
thumbs,
|
||||
tools,
|
||||
watchers: std::sync::OnceLock::new(),
|
||||
scan_cancel: Arc::new(AtomicBool::new(false)),
|
||||
scan_running: Arc::new(AtomicBool::new(false)),
|
||||
dedupe_cancel: Arc::new(AtomicBool::new(false)),
|
||||
dedupe_running: Arc::new(AtomicBool::new(false)),
|
||||
remote,
|
||||
creds: Arc::new(std::sync::Mutex::new(
|
||||
archive_vfs::creds::CredStore::Keychain,
|
||||
)),
|
||||
});
|
||||
|
||||
// 파일 워처: 등록된 로컬 소스 전체 감시 (AppState manage 이후에 초기화)
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let manager = watcher::WatcherManager::new(app.handle().clone());
|
||||
manager.refresh(&state.db);
|
||||
let _ = state.watchers.set(manager);
|
||||
}
|
||||
|
||||
// 원격 소스 자동 재연결 (키체인 자격증명 기준 — 볼트는 잠금해제 후 수동).
|
||||
{
|
||||
let handle = app.handle().clone();
|
||||
std::thread::Builder::new()
|
||||
.name("remote-reconnect".into())
|
||||
.spawn(move || {
|
||||
tauri::async_runtime::block_on(async {
|
||||
let state = handle.state::<AppState>();
|
||||
let remotes: Vec<(i64, String, Option<String>)> = state
|
||||
.db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, kind, config FROM sources WHERE kind != 'local'",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for (source_id, kind, config_json) in remotes {
|
||||
let Ok(config) = serde_json::from_str::<remote::RemoteConfig>(
|
||||
&config_json.unwrap_or_default(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let key = remote::cred_key(&kind, source_id);
|
||||
let pw = state.creds.lock().unwrap().get(&key).ok().flatten();
|
||||
if let Some(pw) = pw {
|
||||
match state.remote.connect(source_id, &kind, &config, &pw).await {
|
||||
Ok(_) => tracing::info!(source_id, kind, "원격 소스 자동 재연결"),
|
||||
Err(e) => tracing::warn!(source_id, "자동 재연결 실패: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// 시작 시 유지보수 (백그라운드): DB 백업 → 소프트삭제 퍼지(30일) → 썸네일 캐시 퍼지(4GB)
|
||||
{
|
||||
let state = app.state::<AppState>();
|
||||
let db = state.db.clone();
|
||||
let data_dir = state.data_dir.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("maintenance".into())
|
||||
.spawn(move || {
|
||||
let backup = data_dir.join("archive.db.bak");
|
||||
if let Err(e) = archive_indexer::maintenance::backup_db(&db, &backup) {
|
||||
tracing::warn!("DB 백업 실패: {e}");
|
||||
}
|
||||
let cutoff = now_ms() - 30 * 24 * 3600 * 1000;
|
||||
let purged = archive_indexer::maintenance::purge_deleted(&db, cutoff);
|
||||
if purged > 0 {
|
||||
tracing::info!(purged, "오래된 삭제 항목 정리");
|
||||
}
|
||||
let freed = archive_indexer::maintenance::prune_thumb_cache(
|
||||
&db,
|
||||
&data_dir.join("thumbs"),
|
||||
4 * 1024 * 1024 * 1024, // 4GB
|
||||
);
|
||||
if freed > 0 {
|
||||
tracing::info!(freed_mb = freed / 1024 / 1024, "썸네일 캐시 정리");
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// dev 편의: ARCHIVE_AUTO_SOURCE=<폴더> → 시작 시 소스 등록 + 스캔
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(auto) = std::env::var("ARCHIVE_AUTO_SOURCE") {
|
||||
if !auto.is_empty() {
|
||||
let handle = app.handle().clone();
|
||||
let state = app.state::<AppState>();
|
||||
let existing: Option<i64> = state
|
||||
.db
|
||||
.with_read(|c| {
|
||||
c.query_row("SELECT id FROM sources WHERE root = ?1", [&auto], |r| r.get(0))
|
||||
})
|
||||
.ok();
|
||||
let source_id = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let name = std::path::Path::new(&auto)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| auto.clone());
|
||||
let auto2 = auto.clone();
|
||||
state
|
||||
.db
|
||||
.with_write(move |c| {
|
||||
c.execute(
|
||||
"INSERT INTO sources(kind,name,root) VALUES('local',?1,?2)",
|
||||
rusqlite::params![name, auto2],
|
||||
)?;
|
||||
Ok(c.last_insert_rowid())
|
||||
})
|
||||
.expect("자동 소스 등록 실패")
|
||||
}
|
||||
};
|
||||
state.scan_running.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = commands::spawn_scan_thread(handle, &state, source_id, auto, None);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::app_info,
|
||||
commands::list_sources,
|
||||
commands::add_local_source,
|
||||
commands::start_scan,
|
||||
commands::cancel_scan,
|
||||
commands::folder_tree,
|
||||
commands::folder_snapshot,
|
||||
commands::search_snapshot,
|
||||
commands::file_details,
|
||||
commands::set_thumb_priority,
|
||||
commands::playback_info,
|
||||
commands::start_stream,
|
||||
commands::stop_stream,
|
||||
commands::list_tags,
|
||||
commands::create_tag,
|
||||
commands::rename_tag,
|
||||
commands::delete_tag,
|
||||
commands::assign_tags,
|
||||
commands::unassign_tags,
|
||||
commands::file_tags_of,
|
||||
commands::export_tags,
|
||||
commands::tag_snapshot,
|
||||
commands::move_files,
|
||||
commands::rename_file,
|
||||
commands::trash_files,
|
||||
commands::undo_last,
|
||||
commands::list_smart_folders,
|
||||
commands::create_smart_folder,
|
||||
commands::delete_smart_folder,
|
||||
commands::smart_snapshot,
|
||||
commands::start_dedupe,
|
||||
commands::cancel_dedupe,
|
||||
commands::list_dupe_groups,
|
||||
commands::resolve_dupe_group,
|
||||
commands::dismiss_dupe_pair,
|
||||
commands::add_remote_source,
|
||||
commands::connect_remote_source,
|
||||
commands::scan_remote_source,
|
||||
commands::trash_remote_files,
|
||||
commands::unlock_vault,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Tauri 앱 실행 실패");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! 데이터 디렉터리 해석 (계획서 §3 "DB 위치"):
|
||||
//! ① `--data-dir <path>` 인자 → ② exe 옆 `data/` 또는 `portable.marker` 존재 + 쓰기 가능
|
||||
//! → 포터블 모드 → ③ OS 앱데이터 디렉터리.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct DataDir {
|
||||
pub path: PathBuf,
|
||||
pub portable: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_data_dir() -> std::io::Result<DataDir> {
|
||||
// ① 명시적 인자 / 환경변수
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
if a == "--data-dir" {
|
||||
if let Some(p) = args.next() {
|
||||
let path = PathBuf::from(p);
|
||||
std::fs::create_dir_all(&path)?;
|
||||
return Ok(DataDir { path, portable: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(p) = std::env::var("ARCHIVE_DATA_DIR") {
|
||||
if !p.is_empty() {
|
||||
let path = PathBuf::from(p);
|
||||
std::fs::create_dir_all(&path)?;
|
||||
return Ok(DataDir { path, portable: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ② 포터블 프로브
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe.parent() {
|
||||
let data = exe_dir.join("data");
|
||||
let marker = exe_dir.join("portable.marker");
|
||||
if (data.is_dir() || marker.is_file()) && dir_writable(exe_dir) {
|
||||
std::fs::create_dir_all(&data)?;
|
||||
return Ok(DataDir { path: data, portable: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ OS 앱데이터
|
||||
let base = dirs::data_dir()
|
||||
.ok_or_else(|| std::io::Error::other("OS 데이터 디렉터리를 찾을 수 없음"))?;
|
||||
let path = base.join("Archive");
|
||||
std::fs::create_dir_all(&path)?;
|
||||
Ok(DataDir { path, portable: false })
|
||||
}
|
||||
|
||||
fn dir_writable(dir: &std::path::Path) -> bool {
|
||||
let probe = dir.join(".archive_write_probe");
|
||||
match std::fs::write(&probe, b"") {
|
||||
Ok(()) => {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! 원격 소스 관리 — VFS 연결 풀, 재귀 인덱싱, 자격증명 연동.
|
||||
|
||||
use archive_db::Db;
|
||||
use archive_vfs::creds::CredStore;
|
||||
use archive_vfs::ftp::{FtpConfig, FtpFs};
|
||||
use archive_vfs::sftp::{SftpConfig, SftpFs};
|
||||
use archive_vfs::webdav::{WebDavConfig, WebDavFs};
|
||||
use archive_vfs::VfsProvider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// sources.config에 저장되는 원격 연결 정보 (비밀번호 제외).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteConfig {
|
||||
pub host: String,
|
||||
#[serde(default)]
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub base_path: String,
|
||||
/// WebDAV 전용 — 전체 URL
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// 활성 원격 연결 풀 (source_id → provider).
|
||||
#[derive(Default)]
|
||||
pub struct RemotePool {
|
||||
conns: Mutex<HashMap<i64, Arc<dyn VfsProvider>>>,
|
||||
}
|
||||
|
||||
impl RemotePool {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 소스에 연결 (이미 있으면 재사용). password는 CredStore에서 조회.
|
||||
pub async fn connect(
|
||||
&self,
|
||||
source_id: i64,
|
||||
kind: &str,
|
||||
config: &RemoteConfig,
|
||||
password: &str,
|
||||
) -> archive_vfs::Result<Arc<dyn VfsProvider>> {
|
||||
if let Some(p) = self.conns.lock().await.get(&source_id) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let provider: Arc<dyn VfsProvider> = match kind {
|
||||
"sftp" => Arc::new(
|
||||
SftpFs::connect(&SftpConfig {
|
||||
host: config.host.clone(),
|
||||
port: if config.port == 0 { 22 } else { config.port },
|
||||
username: config.username.clone(),
|
||||
password: password.to_string(),
|
||||
base_path: config.base_path.clone(),
|
||||
})
|
||||
.await?,
|
||||
),
|
||||
"ftp" => Arc::new(
|
||||
FtpFs::connect(&FtpConfig {
|
||||
host: config.host.clone(),
|
||||
port: if config.port == 0 { 21 } else { config.port },
|
||||
username: config.username.clone(),
|
||||
password: password.to_string(),
|
||||
base_path: config.base_path.clone(),
|
||||
})
|
||||
.await?,
|
||||
),
|
||||
"webdav" => Arc::new(
|
||||
WebDavFs::connect(&WebDavConfig {
|
||||
url: config.url.clone(),
|
||||
username: config.username.clone(),
|
||||
password: password.to_string(),
|
||||
base_path: config.base_path.clone(),
|
||||
})
|
||||
.await?,
|
||||
),
|
||||
other => {
|
||||
return Err(archive_vfs::VfsError::Protocol(format!("알 수 없는 종류: {other}")))
|
||||
}
|
||||
};
|
||||
self.conns.lock().await.insert(source_id, provider.clone());
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
pub async fn get(&self, source_id: i64) -> Option<Arc<dyn VfsProvider>> {
|
||||
self.conns.lock().await.get(&source_id).cloned()
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self, source_id: i64) {
|
||||
self.conns.lock().await.remove(&source_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 자격증명 키 (source별)
|
||||
pub fn cred_key(kind: &str, source_id: i64) -> String {
|
||||
format!("{kind}:{source_id}:password")
|
||||
}
|
||||
|
||||
/// 원격 소스를 재귀 인덱싱한다 (메타데이터 티어: 경로/크기/mtime만).
|
||||
/// 썸네일/해시는 조회 시 지연 생성한다.
|
||||
pub async fn index_remote(
|
||||
db: &Arc<Db>,
|
||||
provider: &Arc<dyn VfsProvider>,
|
||||
source_id: i64,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
mut on_progress: impl FnMut(u64),
|
||||
) -> archive_vfs::Result<u64> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let scan_id: i64 = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO scans(source_id, started_at, status) VALUES (?1, ?2, 0)",
|
||||
rusqlite::params![source_id, now],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.map_err(db_to_vfs)?;
|
||||
|
||||
let mut seen = 0u64;
|
||||
// BFS 큐 (원격 경로는 '/' 구분)
|
||||
let mut queue = vec![String::new()];
|
||||
// 루트 폴더 보장
|
||||
ensure_remote_folder(db, source_id, "", None).await?;
|
||||
|
||||
while let Some(dir) = queue.pop() {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let entries = match provider.list_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(dir, "원격 list_dir 실패: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let parent_folder_id = folder_id_of(db, source_id, &dir).await?;
|
||||
|
||||
let mut batch: Vec<(String, u64, i64, String)> = Vec::new();
|
||||
for entry in entries {
|
||||
// 앱 레벨 휴지통은 인덱싱 제외
|
||||
if entry.name == ".archive_trash" {
|
||||
continue;
|
||||
}
|
||||
let child_path = if dir.is_empty() {
|
||||
entry.name.clone()
|
||||
} else {
|
||||
format!("{dir}/{}", entry.name)
|
||||
};
|
||||
if entry.is_dir {
|
||||
ensure_remote_folder(db, source_id, &child_path, Some(parent_folder_id)).await?;
|
||||
queue.push(child_path);
|
||||
} else {
|
||||
let ext = child_path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
if archive_indexer::model::is_media_ext(&ext) {
|
||||
batch.push((entry.name, entry.size, entry.mtime_ms, ext));
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
let fid = parent_folder_id;
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO files(source_id, folder_id, name, ext, kind, size, mtime_ms, scan_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(folder_id, name) DO UPDATE SET
|
||||
size = excluded.size, mtime_ms = excluded.mtime_ms,
|
||||
scan_id = excluded.scan_id, deleted_at = NULL",
|
||||
)?;
|
||||
for (name, size, mtime, ext) in &batch {
|
||||
let kind = archive_indexer::model::classify_ext(ext) as u8;
|
||||
stmt.execute(rusqlite::params![
|
||||
source_id, fid, name, ext, kind, *size as i64, mtime, scan_id
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})
|
||||
.map_err(db_to_vfs)?;
|
||||
}
|
||||
on_progress(seen);
|
||||
}
|
||||
|
||||
// 사라진 파일 소프트 삭제
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE files SET deleted_at = ?3 WHERE source_id = ?1 AND deleted_at IS NULL
|
||||
AND (scan_id IS NULL OR scan_id <> ?2)",
|
||||
rusqlite::params![source_id, scan_id, now],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE scans SET status = 1, finished_at = ?2 WHERE id = ?1",
|
||||
rusqlite::params![scan_id, now],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(db_to_vfs)?;
|
||||
|
||||
Ok(seen)
|
||||
}
|
||||
|
||||
fn db_to_vfs(e: archive_db::DbError) -> archive_vfs::VfsError {
|
||||
archive_vfs::VfsError::Protocol(format!("DB: {e}"))
|
||||
}
|
||||
|
||||
async fn ensure_remote_folder(
|
||||
db: &Arc<Db>,
|
||||
source_id: i64,
|
||||
path: &str,
|
||||
parent_id: Option<i64>,
|
||||
) -> archive_vfs::Result<()> {
|
||||
let name = if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path.rsplit('/').next().unwrap_or(path).to_string()
|
||||
};
|
||||
let path = path.to_string();
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO folders(source_id, parent_id, path, name) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(source_id, path) DO NOTHING",
|
||||
rusqlite::params![source_id, parent_id, path, name],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(db_to_vfs)
|
||||
}
|
||||
|
||||
async fn folder_id_of(db: &Arc<Db>, source_id: i64, path: &str) -> archive_vfs::Result<i64> {
|
||||
let path = path.to_string();
|
||||
db.with_read(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT id FROM folders WHERE source_id = ?1 AND path = ?2",
|
||||
rusqlite::params![source_id, path],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.map_err(db_to_vfs)
|
||||
}
|
||||
|
||||
/// CredStore 생성 — 포터블 볼트(패스프레이즈) 또는 OS 키체인.
|
||||
pub fn make_cred_store(vault_path: Option<std::path::PathBuf>, passphrase: Option<&str>) -> CredStore {
|
||||
match (vault_path, passphrase) {
|
||||
(Some(path), Some(pass)) => match archive_vfs::creds::Vault::open(path, pass) {
|
||||
Ok(v) => CredStore::Vault(v),
|
||||
Err(e) => {
|
||||
tracing::warn!("볼트 열기 실패, 키체인으로 폴백: {e}");
|
||||
CredStore::Keychain
|
||||
}
|
||||
},
|
||||
_ => CredStore::Keychain,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 원격 소스 썸네일 생성 — VFS(async)로 바이트를 받아 생성한다.
|
||||
//! 로컬 썸네일 큐(sync 스레드)와 계층이 달라 별도 async 경로로 처리한다.
|
||||
//!
|
||||
//! 이미지: EXIF 내장 썸네일 우선(앞 256KB만 다운로드) → 실패 시 전체 다운로드.
|
||||
//! 영상: 미디어 서버 bridge URL로 ffmpeg가 Range 읽기(moov+수백KB만 전송).
|
||||
|
||||
use archive_db::Db;
|
||||
use archive_indexer::ffmpeg::FfTools;
|
||||
use archive_indexer::thumbq::{cache_key, thumb_path};
|
||||
use archive_indexer::thumbs;
|
||||
use archive_vfs::VfsProvider;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const HEAD_BYTES: u64 = 256 * 1024;
|
||||
|
||||
struct RemoteFile {
|
||||
id: i64,
|
||||
rel: String,
|
||||
name: String,
|
||||
size: i64,
|
||||
mtime_ms: i64,
|
||||
kind: i64,
|
||||
}
|
||||
|
||||
/// 원격 소스의 미처리 썸네일을 생성한다. 완료 id 배치를 콜백으로 전달.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn generate(
|
||||
db: &Arc<Db>,
|
||||
provider: &Arc<dyn VfsProvider>,
|
||||
source_id: i64,
|
||||
thumb_root: &PathBuf,
|
||||
media_base: &str, // "http://127.0.0.1:PORT/media/TOKEN"
|
||||
tools: &Option<FfTools>,
|
||||
cancel: &AtomicBool,
|
||||
mut on_done: impl FnMut(Vec<i64>),
|
||||
) {
|
||||
let pending: Vec<RemoteFile> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.id, fo.path, f.name, f.size, f.mtime_ms, f.kind
|
||||
FROM files f JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.thumb_state = 0 AND f.kind IN (0,1)
|
||||
AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([source_id], |r| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
let rel = if dir == "/" || dir.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}/{}", dir.trim_start_matches('/'), name)
|
||||
};
|
||||
Ok(RemoteFile {
|
||||
id: r.get(0)?,
|
||||
rel,
|
||||
name,
|
||||
size: r.get(3)?,
|
||||
mtime_ms: r.get(4)?,
|
||||
kind: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut done_batch = Vec::new();
|
||||
for f in pending {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let key = cache_key(source_id, &f.rel, f.size, f.mtime_ms);
|
||||
let dst = thumb_path(thumb_root, 0, &key);
|
||||
|
||||
let dims = if f.kind == 1 {
|
||||
gen_video_thumb(tools, media_base, f.id, &dst).await
|
||||
} else {
|
||||
gen_image_thumb(provider, &f, &dst).await
|
||||
};
|
||||
|
||||
let state = match dims {
|
||||
Some((w, h)) => {
|
||||
let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0);
|
||||
let key2 = key.clone();
|
||||
let _ = db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO thumbs(file_id, size_class, cache_key, width, height, bytes)
|
||||
VALUES (?1, 0, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![f.id, key2, w, h, bytes],
|
||||
)?;
|
||||
tx.execute("UPDATE files SET thumb_state = 1 WHERE id = ?1", [f.id])?;
|
||||
tx.commit()
|
||||
});
|
||||
done_batch.push(f.id);
|
||||
1
|
||||
}
|
||||
None => 2,
|
||||
};
|
||||
if state == 2 {
|
||||
let _ = db.with_write(move |conn| {
|
||||
conn.execute("UPDATE files SET thumb_state = 2 WHERE id = ?1", [f.id])
|
||||
});
|
||||
}
|
||||
if done_batch.len() >= 32 {
|
||||
on_done(std::mem::take(&mut done_batch));
|
||||
}
|
||||
}
|
||||
if !done_batch.is_empty() {
|
||||
on_done(done_batch);
|
||||
}
|
||||
}
|
||||
|
||||
async fn gen_image_thumb(
|
||||
provider: &Arc<dyn VfsProvider>,
|
||||
f: &RemoteFile,
|
||||
dst: &PathBuf,
|
||||
) -> Option<(u32, u32)> {
|
||||
let tmp = std::env::temp_dir().join(format!("archive-rthumb-{}.bin", f.id));
|
||||
|
||||
// 1) EXIF 내장 썸네일 시도 (앞 256KB만) — 대부분 카메라/폰 JPEG에 존재
|
||||
if matches!(f.name.rsplit('.').next().map(|e| e.to_lowercase()).as_deref(), Some("jpg") | Some("jpeg")) {
|
||||
if let Ok(mut r) = provider.open_range(&f.rel, Some(0..HEAD_BYTES)).await {
|
||||
let mut head = Vec::new();
|
||||
if r.read_to_end(&mut head).await.is_ok() {
|
||||
if let Some(embedded) = thumbs::scan_embedded_jpeg(&head) {
|
||||
if let Some(dims) = thumbs::make_thumb_from_bytes(
|
||||
embedded,
|
||||
dst,
|
||||
thumbs::GRID_LONG_EDGE,
|
||||
thumbs::JPEG_QUALITY,
|
||||
160, // 내장 썸네일이 그리드보다 작으면 전체 다운로드로 폴백
|
||||
) {
|
||||
return Some(dims);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 전체 다운로드 → 썸네일
|
||||
let full = provider.open_range(&f.rel, None).await.ok()?;
|
||||
if download_to(full, &tmp).await.is_err() {
|
||||
return None;
|
||||
}
|
||||
let result = thumbs::make_image_thumb(&tmp, dst, thumbs::GRID_LONG_EDGE, thumbs::JPEG_QUALITY)
|
||||
.ok()
|
||||
.map(|(_, _, w, h)| (w, h));
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
result
|
||||
}
|
||||
|
||||
async fn gen_video_thumb(
|
||||
tools: &Option<FfTools>,
|
||||
media_base: &str,
|
||||
file_id: i64,
|
||||
dst: &PathBuf,
|
||||
) -> Option<(u32, u32)> {
|
||||
let tools = tools.as_ref()?;
|
||||
// ffmpeg가 미디어 서버 URL을 Range로 읽음 → 원격 영상 전체를 받지 않음
|
||||
let url = format!("{media_base}/{file_id}");
|
||||
archive_indexer::ffmpeg::extract_frame_url(tools, &url, dst, 1.0, thumbs::GRID_LONG_EDGE).ok()
|
||||
}
|
||||
|
||||
async fn download_to(
|
||||
mut reader: archive_vfs::VfsRead,
|
||||
dst: &PathBuf,
|
||||
) -> std::io::Result<()> {
|
||||
let mut buf = Vec::new();
|
||||
reader.read_to_end(&mut buf).await?;
|
||||
tokio::fs::write(dst, buf).await
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 미디어 서버용 file_id → 경로/원격스트림 리졸버 (DB 조회 + VFS).
|
||||
|
||||
use crate::remote::RemotePool;
|
||||
use archive_db::Db;
|
||||
use archive_media::RemoteStream;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct DbResolver {
|
||||
pub db: Arc<Db>,
|
||||
pub thumb_root: PathBuf,
|
||||
pub remote: Arc<RemotePool>,
|
||||
}
|
||||
|
||||
struct FileLoc {
|
||||
kind: String, // sources.kind
|
||||
source_id: i64,
|
||||
dir: String,
|
||||
name: String,
|
||||
size: i64,
|
||||
}
|
||||
|
||||
impl DbResolver {
|
||||
fn locate(&self, file_id: i64) -> Option<FileLoc> {
|
||||
self.db
|
||||
.with_read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT s.kind, f.source_id, fo.path, f.name, f.size
|
||||
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 f.deleted_at IS NULL",
|
||||
[file_id],
|
||||
|r| {
|
||||
Ok(FileLoc {
|
||||
kind: r.get(0)?,
|
||||
source_id: r.get(1)?,
|
||||
dir: r.get(2)?,
|
||||
name: r.get(3)?,
|
||||
size: r.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl archive_media::PathResolver for DbResolver {
|
||||
fn resolve(&self, file_id: i64) -> Option<PathBuf> {
|
||||
let loc = self.locate(file_id)?;
|
||||
if loc.kind != "local" {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(loc.dir).join(loc.name))
|
||||
}
|
||||
|
||||
fn resolve_thumb(&self, file_id: i64, size_class: u8) -> Option<PathBuf> {
|
||||
let key: String = self
|
||||
.db
|
||||
.with_read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT cache_key FROM thumbs WHERE file_id = ?1 AND size_class = ?2",
|
||||
rusqlite::params![file_id, size_class],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.ok()?;
|
||||
let path = archive_indexer::thumbq::thumb_path(&self.thumb_root, size_class, &key);
|
||||
path.exists().then_some(path)
|
||||
}
|
||||
|
||||
fn is_remote(&self, file_id: i64) -> bool {
|
||||
self.locate(file_id).map(|l| l.kind != "local").unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn open_remote(
|
||||
&self,
|
||||
file_id: i64,
|
||||
range: Option<std::ops::Range<u64>>,
|
||||
) -> Option<RemoteStream> {
|
||||
let loc = self.locate(file_id)?;
|
||||
let provider = self.remote.get(loc.source_id).await?;
|
||||
// 원격 상대 경로: 폴더 path + name (루트는 빈 문자열)
|
||||
let rel = if loc.dir == "/" || loc.dir.is_empty() {
|
||||
loc.name.clone()
|
||||
} else {
|
||||
format!("{}/{}", loc.dir.trim_start_matches('/'), loc.name)
|
||||
};
|
||||
let mime = mime_guess::from_path(&loc.name).first_or_octet_stream().to_string();
|
||||
|
||||
// 0바이트 요청(HEAD 격)이면 길이만 반환
|
||||
if let Some(r) = &range {
|
||||
if r.start == 0 && r.end == 0 {
|
||||
return Some(RemoteStream {
|
||||
total_len: loc.size.max(0) as u64,
|
||||
reader: Box::new(tokio::io::empty()),
|
||||
mime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let reader = provider.open_range(&rel, range).await.ok()?;
|
||||
Some(RemoteStream {
|
||||
total_len: loc.size.max(0) as u64,
|
||||
reader,
|
||||
mime,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! 로컬 소스 파일 워처 — 변경 이벤트를 디바운스해 증분 재스캔을 트리거한다.
|
||||
//! 워처는 힌트일 뿐이고 재스캔(조정 스캔)이 진실의 원천이다 (계획서 §4).
|
||||
|
||||
use crate::AppState;
|
||||
use notify_debouncer_full::notify::RecursiveMode;
|
||||
use notify_debouncer_full::{new_debouncer, DebounceEventResult, DebouncedEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
type Debouncer = notify_debouncer_full::Debouncer<
|
||||
notify_debouncer_full::notify::RecommendedWatcher,
|
||||
notify_debouncer_full::RecommendedCache,
|
||||
>;
|
||||
|
||||
pub struct WatcherManager {
|
||||
watchers: Mutex<HashMap<i64, Debouncer>>,
|
||||
dirty_tx: crossbeam_channel::Sender<i64>,
|
||||
}
|
||||
|
||||
impl WatcherManager {
|
||||
/// 워커 스레드를 시작하고 매니저를 만든다.
|
||||
pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> {
|
||||
let (tx, rx) = crossbeam_channel::unbounded::<i64>();
|
||||
let manager = std::sync::Arc::new(WatcherManager {
|
||||
watchers: Mutex::new(HashMap::new()),
|
||||
dirty_tx: tx,
|
||||
});
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("watch-rescan".into())
|
||||
.spawn(move || {
|
||||
while let Ok(first) = rx.recv() {
|
||||
// 몰려온 이벤트를 한 번에 흡수
|
||||
let mut dirty: std::collections::HashSet<i64> = [first].into();
|
||||
while let Ok(more) = rx.try_recv() {
|
||||
dirty.insert(more);
|
||||
}
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
for source_id in dirty {
|
||||
// 스캔 중이면 끝날 때까지 대기 (취소가 아니라 순번 대기)
|
||||
while state.scan_running.swap(true, Ordering::SeqCst) {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
let root: Result<String, _> = state.db.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT root FROM sources WHERE id = ?1 AND kind = 'local'",
|
||||
[source_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
});
|
||||
match root {
|
||||
Ok(root) => {
|
||||
tracing::info!(source_id, "워처 트리거 재스캔");
|
||||
crate::commands::run_scan_pipeline(
|
||||
&state.db,
|
||||
&state.tools,
|
||||
&state.thumbs,
|
||||
source_id,
|
||||
Path::new(&root),
|
||||
&state.scan_cancel,
|
||||
|_| {},
|
||||
);
|
||||
state.scan_running.store(false, Ordering::SeqCst);
|
||||
let _ = app.emit("library-changed", source_id);
|
||||
}
|
||||
Err(_) => {
|
||||
state.scan_running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("watch-rescan 스레드 생성 실패");
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
/// DB의 로컬 소스 전체를 워치 대상으로 동기화한다 (시작 시 + 소스 추가 후 호출).
|
||||
pub fn refresh(&self, db: &archive_db::Db) {
|
||||
let sources: Vec<(i64, String)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt =
|
||||
conn.prepare_cached("SELECT id, root FROM sources WHERE kind = 'local'")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut watchers = self.watchers.lock().unwrap();
|
||||
for (source_id, root) in sources {
|
||||
if watchers.contains_key(&source_id) {
|
||||
continue;
|
||||
}
|
||||
let tx = self.dirty_tx.clone();
|
||||
let result = new_debouncer(
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
move |ev: DebounceEventResult| {
|
||||
// 어떤 변경이든 소스 단위 재스캔 힌트로 취급
|
||||
if let Ok(events) = ev {
|
||||
if events.iter().any(relevant_event) {
|
||||
let _ = tx.send(source_id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(mut debouncer) => {
|
||||
match debouncer.watch(Path::new(&root), RecursiveMode::Recursive) {
|
||||
Ok(()) => {
|
||||
tracing::info!(source_id, root = %root, "파일 워처 시작");
|
||||
watchers.insert(source_id, debouncer);
|
||||
}
|
||||
Err(e) => {
|
||||
// 네트워크 드라이브 등 워치 불가 — 수동 새로고침으로 폴백
|
||||
tracing::warn!(source_id, "워치 불가(수동 새로고침 사용): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("워처 생성 실패: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relevant_event(ev: &DebouncedEvent) -> bool {
|
||||
use notify_debouncer_full::notify::EventKind;
|
||||
matches!(
|
||||
ev.kind,
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user