Files
Archive/src-tauri/src/watcher.rs
T
ncakanghanandClaude Opus 5 8aa74c1a8d
CI / windows-x64 (push) Canceled after 0s
CI / windows-arm64-cross (push) Canceled after 0s
CI / windows-arm64-native (push) Canceled after 0s
CI / macos (push) Canceled after 0s
영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지
- 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시)
- 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(8변형 질의, 선택 옵션)
- 오디오 지문(chromaprint)을 같은 디코드 패스에서 뽑아 레터박스 사본을 잡는다.
  배경음만 같은 남남은 시각 확인 패스(트림 축 프레임 거리)로 걸러낸다 — 실측 9 vs 29.
- 비교 재생(CompareView): 여러 사본을 offset 정렬해 나란히 재생
- 지문 생성 속도 조절(느림·보통·빠름) — 재시작 없이 반영

간단 편집 (원본을 직접 고침, Ctrl+Z로 되돌림)
- 회전·반전: JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 — 화질 손실 0.
  방향값 전이표는 군의 성질(90도x4=제자리)로 검산하고, 실제 화소를 transpose=1과
  비교해 "시계 방향"이 정말 시계인지 확인한다.
- 자르기: 화면 좌표 기준(회전 메타가 붙어 있어도 어긋나지 않는다)
- 구간 자르기: 스트림 복사라 무손실. 필름스트립 타임라인 + 손잡이 끌기 + 구간 미리보기.
- 되돌릴 수 없는 편집(자르기·반전)은 원본을 .archive_trash/edits로 옮겨 보관
- 편집이 수정시각을 바꾸지 않는다 — 기본 정렬이 촬영일이라 항목이 튀면 다시 찾아야 한다

동시 재생
- 고른 영상을 최대 9개 격자에 놓고 함께 재생. 소리는 한 칸만.
- 실시간 변환이 필요한 코덱은 2개까지만 — 그 이상은 ffmpeg가 기계를 멈춘다

그 외
- 별점(스냅샷 flags 남는 비트에 얹어 항목당 바이트 증가 0)
- 태그: 검색 없이 클릭으로 붙이기, 세트+단축키, 이름 수정 시 붙은 항목에 전파
- 파일 로깅(tracing-appender) — 릴리스는 콘솔이 없어 stdout만으로는 원인을 못 찾았다
- ARM64 크로스 컴파일 스크립트(관리자 권한 없이 VS 카탈로그에서 조립)
- DB 마이그레이션 v7~v10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:18:24 +09:00

234 lines
10 KiB
Rust

