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,13 @@
|
||||
[package]
|
||||
name = "archive-db"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
crossbeam-channel = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user