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:
강 한
2026-07-16 22:04:06 +09:00
co-authored by Claude Opus 4.8
commit 394d1b9805
132 changed files with 23468 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+293
View File
@@ -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 앱 실행 실패");
}
+61
View File
@@ -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,
}
}
+266
View File
@@ -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,
}
}
+174
View File
@@ -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
}
+111
View File
@@ -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,
})
}
}
+137
View File
@@ -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(_)
)
}