//! 로컬 소스 파일 워처 — 변경 이벤트를 디바운스해 증분 재스캔을 트리거한다.
//! 워처는 힌트일 뿐이고 재스캔(조정 스캔)이 진실의 원천이다 (계획서 §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,
>;
/// 워치 불가 소스의 **첫** 재스캔 간격. 네트워크 공유(SMB/NAS)는 변경 알림을 안 주는
/// 서버가 있어 폴백이 필요하다.
const POLL_MIN: Duration = Duration::from_secs(60);
/// 변경이 계속 없을 때 늘어나는 상한.
///
/// 고정 60초는 대규모 소스에서 치명적이다 — 100만 건 열거가 60초 안에 끝나지 않으므로
/// 재스캔이 끝나기 전에 다음 재스캔이 큐에 쌓이고, 앱이 켜져 있는 동안 영구히 디스크를 간다.
/// 그래서 변경이 없으면 간격을 2배씩 늘리고(상한 30분), 변경이 있으면 즉시 되돌린다.
const POLL_MAX: Duration = Duration::from_secs(30 * 60);
/// 만기 검사 주기 — 간격 자체가 아니라 '지금 만기된 소스가 있는지' 보는 틱.
const POLL_TICK: Duration = Duration::from_secs(5);
/// 폴링 소스 한 개의 상태. 소스별로 따로 늘어난다(느린 NAS 하나가 다른 소스를 늦추지 않는다).
struct PollState {
/// 다음 재스캔 예정 시각
due: std::time::Instant,
/// 현재 간격 (POLL_MIN..=POLL_MAX)
interval: Duration,
}
type PollMap = std::sync::Arc<Mutex<HashMap<i64, PollState>>>;
pub struct WatcherManager {
watchers: Mutex<HashMap<i64, Debouncer>>,
/// 워치 불가로 폴링에 의존하는 소스 (네트워크 공유 등)
polled: PollMap,
dirty_tx: crossbeam_channel::Sender<i64>,
}
/// 재스캔 결과를 폴링 간격에 반영한다. 변경이 있었으면 최소 간격으로 되돌리고,
/// 없었으면 2배로 늘린다. 폴링 대상이 아닌 소스(워처가 붙은 소스)는 아무 일도 하지 않는다.
fn report_scan(polled: &PollMap, source_id: i64, changed: bool) {
let mut map = polled.lock().unwrap();
if let Some(st) = map.get_mut(&source_id) {
st.interval = if changed {
POLL_MIN
} else {
(st.interval * 2).min(POLL_MAX)
};
st.due = std::time::Instant::now() + st.interval;
tracing::debug!(source_id, secs = st.interval.as_secs(), changed, "폴링 간격 갱신");
}
}
impl WatcherManager {
/// 워커 스레드를 시작하고 매니저를 만든다.
pub fn new(app: tauri::AppHandle) -> std::sync::Arc<WatcherManager> {
let (tx, rx) = crossbeam_channel::unbounded::<i64>();
let polled: PollMap = std::sync::Arc::new(Mutex::new(HashMap::new()));
let manager = std::sync::Arc::new(WatcherManager {
watchers: Mutex::new(HashMap::new()),
polled: polled.clone(),
dirty_tx: tx.clone(),
});
// 워치 불가 소스 폴링 — 이벤트를 못 받는 공유도 결국 최신화된다.
// 만기된 소스만 보낸다. 보낼 때 다음 만기를 미리 밀어 두므로, 재스캔이 오래 걸려도
// 그 사이에 같은 소스가 다시 큐에 쌓이지 않는다(간격 갱신은 report_scan이 한다).
{
let polled = polled.clone();
std::thread::Builder::new()
.name("watch-poll".into())
.spawn(move || loop {
std::thread::sleep(POLL_TICK);
let now = std::time::Instant::now();
let mut due: Vec<i64> = Vec::new();
{
let mut map = polled.lock().unwrap();
for (id, st) in map.iter_mut() {
if st.due <= now {
due.push(*id);
st.due = now + st.interval;
}
}
}
for id in due {
let _ = tx.send(id);
}
})
.expect("watch-poll 스레드 생성 실패");
}
let poll_report = polled;
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, "워처 트리거 재스캔");
let stats = crate::commands::run_scan_pipeline(
&state.db,
&state.tools,
&state.thumbs,
source_id,
Path::new(&root),
&state.scan_cancel,
|_| {},
|| {
let _ = app.emit("library-changed", source_id);
},
);
// 아무것도 바뀌지 않았으면 다음 폴링을 늦춘다 —
// 조용한 대규모 NAS를 60초마다 다시 훑지 않게 하는 핵심이다.
let changed = stats
.map(|s| s.added + s.changed + s.removed + s.moved > 0)
.unwrap_or(false);
report_scan(&poll_report, source_id, changed);
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
}
/// 소스 워치를 해제한다 (소스 제외 시). Debouncer drop으로 감시가 멈춘다.
pub fn remove(&self, source_id: i64) {
if self.watchers.lock().unwrap().remove(&source_id).is_some() {
tracing::info!(source_id, "파일 워처 해제");
}
self.polled.lock().unwrap().remove(&source_id);
}
/// 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,
secs = POLL_MIN.as_secs(),
"워치 불가 — 주기 재스캔으로 폴백(변경 없으면 간격 증가): {e}"
);
self.polled.lock().unwrap().entry(source_id).or_insert_with(|| {
PollState {
due: std::time::Instant::now() + POLL_MIN,
interval: POLL_MIN,
}
});
}
}
}
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(_)
)
}