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:
@@ -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