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
+239
View File
@@ -0,0 +1,239 @@
//! SQLite 인덱스 접근 계층.
//!
//! 아키텍처: **단일 writer 스레드**(crossbeam 채널로 잡 수신, 배치 트랜잭션)
//! + 읽기전용 커넥션 풀. WAL 모드라 읽기는 쓰기에 블록되지 않는다.
pub mod migrations;
use crossbeam_channel::{unbounded, Sender};
use rusqlite::{Connection, OpenFlags};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::thread::JoinHandle;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("sqlite 오류: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("DB가 이미 닫혔습니다")]
Closed,
}
pub type Result<T> = std::result::Result<T, DbError>;
type WriteJob = Box<dyn FnOnce(&mut Connection) + Send + 'static>;
pub struct Db {
path: PathBuf,
write_tx: Option<Sender<WriteJob>>,
writer: Option<JoinHandle<()>>,
readers: Mutex<Vec<Connection>>,
pub fts_enabled: bool,
}
impl Db {
/// DB를 열고(없으면 생성) 마이그레이션을 적용한 뒤 writer 스레드를 시작한다.
pub fn open(db_path: &Path) -> Result<Db> {
let mut conn = Connection::open(db_path)?;
apply_writer_pragmas(&conn)?;
let fts_enabled = migrations::migrate(&mut conn)?;
let (tx, rx) = unbounded::<WriteJob>();
let writer = std::thread::Builder::new()
.name("db-writer".into())
.spawn(move || {
while let Ok(job) = rx.recv() {
job(&mut conn);
}
// 종료 정리: 체크포인트 + 통계 갱신
let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA optimize;");
})
.expect("db-writer 스레드 생성 실패");
Ok(Db {
path: db_path.to_path_buf(),
write_tx: Some(tx),
writer: Some(writer),
readers: Mutex::new(Vec::new()),
fts_enabled,
})
}
/// writer 스레드에서 클로저를 실행하고 결과를 동기적으로 기다린다.
pub fn with_write<T, F>(&self, f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
let tx = self.write_tx.as_ref().ok_or(DbError::Closed)?;
let (rtx, rrx) = std::sync::mpsc::sync_channel::<rusqlite::Result<T>>(1);
tx.send(Box::new(move |conn| {
let _ = rtx.send(f(conn));
}))
.map_err(|_| DbError::Closed)?;
rrx.recv().map_err(|_| DbError::Closed)?.map_err(Into::into)
}
/// 결과를 기다리지 않는 쓰기(스캔 배치 등 대량 파이프라인용).
pub fn write_detached<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut Connection) + Send + 'static,
{
let tx = self.write_tx.as_ref().ok_or(DbError::Closed)?;
tx.send(Box::new(f)).map_err(|_| DbError::Closed)
}
/// 읽기전용 커넥션으로 쿼리를 실행한다 (풀에서 재사용).
pub fn with_read<T>(&self, f: impl FnOnce(&Connection) -> rusqlite::Result<T>) -> Result<T> {
let conn = match self.readers.lock().unwrap().pop() {
Some(c) => c,
None => self.open_reader()?,
};
let result = f(&conn);
// 풀 크기 상한: 커넥션 4개
let mut pool = self.readers.lock().unwrap();
if pool.len() < 4 {
pool.push(conn);
}
drop(pool);
result.map_err(Into::into)
}
fn open_reader(&self) -> Result<Connection> {
let conn = Connection::open_with_flags(
&self.path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
apply_reader_pragmas(&conn)?;
Ok(conn)
}
}
impl Drop for Db {
fn drop(&mut self) {
// 채널을 닫으면 writer 루프가 종료된다
self.write_tx.take();
if let Some(h) = self.writer.take() {
let _ = h.join();
}
}
}
fn apply_writer_pragmas(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
common_pragmas(conn)
}
fn apply_reader_pragmas(conn: &Connection) -> rusqlite::Result<()> {
// journal_mode는 DB 영속 속성이라 읽기전용 커넥션에서는 건드리지 않는다
common_pragmas(conn)
}
fn common_pragmas(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "cache_size", -64000)?;
conn.pragma_update(None, "mmap_size", 268_435_456i64)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn open_temp() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("test.db")).unwrap();
(dir, db)
}
fn insert_fixture_file(db: &Db, name: &str) -> i64 {
db.with_write({
let name = name.to_string();
move |conn| {
conn.execute(
"INSERT OR IGNORE INTO sources(id, kind, name, root) VALUES (1,'local','테스트','C:\\media')",
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO folders(id, source_id, path, name) VALUES (1,1,'C:\\media','media')",
[],
)?;
conn.execute(
"INSERT INTO files(source_id, folder_id, name, ext, kind, size, mtime_ms)
VALUES (1, 1, ?1, 'jpg', 0, 1000, 0)",
[&name],
)?;
Ok(conn.last_insert_rowid())
}
})
.unwrap()
}
/// M0 스파이크 ③: bundled SQLite의 FTS5 trigram 가용성 확인.
#[test]
fn fts5_trigram_smoke() {
let (_dir, db) = open_temp();
assert!(db.fts_enabled, "bundled SQLite에 FTS5 trigram이 없음 — LIKE 폴백 필요");
insert_fixture_file(&db, "가족여행_제주도_2025.jpg");
insert_fixture_file(&db, "회사_워크샵.png");
// 한글 부분 문자열(3자 이상) 매칭
let hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH ?1",
["제주도"],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(hits, 1);
}
#[test]
fn writer_roundtrip_and_read_pool() {
let (_dir, db) = open_temp();
let id = insert_fixture_file(&db, "test.jpg");
let name: String = db
.with_read(|conn| {
conn.query_row("SELECT name FROM files WHERE id = ?1", [id], |r| r.get(0))
})
.unwrap();
assert_eq!(name, "test.jpg");
}
#[test]
fn fts_sync_triggers() {
let (_dir, db) = open_temp();
let id = insert_fixture_file(&db, "바다사진.jpg");
db.with_write(move |conn| {
conn.execute("UPDATE files SET name = '산사진.jpg' WHERE id = ?1", [id])
})
.unwrap();
let old_hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH '바다사진'",
[],
|r| r.get(0),
)
})
.unwrap();
let new_hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH '산사진'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(old_hits, 0);
assert_eq!(new_hits, 1);
}
}
@@ -0,0 +1,213 @@
//! 스키마 마이그레이션 — `PRAGMA user_version` 기반 순차 적용.
use rusqlite::Connection;
/// 스키마 v1: 코어 테이블 전체.
const V1_DDL: &str = r#"
CREATE TABLE sources (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('local','sftp','ftp','webdav')),
name TEXT NOT NULL,
root TEXT NOT NULL,
config TEXT,
status INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000)
);
CREATE TABLE folders (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES folders(id),
path TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE(source_id, path)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TABLE files (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
folder_id INTEGER NOT NULL REFERENCES folders(id),
name TEXT NOT NULL,
ext TEXT,
kind INTEGER NOT NULL DEFAULT 2,
size INTEGER NOT NULL,
mtime_ms INTEGER NOT NULL,
width INTEGER,
height INTEGER,
orientation INTEGER,
duration_ms INTEGER,
vcodec TEXT,
acodec TEXT,
fps REAL,
taken_at INTEGER,
quick_hash INTEGER,
full_hash BLOB,
phash INTEGER,
vhash BLOB,
meta_state INTEGER NOT NULL DEFAULT 0,
thumb_state INTEGER NOT NULL DEFAULT 0,
scan_id INTEGER,
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
deleted_at INTEGER,
UNIQUE(folder_id, name)
);
CREATE INDEX idx_files_folder ON files(folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_size ON files(size);
CREATE INDEX idx_files_qhash ON files(quick_hash) WHERE quick_hash IS NOT NULL;
CREATE INDEX idx_files_fhash ON files(full_hash) WHERE full_hash IS NOT NULL;
CREATE INDEX idx_files_taken ON files(taken_at);
CREATE INDEX idx_files_pending_meta ON files(id) WHERE meta_state = 0 AND deleted_at IS NULL;
CREATE INDEX idx_files_pending_thumb ON files(id) WHERE thumb_state = 0 AND deleted_at IS NULL;
CREATE TABLE tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT,
parent_id INTEGER REFERENCES tags(id),
sort_order INTEGER
);
CREATE TABLE file_tags (
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
PRIMARY KEY (file_id, tag_id)
) WITHOUT ROWID;
CREATE INDEX idx_file_tags_tag ON file_tags(tag_id);
CREATE TABLE thumbs (
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
size_class INTEGER NOT NULL,
cache_key TEXT NOT NULL,
width INTEGER,
height INTEGER,
bytes INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
PRIMARY KEY (file_id, size_class)
);
CREATE INDEX idx_thumbs_key ON thumbs(cache_key);
CREATE TABLE scans (
id INTEGER PRIMARY KEY,
source_id INTEGER REFERENCES sources(id),
started_at INTEGER NOT NULL,
finished_at INTEGER,
status INTEGER NOT NULL DEFAULT 0,
stats TEXT
);
CREATE TABLE ops_journal (
id INTEGER PRIMARY KEY,
batch_id TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('move','rename','trash','tag','untag')),
file_id INTEGER,
src_path TEXT,
dst_path TEXT,
tag_id INTEGER,
performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
undone INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_ops_batch ON ops_journal(batch_id);
CREATE TABLE dupe_groups (
id INTEGER PRIMARY KEY,
kind INTEGER NOT NULL,
score REAL,
status TEXT NOT NULL DEFAULT 'open',
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000)
);
CREATE TABLE dupe_members (
group_id INTEGER NOT NULL REFERENCES dupe_groups(id) ON DELETE CASCADE,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
is_keeper INTEGER NOT NULL DEFAULT 0,
distance INTEGER,
resolved_action TEXT,
PRIMARY KEY (group_id, file_id)
);
CREATE TABLE dupe_dismissed (
file_a INTEGER NOT NULL,
file_b INTEGER NOT NULL,
dismissed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
PRIMARY KEY (file_a, file_b)
) WITHOUT ROWID;
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
source_id INTEGER,
state TEXT NOT NULL DEFAULT 'pending',
cursor TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
updated_at INTEGER
);
CREATE TABLE smart_folders (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
query TEXT NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
) WITHOUT ROWID;
"#;
/// FTS5 trigram — 한글 파일명 부분일치 검색. bundled SQLite가 trigram을
/// 지원하지 않는 환경이면 생성 실패를 허용하고 LIKE 폴백을 쓴다.
const V1_FTS_DDL: &str = r#"
CREATE VIRTUAL TABLE files_fts USING fts5(
name,
tokenize = 'trigram',
content = 'files',
content_rowid = 'id'
);
CREATE TRIGGER files_fts_ai AFTER INSERT ON files BEGIN
INSERT INTO files_fts(rowid, name) VALUES (new.id, new.name);
END;
CREATE TRIGGER files_fts_ad AFTER DELETE ON files BEGIN
INSERT INTO files_fts(files_fts, rowid, name) VALUES ('delete', old.id, old.name);
END;
CREATE TRIGGER files_fts_au AFTER UPDATE OF name ON files BEGIN
INSERT INTO files_fts(files_fts, rowid, name) VALUES ('delete', old.id, old.name);
INSERT INTO files_fts(rowid, name) VALUES (new.id, new.name);
END;
"#;
/// 마이그레이션을 적용하고 FTS 사용 가능 여부를 반환한다.
pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version < 1 {
let tx = conn.transaction()?;
tx.execute_batch(V1_DDL)?;
tx.pragma_update(None, "user_version", 1)?;
tx.commit()?;
// FTS는 별도 트랜잭션 — 실패해도 코어 스키마는 유지
let fts_ok = conn.execute_batch(V1_FTS_DDL).is_ok();
conn.execute(
"INSERT OR REPLACE INTO settings(key, value) VALUES ('fts', ?1)",
[if fts_ok { "on" } else { "off" }],
)?;
if !fts_ok {
tracing::warn!("FTS5 trigram 사용 불가 — LIKE 검색으로 폴백");
}
return Ok(fts_ok);
}
let fts: Option<String> = conn
.query_row("SELECT value FROM settings WHERE key = 'fts'", [], |r| r.get(0))
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
e => Err(e),
})?;
Ok(fts.as_deref() == Some("on"))
}