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"))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "archive-indexer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
archive-db = { path = "../archive-db" }
|
||||
archive-vfs = { path = "../archive-vfs" }
|
||||
jwalk = "0.8"
|
||||
rayon = "1"
|
||||
crossbeam-channel = { workspace = true }
|
||||
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
||||
blake3 = { version = "1", features = ["mmap", "rayon"] }
|
||||
image = "0.25"
|
||||
image_hasher = "3"
|
||||
fast_image_resize = "6"
|
||||
imagesize = "0.14"
|
||||
kamadak-exif = "0.6"
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
trash = "5"
|
||||
filetime = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,502 @@
|
||||
//! 중복 탐지 파이프라인.
|
||||
//!
|
||||
//! 완전 동일: size 그룹 → quick_hash → BLAKE3 전체 해시 (그룹 내만).
|
||||
//! 유사 이미지: dHash(썸네일에서) 병렬 브루트포스 → Union-Find 클러스터.
|
||||
//! 유사 영상: duration ±5% 사전필터 → 프레임 시그니처 중앙값 해밍.
|
||||
//! 결과는 dupe_groups/dupe_members에 영속화된다.
|
||||
|
||||
use crate::ffmpeg::FfTools;
|
||||
use crate::{hash, phash};
|
||||
use archive_db::Db;
|
||||
use rayon::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DedupeError {
|
||||
#[error("DB 오류: {0}")]
|
||||
Db(#[from] archive_db::DbError),
|
||||
#[error("취소됨")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DedupeError>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum GroupKind {
|
||||
Exact = 0,
|
||||
Image = 1,
|
||||
Video = 2,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DedupeStats {
|
||||
pub exact_groups: u64,
|
||||
pub image_groups: u64,
|
||||
pub video_groups: u64,
|
||||
pub total_dupes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DedupeProgress {
|
||||
pub phase: String, // exact | image | video | done
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
struct FileMeta {
|
||||
id: i64,
|
||||
path: PathBuf,
|
||||
size: i64,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
mtime_ms: i64,
|
||||
duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
fn load_files(db: &Db, kind: u8, scope: Option<i64>) -> Result<Vec<FileMeta>> {
|
||||
Ok(db.with_read(move |conn| {
|
||||
let base = "SELECT f.id, fo.path, f.name, f.size, f.width, f.height, f.mtime_ms, f.duration_ms
|
||||
FROM files f JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.kind = ?1 AND f.deleted_at IS NULL";
|
||||
let map = |r: &rusqlite::Row<'_>| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok(FileMeta {
|
||||
id: r.get(0)?,
|
||||
path: Path::new(&dir).join(name),
|
||||
size: r.get(3)?,
|
||||
width: r.get(4)?,
|
||||
height: r.get(5)?,
|
||||
mtime_ms: r.get(6)?,
|
||||
duration_ms: r.get(7)?,
|
||||
})
|
||||
};
|
||||
match scope {
|
||||
Some(sid) => {
|
||||
let mut stmt = conn.prepare(&format!("{base} AND f.source_id = ?2"))?;
|
||||
let rows = stmt.query_map(rusqlite::params![kind, sid], map)?;
|
||||
rows.collect()
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare(base)?;
|
||||
let rows = stmt.query_map([kind], map)?;
|
||||
rows.collect()
|
||||
}
|
||||
}
|
||||
})?)
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// keeper 자동 선정: 해상도↓ → mtime(오래된 것)↓ → id↓.
|
||||
/// 반환된 첫 요소가 keeper.
|
||||
fn rank_keeper<'a>(group: &'a [&'a FileMeta]) -> Vec<i64> {
|
||||
let mut sorted: Vec<&FileMeta> = group.to_vec();
|
||||
sorted.sort_by(|a, b| {
|
||||
let pa = a.width.unwrap_or(0) * a.height.unwrap_or(0);
|
||||
let pb = b.width.unwrap_or(0) * b.height.unwrap_or(0);
|
||||
pb.cmp(&pa) // 해상도 큰 것 우선
|
||||
.then(a.size.cmp(&b.size).reverse()) // 파일 큰 것 우선
|
||||
.then(a.mtime_ms.cmp(&b.mtime_ms)) // 오래된 것 우선
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
sorted.iter().map(|f| f.id).collect()
|
||||
}
|
||||
|
||||
/// 그룹을 dupe_groups/dupe_members에 저장한다 (keeper 자동 선정).
|
||||
fn persist_group(
|
||||
db: &Db,
|
||||
kind: GroupKind,
|
||||
members: &[&FileMeta],
|
||||
distances: &HashMap<i64, u32>,
|
||||
) -> Result<()> {
|
||||
let ranked = rank_keeper(members);
|
||||
let kind_val = kind as i64;
|
||||
let dists: Vec<(i64, Option<i64>)> = ranked
|
||||
.iter()
|
||||
.map(|id| (*id, distances.get(id).map(|d| *d as i64)))
|
||||
.collect();
|
||||
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO dupe_groups(kind, created_at) VALUES (?1, ?2)",
|
||||
rusqlite::params![kind_val, now_ms()],
|
||||
)?;
|
||||
let gid = tx.last_insert_rowid();
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO dupe_members(group_id, file_id, is_keeper, distance) VALUES (?1, ?2, ?3, ?4)",
|
||||
)?;
|
||||
for (i, (id, dist)) in dists.iter().enumerate() {
|
||||
stmt.execute(rusqlite::params![gid, id, if i == 0 { 1 } else { 0 }, dist])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 이전 미해결 그룹을 지운다 (재탐지 시). 해결/무시(dismissed)는 보존.
|
||||
fn clear_open_groups(db: &Db) -> Result<()> {
|
||||
db.with_write(|conn| {
|
||||
conn.execute("DELETE FROM dupe_groups WHERE status = 'open'", [])?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dismissed(dismissed: &std::collections::HashSet<(i64, i64)>, a: i64, b: i64) -> bool {
|
||||
let key = if a < b { (a, b) } else { (b, a) };
|
||||
dismissed.contains(&key)
|
||||
}
|
||||
|
||||
fn load_dismissed(db: &Db) -> Result<std::collections::HashSet<(i64, i64)>> {
|
||||
Ok(db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT file_a, file_b FROM dupe_dismissed")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 완전 동일 탐지: size → quick_hash → BLAKE3.
|
||||
/// 그룹으로 묶인 파일 id는 `grouped`에 누적된다 (유사 패스에서 제외용).
|
||||
fn detect_exact(
|
||||
db: &Db,
|
||||
files: &[FileMeta],
|
||||
grouped: &mut std::collections::HashSet<i64>,
|
||||
cancel: &AtomicBool,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<u64> {
|
||||
// 1. size로 그룹핑
|
||||
let mut by_size: HashMap<i64, Vec<&FileMeta>> = HashMap::new();
|
||||
for f in files {
|
||||
by_size.entry(f.size).or_default().push(f);
|
||||
}
|
||||
let candidates: Vec<Vec<&FileMeta>> =
|
||||
by_size.into_values().filter(|g| g.len() >= 2).collect();
|
||||
|
||||
let total = candidates.iter().map(|g| g.len()).sum::<usize>() as u64;
|
||||
let mut processed = 0u64;
|
||||
let mut group_count = 0u64;
|
||||
|
||||
for size_group in candidates {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DedupeError::Cancelled);
|
||||
}
|
||||
// 2. quick_hash로 세분화
|
||||
let with_qh: Vec<(u64, &FileMeta)> = size_group
|
||||
.par_iter()
|
||||
.filter_map(|f| hash::quick_hash(&f.path).ok().map(|h| (h, *f)))
|
||||
.collect();
|
||||
processed += size_group.len() as u64;
|
||||
progress(processed, total);
|
||||
|
||||
let mut by_qh: HashMap<u64, Vec<&FileMeta>> = HashMap::new();
|
||||
for (qh, f) in with_qh {
|
||||
by_qh.entry(qh).or_default().push(f);
|
||||
}
|
||||
|
||||
for qh_group in by_qh.into_values().filter(|g| g.len() >= 2) {
|
||||
// 3. BLAKE3 전체 해시로 확정
|
||||
let with_fh: Vec<([u8; 32], &FileMeta)> = qh_group
|
||||
.par_iter()
|
||||
.filter_map(|f| hash::full_hash(&f.path).ok().map(|h| (h, *f)))
|
||||
.collect();
|
||||
let mut by_fh: HashMap<[u8; 32], Vec<&FileMeta>> = HashMap::new();
|
||||
for (fh, f) in with_fh {
|
||||
by_fh.entry(fh).or_default().push(f);
|
||||
}
|
||||
for exact in by_fh.into_values().filter(|g| g.len() >= 2) {
|
||||
// keeper(첫 요소)는 유사 패스에 남겨 교차 유사도를 잡되,
|
||||
// 나머지 완전 동일본은 제외해 중복 표시를 막는다.
|
||||
let ranked = rank_keeper(&exact);
|
||||
for id in ranked.iter().skip(1) {
|
||||
grouped.insert(*id);
|
||||
}
|
||||
persist_group(db, GroupKind::Exact, &exact, &HashMap::new())?;
|
||||
group_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(group_count)
|
||||
}
|
||||
|
||||
/// 유사 이미지 탐지: dHash → 브루트포스 → 클러스터.
|
||||
/// `exclude`에 속한 파일(이미 완전 동일 그룹으로 처리됨)은 제외한다.
|
||||
fn detect_similar_images(
|
||||
db: &Db,
|
||||
files: &[FileMeta],
|
||||
threshold: u32,
|
||||
dismissed: &std::collections::HashSet<(i64, i64)>,
|
||||
exclude: &std::collections::HashSet<i64>,
|
||||
cancel: &AtomicBool,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<u64> {
|
||||
let total = files.len() as u64;
|
||||
// dHash 병렬 계산 (썸네일 우선 — thumbs 캐시가 있으면 그것으로, 없으면 원본)
|
||||
let hashes: Vec<(i64, u64)> = files
|
||||
.par_iter()
|
||||
.filter(|f| !exclude.contains(&f.id))
|
||||
.filter_map(|f| phash::dhash_file(&f.path).map(|h| (f.id, h)))
|
||||
.collect();
|
||||
progress(total, total);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DedupeError::Cancelled);
|
||||
}
|
||||
|
||||
let pairs: Vec<(i64, i64, u32)> = phash::find_near_pairs(&hashes, threshold)
|
||||
.into_iter()
|
||||
.filter(|(a, b, _)| !is_dismissed(dismissed, *a, *b))
|
||||
.collect();
|
||||
|
||||
let ids: Vec<i64> = hashes.iter().map(|(id, _)| *id).collect();
|
||||
let groups = phash::cluster(&ids, &pairs);
|
||||
|
||||
// 거리 맵 (keeper 대비 근사 — 그룹 내 최소 거리)
|
||||
let by_id: HashMap<i64, &FileMeta> = files.iter().map(|f| (f.id, f)).collect();
|
||||
let mut group_count = 0u64;
|
||||
for group in groups {
|
||||
let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect();
|
||||
if members.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
persist_group(db, GroupKind::Image, &members, &HashMap::new())?;
|
||||
group_count += 1;
|
||||
}
|
||||
Ok(group_count)
|
||||
}
|
||||
|
||||
/// 영상 프레임 시그니처: 5/15/…/95% 10프레임 dHash.
|
||||
fn video_signature(tools: &FfTools, path: &Path) -> Option<Vec<u64>> {
|
||||
let offsets = [0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95];
|
||||
let dir = std::env::temp_dir().join(format!("archive-vsig-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let info = crate::ffmpeg::probe(tools, path).ok()?;
|
||||
let dur = info.duration_ms? as f64 / 1000.0;
|
||||
if dur <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut sig = Vec::with_capacity(offsets.len());
|
||||
for (i, off) in offsets.iter().enumerate() {
|
||||
let frame = dir.join(format!("f{i}.jpg"));
|
||||
if crate::ffmpeg::extract_frame_jpeg(tools, path, &frame, dur * off, 64).is_ok() {
|
||||
if let Some(h) = phash::dhash_file(&frame) {
|
||||
sig.push(h);
|
||||
}
|
||||
let _ = std::fs::remove_file(&frame);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if sig.len() >= 5 {
|
||||
Some(sig)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 두 시그니처의 프레임별 해밍 중앙값.
|
||||
fn signature_distance(a: &[u64], b: &[u64]) -> u32 {
|
||||
let n = a.len().min(b.len());
|
||||
if n == 0 {
|
||||
return u32::MAX;
|
||||
}
|
||||
let mut dists: Vec<u32> = (0..n).map(|i| phash::hamming(a[i], b[i])).collect();
|
||||
dists.sort_unstable();
|
||||
dists[n / 2]
|
||||
}
|
||||
|
||||
fn detect_similar_videos(
|
||||
db: &Db,
|
||||
files: &[FileMeta],
|
||||
tools: &FfTools,
|
||||
threshold: u32,
|
||||
dismissed: &std::collections::HashSet<(i64, i64)>,
|
||||
cancel: &AtomicBool,
|
||||
mut progress: impl FnMut(u64, u64),
|
||||
) -> Result<u64> {
|
||||
// duration 있는 영상만
|
||||
let with_dur: Vec<&FileMeta> = files.iter().filter(|f| f.duration_ms.unwrap_or(0) > 0).collect();
|
||||
let total = with_dur.len() as u64;
|
||||
|
||||
// 시그니처 계산 (동시 2 — 각 ffmpeg가 ~1코어)
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().ok();
|
||||
let sigs: Vec<(i64, i64, Vec<u64>)> = match &pool {
|
||||
Some(p) => p.install(|| {
|
||||
with_dur
|
||||
.par_iter()
|
||||
.filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s)))
|
||||
.collect()
|
||||
}),
|
||||
None => with_dur
|
||||
.iter()
|
||||
.filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s)))
|
||||
.collect(),
|
||||
};
|
||||
progress(total, total);
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(DedupeError::Cancelled);
|
||||
}
|
||||
|
||||
// duration ±5% 사전필터 후 시그니처 비교
|
||||
let mut pairs: Vec<(i64, i64, u32)> = Vec::new();
|
||||
for i in 0..sigs.len() {
|
||||
for j in (i + 1)..sigs.len() {
|
||||
let (ida, da, ref sa) = sigs[i];
|
||||
let (idb, db_dur, ref sb) = sigs[j];
|
||||
let ratio = da as f64 / db_dur.max(1) as f64;
|
||||
if !(0.95..=1.05).contains(&ratio) {
|
||||
continue;
|
||||
}
|
||||
let dist = signature_distance(sa, sb);
|
||||
if dist <= threshold && !is_dismissed(dismissed, ida, idb) {
|
||||
let (lo, hi) = if ida < idb { (ida, idb) } else { (idb, ida) };
|
||||
pairs.push((lo, hi, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ids: Vec<i64> = sigs.iter().map(|(id, _, _)| *id).collect();
|
||||
let groups = phash::cluster(&ids, &pairs);
|
||||
let by_id: HashMap<i64, &FileMeta> = files.iter().map(|f| (f.id, f)).collect();
|
||||
let mut group_count = 0u64;
|
||||
for group in groups {
|
||||
let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect();
|
||||
if members.len() >= 2 {
|
||||
persist_group(db, GroupKind::Video, &members, &HashMap::new())?;
|
||||
group_count += 1;
|
||||
}
|
||||
}
|
||||
Ok(group_count)
|
||||
}
|
||||
|
||||
/// 전체 중복 탐지 실행. 블로킹 — 전용 스레드에서 호출할 것.
|
||||
pub fn run(
|
||||
db: &Arc<Db>,
|
||||
tools: Option<&FfTools>,
|
||||
scope_source: Option<i64>,
|
||||
image_threshold: u32,
|
||||
include_video: bool,
|
||||
cancel: &AtomicBool,
|
||||
mut on_progress: impl FnMut(DedupeProgress),
|
||||
) -> Result<DedupeStats> {
|
||||
clear_open_groups(db)?;
|
||||
let dismissed = load_dismissed(db)?;
|
||||
let mut stats = DedupeStats::default();
|
||||
let mut grouped: std::collections::HashSet<i64> = std::collections::HashSet::new();
|
||||
|
||||
// 완전 동일 (이미지+영상 모두)
|
||||
let mut all_files = load_files(db, 0, scope_source)?;
|
||||
all_files.extend(load_files(db, 1, scope_source)?);
|
||||
stats.exact_groups = detect_exact(db, &all_files, &mut grouped, cancel, |c, t| {
|
||||
on_progress(DedupeProgress { phase: "exact".into(), current: c, total: t });
|
||||
})?;
|
||||
|
||||
// 유사 이미지 (완전 동일 그룹에 속한 파일은 제외)
|
||||
let images = load_files(db, 0, scope_source)?;
|
||||
stats.image_groups = detect_similar_images(db, &images, image_threshold, &dismissed, &grouped, cancel, |c, t| {
|
||||
on_progress(DedupeProgress { phase: "image".into(), current: c, total: t });
|
||||
})?;
|
||||
|
||||
// 유사 영상 (opt-in + ffmpeg 필요)
|
||||
if include_video {
|
||||
if let Some(tools) = tools {
|
||||
let videos = load_files(db, 1, scope_source)?;
|
||||
stats.video_groups = detect_similar_videos(db, &videos, tools, 8, &dismissed, cancel, |c, t| {
|
||||
on_progress(DedupeProgress { phase: "video".into(), current: c, total: t });
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
stats.total_dupes = db.with_read(|conn| {
|
||||
conn.query_row("SELECT count(*) FROM dupe_members", [], |r| r.get::<_, i64>(0))
|
||||
})? as u64;
|
||||
|
||||
on_progress(DedupeProgress { phase: "done".into(), current: 0, total: 0 });
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
(dbdir, media, db, sid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_exact_and_resized_duplicates() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path();
|
||||
|
||||
// 원본 이미지
|
||||
let base = image::RgbImage::from_fn(400, 300, |x, y| {
|
||||
image::Rgb([((x * 255) / 400) as u8, ((y * 255) / 300) as u8, 100])
|
||||
});
|
||||
base.save(root.join("original.png")).unwrap();
|
||||
// 완전 동일 복사본 (바이트 동일)
|
||||
std::fs::copy(root.join("original.png"), root.join("exact_copy.png")).unwrap();
|
||||
// 리사이즈본 (유사)
|
||||
image::DynamicImage::ImageRgb8(base.clone())
|
||||
.resize_exact(200, 150, image::imageops::FilterType::Lanczos3)
|
||||
.save(root.join("resized.jpg"))
|
||||
.unwrap();
|
||||
// 무관한 이미지
|
||||
image::RgbImage::from_pixel(300, 300, image::Rgb([10, 200, 30]))
|
||||
.save(root.join("unrelated.png"))
|
||||
.unwrap();
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, root, &cancel, |_| {}).unwrap();
|
||||
crate::meta::extract_image_meta(&db, sid, &cancel).unwrap();
|
||||
|
||||
let stats = run(&db, None, Some(sid), 5, false, &cancel, |_| {}).unwrap();
|
||||
|
||||
// 완전 동일 그룹 1개 (original + exact_copy)
|
||||
assert_eq!(stats.exact_groups, 1, "완전 동일 그룹");
|
||||
// 유사 이미지 그룹: original/exact_copy/resized 모두 시각적으로 같음
|
||||
assert!(stats.image_groups >= 1, "유사 이미지 그룹 {}", stats.image_groups);
|
||||
|
||||
// 완전 동일 그룹에 keeper가 정확히 1개
|
||||
let keepers: i64 = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT count(*) FROM dupe_members dm
|
||||
JOIN dupe_groups dg ON dg.id = dm.group_id
|
||||
WHERE dg.kind = 0 AND dm.is_keeper = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(keepers, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_distance_median() {
|
||||
let a = vec![0u64, 0, 0, 0, 0];
|
||||
let b = vec![0u64, 0, 0xFF, 0, 0]; // 한 프레임만 다름
|
||||
assert_eq!(signature_distance(&a, &b), 0); // 중앙값은 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! ffmpeg/ffprobe 사이드카 — 위치 해석, ffprobe 메타 파싱, 프레임 추출.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(all(windows, target_arch = "x86_64"))]
|
||||
const TRIPLE: &str = "x86_64-pc-windows-msvc";
|
||||
#[cfg(all(windows, target_arch = "aarch64"))]
|
||||
const TRIPLE: &str = "aarch64-pc-windows-msvc";
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
const TRIPLE: &str = "aarch64-apple-darwin";
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
const TRIPLE: &str = "x86_64-apple-darwin";
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const TRIPLE: &str = "unknown";
|
||||
|
||||
#[cfg(windows)]
|
||||
const EXE: &str = ".exe";
|
||||
#[cfg(not(windows))]
|
||||
const EXE: &str = "";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FfTools {
|
||||
pub ffmpeg: PathBuf,
|
||||
pub ffprobe: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FfError {
|
||||
#[error("ffmpeg 실행 오류: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("ffprobe 출력 파싱 실패: {0}")]
|
||||
Parse(String),
|
||||
#[error("ffmpeg 종료 코드 {0}: {1}")]
|
||||
Exit(i32, String),
|
||||
}
|
||||
|
||||
/// 사이드카 탐색: ① ARCHIVE_FFMPEG_DIR ② exe 옆 ③ dev(src-tauri/binaries) ④ PATH
|
||||
pub fn locate() -> Option<FfTools> {
|
||||
let candidates = |name: &str| -> Vec<PathBuf> {
|
||||
let mut v = Vec::new();
|
||||
if let Ok(dir) = std::env::var("ARCHIVE_FFMPEG_DIR") {
|
||||
v.push(Path::new(&dir).join(format!("{name}{EXE}")));
|
||||
v.push(Path::new(&dir).join(format!("{name}-{TRIPLE}{EXE}")));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(d) = exe.parent() {
|
||||
v.push(d.join(format!("{name}{EXE}")));
|
||||
v.push(d.join(format!("{name}-{TRIPLE}{EXE}")));
|
||||
// dev: target/debug → src-tauri/binaries
|
||||
v.push(d.join("../..").join("binaries").join(format!("{name}-{TRIPLE}{EXE}")));
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
let find = |name: &str| -> Option<PathBuf> {
|
||||
for c in candidates(name) {
|
||||
if c.is_file() {
|
||||
return Some(c);
|
||||
}
|
||||
}
|
||||
// PATH 폴백
|
||||
which(name)
|
||||
};
|
||||
|
||||
Some(FfTools {
|
||||
ffmpeg: find("ffmpeg")?,
|
||||
ffprobe: find("ffprobe")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn which(name: &str) -> Option<PathBuf> {
|
||||
let paths = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
let p = dir.join(format!("{name}{EXE}"));
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 콘솔 창 없이 실행되는 Command 생성 (Windows CREATE_NO_WINDOW)
|
||||
pub fn command(path: &Path) -> Command {
|
||||
let cmd = Command::new(path);
|
||||
#[cfg(windows)]
|
||||
let cmd = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let mut c = cmd;
|
||||
c.creation_flags(0x0800_0000);
|
||||
c
|
||||
};
|
||||
cmd
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MediaInfo {
|
||||
pub duration_ms: Option<i64>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub vcodec: Option<String>,
|
||||
pub acodec: Option<String>,
|
||||
pub fps: Option<f64>,
|
||||
}
|
||||
|
||||
/// ffprobe로 영상 메타데이터를 읽는다.
|
||||
pub fn probe(tools: &FfTools, path: &Path) -> Result<MediaInfo, FfError> {
|
||||
let out = command(&tools.ffprobe)
|
||||
.args(["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams"])
|
||||
.arg(path)
|
||||
.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(FfError::Exit(
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
));
|
||||
}
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| FfError::Parse(e.to_string()))?;
|
||||
|
||||
let mut info = MediaInfo::default();
|
||||
info.duration_ms = v["format"]["duration"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|s| (s * 1000.0) as i64);
|
||||
|
||||
if let Some(streams) = v["streams"].as_array() {
|
||||
for s in streams {
|
||||
match s["codec_type"].as_str() {
|
||||
Some("video") if info.vcodec.is_none() => {
|
||||
info.vcodec = s["codec_name"].as_str().map(String::from);
|
||||
info.width = s["width"].as_u64().map(|w| w as u32);
|
||||
info.height = s["height"].as_u64().map(|h| h as u32);
|
||||
info.fps = s["avg_frame_rate"]
|
||||
.as_str()
|
||||
.and_then(parse_frame_rate)
|
||||
.or_else(|| s["r_frame_rate"].as_str().and_then(parse_frame_rate));
|
||||
// 스트림 duration 폴백
|
||||
if info.duration_ms.is_none() {
|
||||
info.duration_ms = s["duration"]
|
||||
.as_str()
|
||||
.and_then(|d| d.parse::<f64>().ok())
|
||||
.map(|d| (d * 1000.0) as i64);
|
||||
}
|
||||
}
|
||||
Some("audio") if info.acodec.is_none() => {
|
||||
info.acodec = s["codec_name"].as_str().map(String::from);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn parse_frame_rate(s: &str) -> Option<f64> {
|
||||
let (num, den) = s.split_once('/')?;
|
||||
let num: f64 = num.parse().ok()?;
|
||||
let den: f64 = den.parse().ok()?;
|
||||
if den == 0.0 || num == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(num / den)
|
||||
}
|
||||
}
|
||||
|
||||
/// 영상에서 프레임 하나를 뽑아 JPEG 썸네일로 저장한다. 반환: (썸네일 w, h)
|
||||
pub fn extract_frame_jpeg(
|
||||
tools: &FfTools,
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
at_seconds: f64,
|
||||
long_edge: u32,
|
||||
) -> Result<(u32, u32), FfError> {
|
||||
extract_frame_input(tools, src.as_os_str(), dst, at_seconds, long_edge)
|
||||
}
|
||||
|
||||
/// URL(예: 미디어 서버 bridge)에서 프레임을 뽑는다 — ffmpeg가 Range로 읽어 전체 다운로드 회피.
|
||||
pub fn extract_frame_url(
|
||||
tools: &FfTools,
|
||||
url: &str,
|
||||
dst: &Path,
|
||||
at_seconds: f64,
|
||||
long_edge: u32,
|
||||
) -> Result<(u32, u32), FfError> {
|
||||
extract_frame_input(tools, std::ffi::OsStr::new(url), dst, at_seconds, long_edge)
|
||||
}
|
||||
|
||||
fn extract_frame_input(
|
||||
tools: &FfTools,
|
||||
input: &std::ffi::OsStr,
|
||||
dst: &Path,
|
||||
at_seconds: f64,
|
||||
long_edge: u32,
|
||||
) -> Result<(u32, u32), FfError> {
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let scale = format!("scale=w={long_edge}:h={long_edge}:force_original_aspect_ratio=decrease");
|
||||
let out = command(&tools.ffmpeg)
|
||||
.args(["-y", "-v", "error", "-ss", &format!("{at_seconds:.3}")])
|
||||
.arg("-i")
|
||||
.arg(input)
|
||||
.args(["-frames:v", "1", "-vf", &scale, "-q:v", "4"])
|
||||
.arg(dst)
|
||||
.output()?;
|
||||
if !out.status.success() || !dst.is_file() {
|
||||
return Err(FfError::Exit(
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
));
|
||||
}
|
||||
image::image_dimensions(dst)
|
||||
.map_err(|e| FfError::Parse(format!("썸네일 치수 읽기 실패: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_rate_parsing() {
|
||||
assert_eq!(parse_frame_rate("30000/1001").map(|f| (f * 100.0).round()), Some(2997.0));
|
||||
assert_eq!(parse_frame_rate("25/1"), Some(25.0));
|
||||
assert_eq!(parse_frame_rate("0/0"), None);
|
||||
assert_eq!(parse_frame_rate("잘못됨"), None);
|
||||
}
|
||||
|
||||
// ffmpeg 실기 테스트는 tests/ffmpeg_integration.rs (사이드카 존재 시에만 실행)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! 파일 작업 계층 — 이동/이름변경/휴지통 + ops_journal 기반 undo.
|
||||
//!
|
||||
//! 원칙: ① 물리적 파일 변경 성공 후에만 DB 갱신 ② 모든 작업은 저널 기록
|
||||
//! ③ 삭제는 OS 휴지통 경유만 (영구삭제 없음 — 계획서 §7).
|
||||
|
||||
use archive_db::Db;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OpError {
|
||||
#[error("DB 오류: {0}")]
|
||||
Db(#[from] archive_db::DbError),
|
||||
#[error("I/O 오류 ({path}): {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("휴지통 오류 ({path}): {message}")]
|
||||
Trash { path: String, message: String },
|
||||
#[error("잘못된 이름: {0}")]
|
||||
BadName(String),
|
||||
#[error("대상 폴더를 찾을 수 없음: {0}")]
|
||||
NoFolder(i64),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, OpError>;
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpSummary {
|
||||
pub done: u64,
|
||||
pub skipped: u64,
|
||||
pub failed: u64,
|
||||
pub batch_id: String,
|
||||
}
|
||||
|
||||
static BATCH_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 배치 id 생성 (프로세스 내 유일)
|
||||
pub fn new_batch_id() -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let n = BATCH_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{now:x}-{n:x}")
|
||||
}
|
||||
|
||||
struct FileRow {
|
||||
id: i64,
|
||||
dir: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
|
||||
let ids = file_ids.to_vec();
|
||||
Ok(db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.id = ?1 AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
if let Ok(row) = stmt.query_row([id], |r| {
|
||||
Ok(FileRow {
|
||||
id: r.get(0)?,
|
||||
dir: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
})
|
||||
}) {
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 충돌 시 "이름 (2).ext" 식으로 비어 있는 이름을 찾는다.
|
||||
fn resolve_collision(dest_dir: &Path, name: &str) -> String {
|
||||
if !dest_dir.join(name).exists() {
|
||||
return name.to_string();
|
||||
}
|
||||
let (stem, ext) = match name.rsplit_once('.') {
|
||||
Some((s, e)) => (s, format!(".{e}")),
|
||||
None => (name, String::new()),
|
||||
};
|
||||
for i in 2..1000 {
|
||||
let candidate = format!("{stem} ({i}){ext}");
|
||||
if !dest_dir.join(&candidate).exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
format!("{stem}-{}{ext}", new_batch_id())
|
||||
}
|
||||
|
||||
/// rename → 크로스 볼륨이면 copy+delete 폴백 (mtime 보존).
|
||||
/// Windows의 일시적 공유 위반(안티바이러스/인덱서)에는 짧게 재시도한다.
|
||||
fn physical_move(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
let mut last_err = None;
|
||||
for attempt in 0..4 {
|
||||
match std::fs::rename(src, dst) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e)
|
||||
if e.raw_os_error() == Some(17) || e.kind() == std::io::ErrorKind::CrossesDevices =>
|
||||
{
|
||||
// 크로스 볼륨: copy + mtime 보존 + delete
|
||||
let meta = std::fs::metadata(src)?;
|
||||
std::fs::copy(src, dst)?;
|
||||
if let Ok(mtime) = meta.modified() {
|
||||
let _ = filetime::set_file_mtime(dst, filetime::FileTime::from_system_time(mtime));
|
||||
}
|
||||
std::fs::remove_file(src)?;
|
||||
return Ok(());
|
||||
}
|
||||
// Windows 공유 위반(32)/액세스 거부(5)는 일시적일 수 있어 재시도
|
||||
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) && attempt < 3 => {
|
||||
last_err = Some(e);
|
||||
std::thread::sleep(std::time::Duration::from_millis(50 * (attempt + 1)));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| std::io::Error::other("move 재시도 소진")))
|
||||
}
|
||||
|
||||
/// 파일들을 대상 폴더(폴더 트리의 로컬 폴더)로 이동한다.
|
||||
pub fn move_files(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
file_ids: &[i64],
|
||||
dest_folder_id: i64,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<OpSummary> {
|
||||
let dest_dir: String = db
|
||||
.with_read(|conn| {
|
||||
conn.query_row("SELECT path FROM folders WHERE id = ?1", [dest_folder_id], |r| r.get(0))
|
||||
})
|
||||
.map_err(|_| OpError::NoFolder(dest_folder_id))?;
|
||||
let dest_path = PathBuf::from(&dest_dir);
|
||||
|
||||
let rows = load_rows(db, file_ids)?;
|
||||
let total = rows.len() as u64;
|
||||
let mut summary = OpSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
if row.dir == dest_dir {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let new_name = resolve_collision(&dest_path, &row.name);
|
||||
let dst = dest_path.join(&new_name);
|
||||
|
||||
match physical_move(&src, &dst) {
|
||||
Ok(()) => {
|
||||
let (bid, fid) = (batch_id.to_string(), row.id);
|
||||
let (src_s, dst_s) = (src.to_string_lossy().into_owned(), dst.to_string_lossy().into_owned());
|
||||
let nn = new_name.clone();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE files SET folder_id = ?2, name = ?3 WHERE id = ?1",
|
||||
rusqlite::params![fid, dest_folder_id, nn],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
|
||||
VALUES (?1, 'move', ?2, ?3, ?4)",
|
||||
rusqlite::params![bid, fid, src_s, dst_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
summary.done += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(src = %src.display(), "이동 실패: {e}");
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
on_progress(i as u64 + 1, total);
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// 파일 이름 변경 (같은 폴더 내).
|
||||
pub fn rename_file(db: &Db, batch_id: &str, file_id: i64, new_name: &str) -> Result<()> {
|
||||
if new_name.is_empty() || new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||
return Err(OpError::BadName(new_name.to_string()));
|
||||
}
|
||||
let rows = load_rows(db, &[file_id])?;
|
||||
let row = rows.first().ok_or(OpError::NoFolder(file_id))?;
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
let dst = Path::new(&row.dir).join(new_name);
|
||||
if dst.exists() {
|
||||
return Err(OpError::BadName(format!("이미 존재하는 이름: {new_name}")));
|
||||
}
|
||||
std::fs::rename(&src, &dst).map_err(|e| OpError::Io {
|
||||
path: src.to_string_lossy().into_owned(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let (bid, fid, nn) = (batch_id.to_string(), row.id, new_name.to_string());
|
||||
let (src_s, dst_s) = (src.to_string_lossy().into_owned(), dst.to_string_lossy().into_owned());
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("UPDATE files SET name = ?2 WHERE id = ?1", rusqlite::params![fid, nn])?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
|
||||
VALUES (?1, 'rename', ?2, ?3, ?4)",
|
||||
rusqlite::params![bid, fid, src_s, dst_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 파일들을 OS 휴지통으로 보낸다 (인앱 undo 불가 — OS 휴지통이 복구 경로).
|
||||
pub fn trash_files(
|
||||
db: &Db,
|
||||
batch_id: &str,
|
||||
file_ids: &[i64],
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<OpSummary> {
|
||||
let rows = load_rows(db, file_ids)?;
|
||||
let total = rows.len() as u64;
|
||||
let mut summary = OpSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let src = Path::new(&row.dir).join(&row.name);
|
||||
match trash::delete(&src) {
|
||||
Ok(()) => {
|
||||
let (bid, fid) = (batch_id.to_string(), row.id);
|
||||
let src_s = src.to_string_lossy().into_owned();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1",
|
||||
[fid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, src_path)
|
||||
VALUES (?1, 'trash', ?2, ?3)",
|
||||
rusqlite::params![bid, fid, src_s],
|
||||
)?;
|
||||
tx.commit()
|
||||
})?;
|
||||
summary.done += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {e}");
|
||||
summary.failed += 1;
|
||||
}
|
||||
}
|
||||
on_progress(i as u64 + 1, total);
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UndoBatch {
|
||||
pub batch_id: String,
|
||||
pub ops: i64,
|
||||
pub kinds: String,
|
||||
pub performed_at: i64,
|
||||
pub undoable: bool,
|
||||
}
|
||||
|
||||
/// 최근 배치 목록 (undo UI용).
|
||||
pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
|
||||
Ok(db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
|
||||
sum(CASE WHEN op = 'trash' THEN 1 ELSE 0 END) = 0
|
||||
FROM ops_journal WHERE undone = 0
|
||||
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit], |r| {
|
||||
Ok(UndoBatch {
|
||||
batch_id: r.get(0)?,
|
||||
ops: r.get(1)?,
|
||||
kinds: r.get(2)?,
|
||||
performed_at: r.get(3)?,
|
||||
undoable: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?)
|
||||
}
|
||||
|
||||
/// 배치 undo — 저널 역순 재생. trash 항목은 건너뛴다.
|
||||
pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
|
||||
struct JEntry {
|
||||
id: i64,
|
||||
op: String,
|
||||
file_id: Option<i64>,
|
||||
src_path: Option<String>,
|
||||
dst_path: Option<String>,
|
||||
tag_id: Option<i64>,
|
||||
}
|
||||
let bid = batch_id.to_string();
|
||||
let entries: Vec<JEntry> = db.with_read(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id, op, file_id, src_path, dst_path, tag_id FROM ops_journal
|
||||
WHERE batch_id = ?1 AND undone = 0 ORDER BY id DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([&bid], |r| {
|
||||
Ok(JEntry {
|
||||
id: r.get(0)?,
|
||||
op: r.get(1)?,
|
||||
file_id: r.get(2)?,
|
||||
src_path: r.get(3)?,
|
||||
dst_path: r.get(4)?,
|
||||
tag_id: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
|
||||
let mut summary = OpSummary {
|
||||
batch_id: batch_id.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for e in entries {
|
||||
let undone = match e.op.as_str() {
|
||||
"move" | "rename" => {
|
||||
let (Some(src), Some(dst), Some(fid)) = (&e.src_path, &e.dst_path, e.file_id) else {
|
||||
summary.failed += 1;
|
||||
continue;
|
||||
};
|
||||
// dst → src로 되돌리기
|
||||
match std::fs::rename(dst, src).or_else(|_| {
|
||||
physical_move(Path::new(dst), Path::new(src))
|
||||
}) {
|
||||
Ok(()) => {
|
||||
let src_dir = Path::new(src)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let src_name = Path::new(src)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
db.with_write(move |conn| {
|
||||
// 원래 폴더 행을 찾아 복원
|
||||
let folder_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT fo.id FROM folders fo
|
||||
JOIN files f ON f.id = ?2
|
||||
WHERE fo.path = ?1 AND fo.source_id = f.source_id",
|
||||
rusqlite::params![src_dir, fid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
match folder_id {
|
||||
Some(foid) => {
|
||||
conn.execute(
|
||||
"UPDATE files SET folder_id = ?2, name = ?3 WHERE id = ?1",
|
||||
rusqlite::params![fid, foid, src_name],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
// 폴더 행이 사라졌으면 이름만이라도 복원
|
||||
conn.execute(
|
||||
"UPDATE files SET name = ?2 WHERE id = ?1",
|
||||
rusqlite::params![fid, src_name],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("undo 이동 실패 ({dst} → {src}): {err}");
|
||||
summary.failed += 1;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
"tag" => {
|
||||
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM file_tags WHERE file_id = ?1 AND tag_id = ?2",
|
||||
rusqlite::params![fid, tid],
|
||||
)
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"untag" => {
|
||||
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![fid, tid],
|
||||
)
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// trash는 인앱 undo 불가 (OS 휴지통에서 복원)
|
||||
_ => {
|
||||
summary.skipped += 1;
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if undone {
|
||||
let jid = e.id;
|
||||
db.with_write(move |conn| {
|
||||
conn.execute("UPDATE ops_journal SET undone = 1 WHERE id = ?1", [jid])
|
||||
})?;
|
||||
summary.done += 1;
|
||||
}
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
std::fs::create_dir_all(media.path().join("a")).unwrap();
|
||||
std::fs::create_dir_all(media.path().join("b")).unwrap();
|
||||
std::fs::write(media.path().join("a/사진1.jpg"), b"one").unwrap();
|
||||
std::fs::write(media.path().join("a/사진2.jpg"), b"two").unwrap();
|
||||
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = std::sync::atomic::AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
(dbdir, media, db, sid)
|
||||
}
|
||||
|
||||
fn folder_id_of(db: &Db, suffix: &str) -> i64 {
|
||||
let sfx = suffix.to_string();
|
||||
db.with_read(move |c| {
|
||||
c.query_row(
|
||||
"SELECT id FROM folders WHERE path LIKE '%' || ?1",
|
||||
[&sfx],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn file_id_of(db: &Db, name: &str) -> i64 {
|
||||
let n = name.to_string();
|
||||
db.with_read(move |c| {
|
||||
c.query_row(
|
||||
"SELECT id FROM files WHERE name = ?1 AND deleted_at IS NULL",
|
||||
[&n],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_and_undo_roundtrip() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let dest = folder_id_of(&db, "b");
|
||||
|
||||
let bid = new_batch_id();
|
||||
let s = move_files(&db, &bid, &[fid], dest, |_, _| {}).unwrap();
|
||||
assert_eq!(s.done, 1);
|
||||
assert!(media.path().join("b/사진1.jpg").is_file());
|
||||
assert!(!media.path().join("a/사진1.jpg").exists());
|
||||
|
||||
// DB 반영 + id 보존
|
||||
assert_eq!(file_id_of(&db, "사진1.jpg"), fid);
|
||||
|
||||
// undo → 원위치
|
||||
let u = undo_batch(&db, &bid).unwrap();
|
||||
assert_eq!(u.done, 1);
|
||||
assert!(media.path().join("a/사진1.jpg").is_file());
|
||||
assert!(!media.path().join("b/사진1.jpg").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_collision_auto_renames() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
std::fs::write(media.path().join("b/사진1.jpg"), b"existing").unwrap();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let dest = folder_id_of(&db, "b");
|
||||
|
||||
let s = move_files(&db, &new_batch_id(), &[fid], dest, |_, _| {}).unwrap();
|
||||
assert_eq!(s.done, 1);
|
||||
assert!(media.path().join("b/사진1 (2).jpg").is_file());
|
||||
assert!(media.path().join("b/사진1.jpg").is_file()); // 기존 파일 무사
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_and_undo() {
|
||||
let (_d, media, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진2.jpg");
|
||||
let bid = new_batch_id();
|
||||
|
||||
rename_file(&db, &bid, fid, "새이름.jpg").unwrap();
|
||||
assert!(media.path().join("a/새이름.jpg").is_file());
|
||||
assert_eq!(file_id_of(&db, "새이름.jpg"), fid);
|
||||
|
||||
assert!(rename_file(&db, &bid, fid, "bad/name.jpg").is_err());
|
||||
|
||||
undo_batch(&db, &bid).unwrap();
|
||||
assert!(media.path().join("a/사진2.jpg").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_undo_roundtrip() {
|
||||
let (_d, _m, db, _sid) = setup();
|
||||
let fid = file_id_of(&db, "사진1.jpg");
|
||||
let tag = crate::tags::create_tag(&db, "테스트태그".into(), None).unwrap();
|
||||
|
||||
let bid = new_batch_id();
|
||||
crate::tags::assign(&db, &bid, vec![fid], tag).unwrap();
|
||||
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 1);
|
||||
|
||||
undo_batch(&db, &bid).unwrap();
|
||||
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! 해시 계층: quick_hash(중복 사전필터·이동감지) / full_hash(확정 중복).
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use xxhash_rust::xxh3::Xxh3;
|
||||
|
||||
const CHUNK: usize = 128 * 1024;
|
||||
|
||||
/// xxh3(크기 ++ 앞 128KB ++ 뒤 128KB). 최대 256KB만 읽는다.
|
||||
pub fn quick_hash(path: &Path) -> std::io::Result<u64> {
|
||||
let mut f = File::open(path)?;
|
||||
let len = f.metadata()?.len();
|
||||
|
||||
let mut hasher = Xxh3::new();
|
||||
hasher.update(&len.to_le_bytes());
|
||||
|
||||
let mut buf = vec![0u8; CHUNK];
|
||||
let head = read_up_to(&mut f, &mut buf)?;
|
||||
hasher.update(&buf[..head]);
|
||||
|
||||
if len > (2 * CHUNK) as u64 {
|
||||
f.seek(SeekFrom::End(-(CHUNK as i64)))?;
|
||||
let tail = read_up_to(&mut f, &mut buf)?;
|
||||
hasher.update(&buf[..tail]);
|
||||
}
|
||||
Ok(hasher.digest())
|
||||
}
|
||||
|
||||
/// BLAKE3 전체 해시 (mmap + rayon — NVMe 포화 수준).
|
||||
pub fn full_hash(path: &Path) -> std::io::Result<[u8; 32]> {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update_mmap_rayon(path)?;
|
||||
Ok(*hasher.finalize().as_bytes())
|
||||
}
|
||||
|
||||
fn read_up_to(f: &mut File, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut total = 0;
|
||||
while total < buf.len() {
|
||||
match f.read(&mut buf[total..])? {
|
||||
0 => break,
|
||||
n => total += n,
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quick_hash_distinguishes_middle_unchanged() {
|
||||
// head/tail이 같고 크기도 같으면 quick_hash는 같다 (설계상 허용 — full_hash가 확정)
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.bin");
|
||||
let b = dir.path().join("b.bin");
|
||||
let mut data = vec![0u8; 3 * CHUNK];
|
||||
std::fs::write(&a, &data).unwrap();
|
||||
data[CHUNK + 500] = 0xFF; // 가운데만 변경
|
||||
std::fs::write(&b, &data).unwrap();
|
||||
|
||||
assert_eq!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
|
||||
assert_ne!(full_hash(&a).unwrap(), full_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_hash_detects_head_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.bin");
|
||||
let b = dir.path().join("b.bin");
|
||||
std::fs::write(&a, b"hello world").unwrap();
|
||||
std::fs::write(&b, b"hello wOrld").unwrap();
|
||||
assert_ne!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_files_same_hashes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.bin");
|
||||
let b = dir.path().join("b.bin");
|
||||
let data = vec![7u8; 5 * CHUNK + 13];
|
||||
std::fs::write(&a, &data).unwrap();
|
||||
std::fs::write(&b, &data).unwrap();
|
||||
assert_eq!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
|
||||
assert_eq!(full_hash(&a).unwrap(), full_hash(&b).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! 스캔·해시·썸네일·중복탐지 엔진. Tauri 의존성 없음 — 헤드리스 테스트 가능.
|
||||
|
||||
pub mod dedupe;
|
||||
pub mod ffmpeg;
|
||||
pub mod fileops;
|
||||
pub mod hash;
|
||||
pub mod maintenance;
|
||||
pub mod meta;
|
||||
pub mod model;
|
||||
pub mod phash;
|
||||
pub mod pipeline;
|
||||
pub mod scan;
|
||||
pub mod tags;
|
||||
pub mod thumbq;
|
||||
pub mod thumbs;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 유지보수 — 썸네일 캐시 LRU 퍼지, 소프트삭제 행 하드 퍼지, DB 백업.
|
||||
|
||||
use archive_db::Db;
|
||||
use std::path::Path;
|
||||
|
||||
/// 썸네일 캐시 총량이 상한을 넘으면 오래된 것부터(created_at) 삭제한다.
|
||||
/// 반환: 삭제한 바이트 수.
|
||||
pub fn prune_thumb_cache(db: &Db, thumb_root: &Path, max_bytes: i64) -> u64 {
|
||||
let total: i64 = db
|
||||
.with_read(|conn| {
|
||||
conn.query_row("SELECT COALESCE(SUM(bytes), 0) FROM thumbs", [], |r| r.get(0))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if total <= max_bytes {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut to_free = total - max_bytes;
|
||||
// 오래된 것부터 (파일당 cache_key/size_class 필요 — 경로 재구성)
|
||||
let victims: Vec<(i64, i64, String, i64)> = db
|
||||
.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT file_id, size_class, cache_key, bytes FROM thumbs
|
||||
ORDER BY created_at ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut freed = 0u64;
|
||||
let mut removed: Vec<(i64, i64)> = Vec::new();
|
||||
for (file_id, size_class, key, bytes) in victims {
|
||||
if to_free <= 0 {
|
||||
break;
|
||||
}
|
||||
let path = crate::thumbq::thumb_path(thumb_root, size_class as u8, &key);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
removed.push((file_id, size_class));
|
||||
freed += bytes.max(0) as u64;
|
||||
to_free -= bytes.max(0);
|
||||
}
|
||||
|
||||
if !removed.is_empty() {
|
||||
let _ = db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut del = tx.prepare_cached(
|
||||
"DELETE FROM thumbs WHERE file_id = ?1 AND size_class = ?2",
|
||||
)?;
|
||||
let mut reset = tx.prepare_cached(
|
||||
"UPDATE files SET thumb_state = 0 WHERE id = ?1 AND thumb_state = 1",
|
||||
)?;
|
||||
for (file_id, size_class) in &removed {
|
||||
del.execute(rusqlite::params![file_id, size_class])?;
|
||||
reset.execute([file_id])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
});
|
||||
}
|
||||
freed
|
||||
}
|
||||
|
||||
/// N일보다 오래된 소프트삭제 행을 하드 삭제한다 (FK CASCADE로 관련 데이터 정리).
|
||||
pub fn purge_deleted(db: &Db, older_than_ms: i64) -> u64 {
|
||||
db.with_write(move |conn| {
|
||||
let n = conn.execute(
|
||||
"DELETE FROM files WHERE deleted_at IS NOT NULL AND deleted_at < ?1",
|
||||
[older_than_ms],
|
||||
)?;
|
||||
Ok(n as u64)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
|
||||
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
|
||||
let _ = std::fs::remove_file(backup_path);
|
||||
let path_str = backup_path.to_string_lossy().into_owned();
|
||||
db.with_write(move |conn| {
|
||||
conn.execute("VACUUM INTO ?1", [&path_str])?;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn prune_removes_oldest_over_limit() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let thumbs = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
|
||||
// 썸네일 파일 5개 생성 (파일 I/O는 클로저 밖에서)
|
||||
for i in 1..=5 {
|
||||
let key = format!("{i:032x}");
|
||||
let p = crate::thumbq::thumb_path(thumbs.path(), 0, &key);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(&p, vec![0u8; 1000]).unwrap();
|
||||
}
|
||||
db.with_write(|conn| {
|
||||
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
|
||||
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
|
||||
for i in 1..=5 {
|
||||
conn.execute(
|
||||
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state)
|
||||
VALUES(?1,1,1,?2,'jpg',0,100,0,1)",
|
||||
rusqlite::params![i, format!("f{i}.jpg")],
|
||||
)?;
|
||||
let key = format!("{i:032x}");
|
||||
conn.execute(
|
||||
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes,created_at) VALUES(?1,0,?2,1000,?3)",
|
||||
rusqlite::params![i, key, i], // created_at = i (1이 가장 오래됨)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// 5000바이트 중 2500 상한 → 오래된 것부터 최소 2500 삭제
|
||||
let freed = prune_thumb_cache(&db, thumbs.path(), 2500);
|
||||
assert!(freed >= 2500, "freed={freed}");
|
||||
|
||||
let remaining: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT count(*) FROM thumbs", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert!(remaining < 5);
|
||||
// 가장 오래된 f1 썸네일 파일이 삭제됐는지
|
||||
assert!(!crate::thumbq::thumb_path(thumbs.path(), 0, &format!("{:032x}", 1)).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_old_deleted_rows() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
db.with_write(|conn| {
|
||||
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
|
||||
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
|
||||
conn.execute("INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms,deleted_at) VALUES(1,1,'old.jpg','jpg',0,1,0,1000)", [])?;
|
||||
conn.execute("INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms,deleted_at) VALUES(1,1,'recent.jpg','jpg',0,1,0,999999999999)", [])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
|
||||
let purged = purge_deleted(&db, 100000);
|
||||
assert_eq!(purged, 1);
|
||||
let count: i64 = db.with_read(|c| c.query_row("SELECT count(*) FROM files", [], |r| r.get(0))).unwrap();
|
||||
assert_eq!(count, 1); // recent만 남음
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_creates_valid_db() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
db.with_write(|conn| {
|
||||
conn.execute("INSERT INTO tags(name) VALUES('백업테스트')", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let backup = dbdir.path().join("backup.db");
|
||||
backup_db(&db, &backup).unwrap();
|
||||
assert!(backup.exists());
|
||||
|
||||
// 백업 파일을 열어 데이터 확인
|
||||
let restored = Db::open(&backup).unwrap();
|
||||
let name: String = restored
|
||||
.with_read(|c| c.query_row("SELECT name FROM tags", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(name, "백업테스트");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! 스캔 2단계: 이미지 메타데이터 추출 (치수는 헤더만 읽는 imagesize, EXIF 촬영일시/회전).
|
||||
//! 영상 메타데이터(ffprobe)는 M2에서 추가.
|
||||
|
||||
use archive_db::Db;
|
||||
use rayon::prelude::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
const BATCH: usize = 512;
|
||||
|
||||
struct MetaResult {
|
||||
id: i64,
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
orientation: u32,
|
||||
taken_at: Option<i64>,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
fn exif_of(path: &Path) -> (u32, Option<i64>) {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return (1, None);
|
||||
};
|
||||
let mut reader = std::io::BufReader::new(&file);
|
||||
let Ok(ex) = exif::Reader::new().read_from_container(&mut reader) else {
|
||||
return (1, None);
|
||||
};
|
||||
let orientation = ex
|
||||
.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
.and_then(|f| f.value.get_uint(0))
|
||||
.unwrap_or(1);
|
||||
let taken_at = ex
|
||||
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
|
||||
.or_else(|| ex.get_field(exif::Tag::DateTime, exif::In::PRIMARY))
|
||||
.and_then(|f| match &f.value {
|
||||
exif::Value::Ascii(v) => v.first().map(|b| String::from_utf8_lossy(b).into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.and_then(|s| parse_exif_datetime(&s));
|
||||
(orientation, taken_at)
|
||||
}
|
||||
|
||||
/// "YYYY:MM:DD HH:MM:SS" → epoch ms (로컬 시간대 무시, UTC로 근사 — 정렬 용도)
|
||||
fn parse_exif_datetime(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
if s.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let y: i64 = s.get(0..4)?.parse().ok()?;
|
||||
let mo: i64 = s.get(5..7)?.parse().ok()?;
|
||||
let d: i64 = s.get(8..10)?.parse().ok()?;
|
||||
let h: i64 = s.get(11..13)?.parse().ok()?;
|
||||
let mi: i64 = s.get(14..16)?.parse().ok()?;
|
||||
let sec: i64 = s.get(17..19)?.parse().ok()?;
|
||||
if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
// days since epoch (civil) — Howard Hinnant 알고리즘
|
||||
let y_adj = if mo <= 2 { y - 1 } else { y };
|
||||
let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400;
|
||||
let yoe = y_adj - era * 400;
|
||||
let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
Some(((days * 86400) + h * 3600 + mi * 60 + sec) * 1000)
|
||||
}
|
||||
|
||||
/// meta_state=0인 이미지들의 메타데이터를 추출한다. 처리한 개수를 반환.
|
||||
pub fn extract_image_meta(
|
||||
db: &Arc<Db>,
|
||||
source_id: i64,
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<u64, archive_db::DbError> {
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.meta_state = 0 AND f.kind = 0 AND f.deleted_at IS NULL
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![source_id, BATCH as i64], |r| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok((r.get::<_, i64>(0)?, Path::new(&dir).join(name)))
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let results: Vec<MetaResult> = batch
|
||||
.par_iter()
|
||||
.map(|(id, path)| {
|
||||
let dims = imagesize::size(path).ok();
|
||||
let (orientation, taken_at) = exif_of(path);
|
||||
MetaResult {
|
||||
id: *id,
|
||||
width: dims.map(|d| d.width as u32),
|
||||
height: dims.map(|d| d.height as u32),
|
||||
orientation,
|
||||
taken_at,
|
||||
ok: dims.is_some(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
total += results.len() as u64;
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"UPDATE files SET width=?2, height=?3, orientation=?4, taken_at=?5, meta_state=?6
|
||||
WHERE id = ?1",
|
||||
)?;
|
||||
for r in &results {
|
||||
stmt.execute(rusqlite::params![
|
||||
r.id,
|
||||
r.width,
|
||||
r.height,
|
||||
r.orientation,
|
||||
r.taken_at,
|
||||
if r.ok { 1 } else { 2 },
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// meta_state=0인 영상들의 메타데이터를 ffprobe로 추출한다. 처리한 개수를 반환.
|
||||
pub fn extract_video_meta(
|
||||
db: &Arc<Db>,
|
||||
source_id: i64,
|
||||
cancel: &AtomicBool,
|
||||
tools: &crate::ffmpeg::FfTools,
|
||||
) -> Result<u64, archive_db::DbError> {
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT f.id, fo.path, f.name FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.meta_state = 0 AND f.kind = 1 AND f.deleted_at IS NULL
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![source_id, BATCH as i64], |r| {
|
||||
let dir: String = r.get(1)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok((r.get::<_, i64>(0)?, Path::new(&dir).join(name)))
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let results: Vec<(i64, Option<crate::ffmpeg::MediaInfo>)> = batch
|
||||
.par_iter()
|
||||
.map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok()))
|
||||
.collect();
|
||||
|
||||
total += results.len() as u64;
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"UPDATE files SET width=?2, height=?3, duration_ms=?4, vcodec=?5, acodec=?6,
|
||||
fps=?7, meta_state=?8 WHERE id = ?1",
|
||||
)?;
|
||||
for (id, info) in &results {
|
||||
match info {
|
||||
Some(i) => stmt.execute(rusqlite::params![
|
||||
id, i.width, i.height, i.duration_ms, i.vcodec, i.acodec, i.fps, 1
|
||||
])?,
|
||||
None => stmt.execute(rusqlite::params![
|
||||
id,
|
||||
Option::<u32>::None,
|
||||
Option::<u32>::None,
|
||||
Option::<i64>::None,
|
||||
Option::<String>::None,
|
||||
Option::<String>::None,
|
||||
Option::<f64>::None,
|
||||
2
|
||||
])?,
|
||||
};
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exif_datetime_parsing() {
|
||||
// 2020-01-01 00:00:00 UTC = 1577836800000
|
||||
assert_eq!(parse_exif_datetime("2020:01:01 00:00:00"), Some(1_577_836_800_000));
|
||||
assert_eq!(parse_exif_datetime("잘못된값"), None);
|
||||
assert_eq!(parse_exif_datetime("2020:13:01 00:00:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_dimensions_for_pending_images() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
|
||||
let img = image::RgbImage::from_pixel(320, 200, image::Rgb([1, 2, 3]));
|
||||
img.save(media.path().join("x.png")).unwrap();
|
||||
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write({
|
||||
let root = root.clone();
|
||||
move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
|
||||
let n = extract_image_meta(&db, sid, &cancel).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
let (w, h, state): (u32, u32, i64) = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT width, height, meta_state FROM files WHERE name='x.png'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((w, h, state), (320, 200, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 파일 분류 모델.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum FileKind {
|
||||
Image = 0,
|
||||
Video = 1,
|
||||
Other = 2,
|
||||
}
|
||||
|
||||
const IMAGE_EXTS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "heic", "heif", "tif", "tiff", "jfif",
|
||||
// RAW — v1은 내장 JPEG 프리뷰만 표시
|
||||
"cr2", "cr3", "nef", "arw", "raf", "dng", "orf", "rw2",
|
||||
];
|
||||
|
||||
const VIDEO_EXTS: &[&str] = &[
|
||||
"mp4", "m4v", "mov", "mkv", "avi", "wmv", "webm", "flv", "ts", "m2ts", "mts", "mpg", "mpeg",
|
||||
"3gp", "ogv", "vob",
|
||||
];
|
||||
|
||||
/// 소문자 확장자(점 제외)로 파일 종류를 분류한다.
|
||||
pub fn classify_ext(ext: &str) -> FileKind {
|
||||
if IMAGE_EXTS.contains(&ext) {
|
||||
FileKind::Image
|
||||
} else if VIDEO_EXTS.contains(&ext) {
|
||||
FileKind::Video
|
||||
} else {
|
||||
FileKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// 경로에서 소문자 확장자를 뽑는다.
|
||||
pub fn ext_of(path: &std::path::Path) -> Option<String> {
|
||||
path.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
}
|
||||
|
||||
/// 인덱싱 대상인지 (이미지/영상만 인덱싱)
|
||||
pub fn is_media_ext(ext: &str) -> bool {
|
||||
classify_ext(ext) != FileKind::Other
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify() {
|
||||
assert_eq!(classify_ext("jpg"), FileKind::Image);
|
||||
assert_eq!(classify_ext("heic"), FileKind::Image);
|
||||
assert_eq!(classify_ext("mkv"), FileKind::Video);
|
||||
assert_eq!(classify_ext("txt"), FileKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_extraction() {
|
||||
use std::path::Path;
|
||||
assert_eq!(ext_of(Path::new("C:\\a\\사진.JPG")).as_deref(), Some("jpg"));
|
||||
assert_eq!(ext_of(Path::new("C:\\a\\noext")), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 지각 해시 (perceptual hash) — 64비트 dHash + 해밍 거리 클러스터링.
|
||||
|
||||
use image_hasher::{HashAlg, HasherConfig};
|
||||
use std::path::Path;
|
||||
|
||||
/// 64비트 dHash(gradient) 계산 — 리사이즈/재인코딩에 강건하다.
|
||||
/// 썸네일(256px)에서 계산해도 원본과 거의 동일한 해시가 나온다.
|
||||
pub fn dhash(img: &image::DynamicImage) -> u64 {
|
||||
let hasher = HasherConfig::new()
|
||||
.hash_size(8, 8)
|
||||
.hash_alg(HashAlg::Gradient)
|
||||
.to_hasher();
|
||||
let h = hasher.hash_image(img);
|
||||
let bytes = h.as_bytes();
|
||||
let mut v = 0u64;
|
||||
for (i, b) in bytes.iter().take(8).enumerate() {
|
||||
v |= (*b as u64) << (i * 8);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 파일에서 직접 dHash (썸네일 우선, 없으면 원본).
|
||||
pub fn dhash_file(path: &Path) -> Option<u64> {
|
||||
let img = image::ImageReader::open(path).ok()?.with_guessed_format().ok()?.decode().ok()?;
|
||||
Some(dhash(&img))
|
||||
}
|
||||
|
||||
/// 64비트 해밍 거리 (XOR + popcount — SIMD 자동 벡터화)
|
||||
#[inline]
|
||||
pub fn hamming(a: u64, b: u64) -> u32 {
|
||||
(a ^ b).count_ones()
|
||||
}
|
||||
|
||||
/// (id, phash) 목록에서 임계값 이하 근접쌍을 찾는다.
|
||||
/// 병렬 브루트포스 상삼각 스캔 — 10만개 ≈ 5×10⁹ 비교, 8코어 1–2초.
|
||||
/// 반환: (id_a, id_b, distance) — 항상 id_a < id_b.
|
||||
pub fn find_near_pairs(items: &[(i64, u64)], threshold: u32) -> Vec<(i64, i64, u32)> {
|
||||
use rayon::prelude::*;
|
||||
let n = items.len();
|
||||
if n < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
(0..n)
|
||||
.into_par_iter()
|
||||
.flat_map_iter(|i| {
|
||||
let (id_a, ha) = items[i];
|
||||
let mut local = Vec::new();
|
||||
for j in (i + 1)..n {
|
||||
let (id_b, hb) = items[j];
|
||||
let d = hamming(ha, hb);
|
||||
if d <= threshold {
|
||||
let (lo, hi) = if id_a < id_b { (id_a, id_b) } else { (id_b, id_a) };
|
||||
local.push((lo, hi, d));
|
||||
}
|
||||
}
|
||||
local
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Union-Find로 근접쌍을 그룹으로 묶는다.
|
||||
pub fn cluster(ids: &[i64], pairs: &[(i64, i64, u32)]) -> Vec<Vec<i64>> {
|
||||
use std::collections::HashMap;
|
||||
let mut index: HashMap<i64, usize> = HashMap::with_capacity(ids.len());
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
index.insert(*id, i);
|
||||
}
|
||||
let mut parent: Vec<usize> = (0..ids.len()).collect();
|
||||
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
while parent[x] != x {
|
||||
parent[x] = parent[parent[x]];
|
||||
x = parent[x];
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
for (a, b, _) in pairs {
|
||||
let (Some(&ia), Some(&ib)) = (index.get(a), index.get(b)) else {
|
||||
continue;
|
||||
};
|
||||
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
|
||||
if ra != rb {
|
||||
parent[ra] = rb;
|
||||
}
|
||||
}
|
||||
|
||||
let mut groups: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let root = find(&mut parent, i);
|
||||
groups.entry(root).or_default().push(*id);
|
||||
}
|
||||
groups.into_values().filter(|g| g.len() >= 2).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hamming_distance() {
|
||||
assert_eq!(hamming(0b1010, 0b1010), 0);
|
||||
assert_eq!(hamming(0b1010, 0b1011), 1);
|
||||
assert_eq!(hamming(0, u64::MAX), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_pairs_and_clustering() {
|
||||
// 0,1은 가깝고(거리1), 2는 멀다
|
||||
let items = vec![
|
||||
(10i64, 0b0000u64),
|
||||
(20i64, 0b0001u64),
|
||||
(30i64, 0b1111_1111_1111u64),
|
||||
];
|
||||
let pairs = find_near_pairs(&items, 5);
|
||||
assert_eq!(pairs.len(), 1);
|
||||
assert_eq!(pairs[0], (10, 20, 1));
|
||||
|
||||
let ids: Vec<i64> = items.iter().map(|(id, _)| *id).collect();
|
||||
let groups = cluster(&ids, &pairs);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitive_clustering() {
|
||||
// A-B 가깝고 B-C 가까우면 A,B,C 한 그룹
|
||||
let pairs = vec![(1i64, 2i64, 2u32), (2, 3, 3)];
|
||||
let ids = vec![1i64, 2, 3, 4];
|
||||
let mut groups = cluster(&ids, &pairs);
|
||||
groups.sort_by_key(|g| g.len());
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_image_has_close_hash() {
|
||||
// 같은 이미지의 원본과 축소본은 dHash가 거의 같아야 한다
|
||||
let img = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(400, 300, |x, y| {
|
||||
image::Rgb([((x * 255) / 400) as u8, ((y * 255) / 300) as u8, 128])
|
||||
}));
|
||||
let small = img.resize_exact(120, 90, image::imageops::FilterType::Lanczos3);
|
||||
let h1 = dhash(&img);
|
||||
let h2 = dhash(&small);
|
||||
assert!(hamming(h1, h2) <= 5, "리사이즈 후 해밍 거리 {}", hamming(h1, h2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
//! 스캔 파이프라인 — 열거(jwalk) → 스냅샷 비교 → 배치 upsert → 이동 감지 → 소프트 삭제.
|
||||
//!
|
||||
//! 핵심 성능 설계: 스캔 시작 시 소스의 활성 행 전체를 (folder_id, name) 키의
|
||||
//! 메모리 스냅샷으로 로드하고, size+mtime이 같은 파일은 **DB 쓰기를 완전히
|
||||
//! 생략**한다. 무변경 재스캔의 비용은 디렉터리 열거 + 스냅샷 로드뿐이다.
|
||||
|
||||
use crate::model::{classify_ext, ext_of};
|
||||
use crate::scan::{enumerate_items, RawEntry, WalkItem};
|
||||
use archive_db::Db;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ScanError {
|
||||
#[error("DB 오류: {0}")]
|
||||
Db(#[from] archive_db::DbError),
|
||||
#[error("취소됨")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct ScanStats {
|
||||
pub seen: u64,
|
||||
pub added: u64,
|
||||
pub changed: u64,
|
||||
pub removed: u64,
|
||||
pub moved: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScanProgress {
|
||||
pub scan_id: i64,
|
||||
pub phase: String, // enumerate | metadata | done
|
||||
pub seen: u64,
|
||||
pub current_dir: String,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
/// 스냅샷/이동감지용 기존 행 정보
|
||||
#[derive(Clone)]
|
||||
struct SnapRow {
|
||||
id: i64,
|
||||
size: i64,
|
||||
mtime_ms: i64,
|
||||
name: String,
|
||||
quick_hash: Option<i64>,
|
||||
}
|
||||
|
||||
const BATCH: usize = 1000;
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 폴더 행을 보장하고 id를 돌려준다 (조상 폴더 포함, 캐시 사용).
|
||||
fn ensure_folder(
|
||||
db: &Db,
|
||||
cache: &mut HashMap<PathBuf, i64>,
|
||||
source_id: i64,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
) -> Result<i64, ScanError> {
|
||||
if let Some(id) = cache.get(dir) {
|
||||
return Ok(*id);
|
||||
}
|
||||
let parent_id = if dir == root {
|
||||
None
|
||||
} else {
|
||||
let parent = dir.parent().unwrap_or(root);
|
||||
Some(ensure_folder(db, cache, source_id, root, parent)?)
|
||||
};
|
||||
|
||||
let path_str = dir.to_string_lossy().into_owned();
|
||||
let name = dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| dir.to_string_lossy().into_owned());
|
||||
|
||||
let id = 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 UPDATE SET name = excluded.name",
|
||||
rusqlite::params![source_id, parent_id, path_str, name],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT id FROM folders WHERE source_id = ?1 AND path = ?2",
|
||||
rusqlite::params![source_id, path_str],
|
||||
|r| r.get(0),
|
||||
)
|
||||
})?;
|
||||
cache.insert(dir.to_path_buf(), id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 소스의 활성 파일 행을 (folder_id, name) 키로 로드한다.
|
||||
fn load_snapshot(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
) -> Result<HashMap<(i64, String), SnapRow>, ScanError> {
|
||||
let rows: Vec<(i64, SnapRow)> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT folder_id, id, size, mtime_ms, name, quick_hash
|
||||
FROM files WHERE source_id = ?1 AND deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([source_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
SnapRow {
|
||||
id: r.get(1)?,
|
||||
size: r.get(2)?,
|
||||
mtime_ms: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
quick_hash: r.get(5)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(folder_id, row)| ((folder_id, row.name.clone()), row))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn flush_batch(db: &Db, scan_id: i64, batch: Vec<(i64, RawEntry)>) -> Result<(), ScanError> {
|
||||
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)
|
||||
SELECT fo.source_id, ?1, ?2, ?3, ?4, ?5, ?6, ?7 FROM folders fo WHERE fo.id = ?1
|
||||
ON CONFLICT(folder_id, name) DO UPDATE SET
|
||||
scan_id = excluded.scan_id,
|
||||
deleted_at = NULL,
|
||||
meta_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.meta_state ELSE 0 END,
|
||||
thumb_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.thumb_state ELSE 0 END,
|
||||
quick_hash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.quick_hash ELSE NULL END,
|
||||
full_hash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.full_hash ELSE NULL END,
|
||||
phash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.phash ELSE NULL END,
|
||||
vhash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
|
||||
THEN files.vhash ELSE NULL END,
|
||||
size = excluded.size,
|
||||
mtime_ms = excluded.mtime_ms",
|
||||
)?;
|
||||
for (folder_id, e) in &batch {
|
||||
let name = e
|
||||
.path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let ext = ext_of(&e.path).unwrap_or_default();
|
||||
let kind = classify_ext(&ext) as u8;
|
||||
stmt.execute(rusqlite::params![
|
||||
folder_id,
|
||||
name,
|
||||
ext,
|
||||
kind,
|
||||
e.size as i64,
|
||||
e.mtime_ms,
|
||||
scan_id
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 이동 감지: 사라진 행(missing)과 이번 스캔에서 새로 추가된 행을
|
||||
/// ① (size, name) ② (size, quick_hash 저장분) 기준으로 매칭해
|
||||
/// 행 id를 보존한 채 경로만 갱신한다 (태그·썸네일 보존).
|
||||
/// 반환: (moved, 소프트삭제 대상 id 목록)
|
||||
fn detect_moves(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
scan_id: i64,
|
||||
scan_start: i64,
|
||||
missing: Vec<SnapRow>,
|
||||
) -> Result<(u64, Vec<i64>), ScanError> {
|
||||
if missing.is_empty() {
|
||||
return Ok((0, Vec::new()));
|
||||
}
|
||||
|
||||
struct NewRow {
|
||||
id: i64,
|
||||
size: i64,
|
||||
name: String,
|
||||
folder_id: i64,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
let added: Vec<NewRow> = db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.id, f.size, f.name, f.folder_id, fo.path FROM files f
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.source_id = ?1 AND f.scan_id = ?2 AND f.added_at >= ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(rusqlite::params![source_id, scan_id, scan_start], |r| {
|
||||
let dir: String = r.get(4)?;
|
||||
let name: String = r.get(2)?;
|
||||
Ok(NewRow {
|
||||
id: r.get(0)?,
|
||||
size: r.get(1)?,
|
||||
name: name.clone(),
|
||||
folder_id: r.get(3)?,
|
||||
path: Path::new(&dir).join(&name),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})?;
|
||||
|
||||
if added.is_empty() {
|
||||
return Ok((0, missing.into_iter().map(|m| m.id).collect()));
|
||||
}
|
||||
|
||||
let mut by_size_name: HashMap<(i64, &str), &SnapRow> = HashMap::new();
|
||||
let mut by_size_hash: HashMap<(i64, i64), &SnapRow> = HashMap::new();
|
||||
for m in &missing {
|
||||
by_size_name.entry((m.size, m.name.as_str())).or_insert(m);
|
||||
if let Some(qh) = m.quick_hash {
|
||||
by_size_hash.entry((m.size, qh)).or_insert(m);
|
||||
}
|
||||
}
|
||||
|
||||
let mut moves: Vec<(i64, i64, String)> = Vec::new(); // (old_id, new_folder_id, new_name)
|
||||
let mut new_ids_to_delete: Vec<i64> = Vec::new();
|
||||
let mut matched_old: std::collections::HashSet<i64> = Default::default();
|
||||
|
||||
for n in &added {
|
||||
let matched = by_size_name
|
||||
.get(&(n.size, n.name.as_str()))
|
||||
.copied()
|
||||
.filter(|m| !matched_old.contains(&m.id))
|
||||
.or_else(|| {
|
||||
if by_size_hash.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let qh = crate::hash::quick_hash(&n.path).ok()? as i64;
|
||||
by_size_hash
|
||||
.get(&(n.size, qh))
|
||||
.copied()
|
||||
.filter(|m| !matched_old.contains(&m.id))
|
||||
});
|
||||
|
||||
if let Some(m) = matched {
|
||||
matched_old.insert(m.id);
|
||||
new_ids_to_delete.push(n.id);
|
||||
moves.push((m.id, n.folder_id, n.name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let moved = moves.len() as u64;
|
||||
if moved > 0 {
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut del = tx.prepare_cached("DELETE FROM files WHERE id = ?1")?;
|
||||
for id in &new_ids_to_delete {
|
||||
del.execute([id])?;
|
||||
}
|
||||
let mut upd = tx.prepare_cached(
|
||||
"UPDATE files SET folder_id = ?2, name = ?3, scan_id = ?4 WHERE id = ?1",
|
||||
)?;
|
||||
for (old_id, folder_id, name) in &moves {
|
||||
upd.execute(rusqlite::params![old_id, folder_id, name, scan_id])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
}
|
||||
|
||||
let to_delete = missing
|
||||
.into_iter()
|
||||
.filter(|m| !matched_old.contains(&m.id))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
Ok((moved, to_delete))
|
||||
}
|
||||
|
||||
fn prune_stale_folders(
|
||||
db: &Db,
|
||||
source_id: i64,
|
||||
seen: &HashMap<PathBuf, i64>,
|
||||
) -> Result<(), ScanError> {
|
||||
let seen_ids: std::collections::HashSet<i64> = seen.values().copied().collect();
|
||||
let all: Vec<(i64, String)> = db.with_read(|conn| {
|
||||
let mut stmt =
|
||||
conn.prepare_cached("SELECT id, path FROM folders WHERE source_id = ?1")?;
|
||||
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect()
|
||||
})?;
|
||||
|
||||
let mut stale: Vec<(i64, String)> = all
|
||||
.into_iter()
|
||||
.filter(|(id, _)| !seen_ids.contains(id))
|
||||
.collect();
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// 자식(더 긴 경로) 먼저 삭제 — parent_id FK 위반 방지
|
||||
stale.sort_by_key(|(_, p)| std::cmp::Reverse(p.len()));
|
||||
|
||||
db.with_write(move |conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"DELETE FROM folders WHERE id = ?1
|
||||
AND NOT EXISTS (SELECT 1 FROM files WHERE folder_id = ?1)
|
||||
AND NOT EXISTS (SELECT 1 FROM folders c WHERE c.parent_id = ?1)",
|
||||
)?;
|
||||
for (id, _) in &stale {
|
||||
let _ = stmt.execute([id]);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn soft_delete(db: &Db, ids: Vec<i64>) -> Result<u64, ScanError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = ids.len() as u64;
|
||||
let ts = now_ms();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached("UPDATE files SET deleted_at = ?2 WHERE id = ?1")?;
|
||||
for id in &ids {
|
||||
stmt.execute(rusqlite::params![id, ts])?;
|
||||
}
|
||||
}
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 소스 하나를 스캔한다. 블로킹 — 전용 스레드에서 호출할 것.
|
||||
pub fn scan_source(
|
||||
db: &Arc<Db>,
|
||||
source_id: i64,
|
||||
root: &Path,
|
||||
cancel: &AtomicBool,
|
||||
mut on_progress: impl FnMut(ScanProgress),
|
||||
) -> Result<ScanStats, ScanError> {
|
||||
let scan_start = now_ms();
|
||||
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, scan_start],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})?;
|
||||
|
||||
let mut stats = ScanStats::default();
|
||||
let mut snapshot = load_snapshot(db, source_id)?;
|
||||
let mut folder_cache: HashMap<PathBuf, i64> = HashMap::new();
|
||||
let mut batch: Vec<(i64, RawEntry)> = Vec::with_capacity(BATCH);
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
for item in enumerate_items(root) {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE scans SET status = 2, finished_at = ?2 WHERE id = ?1",
|
||||
rusqlite::params![scan_id, now_ms()],
|
||||
)
|
||||
})?;
|
||||
return Err(ScanError::Cancelled);
|
||||
}
|
||||
|
||||
let entry = match item {
|
||||
WalkItem::Dir(dir) => {
|
||||
// 빈 폴더도 트리에 포함 (이동 대상으로 쓰인다)
|
||||
ensure_folder(db, &mut folder_cache, source_id, root, &dir)?;
|
||||
continue;
|
||||
}
|
||||
WalkItem::File(e) => e,
|
||||
};
|
||||
|
||||
let dir = entry.path.parent().unwrap_or(root).to_path_buf();
|
||||
let folder_id = ensure_folder(db, &mut folder_cache, source_id, root, &dir)?;
|
||||
stats.seen += 1;
|
||||
|
||||
let name = entry
|
||||
.path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 스냅샷 비교 — 무변경이면 DB 쓰기 생략 (remove로 '발견됨' 표시)
|
||||
match snapshot.remove(&(folder_id, name)) {
|
||||
Some(prev) if prev.size == entry.size as i64 && prev.mtime_ms == entry.mtime_ms => {
|
||||
// unchanged
|
||||
}
|
||||
Some(_) => {
|
||||
stats.changed += 1;
|
||||
batch.push((folder_id, entry));
|
||||
}
|
||||
None => {
|
||||
stats.added += 1;
|
||||
batch.push((folder_id, entry));
|
||||
}
|
||||
}
|
||||
|
||||
if batch.len() >= BATCH {
|
||||
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
|
||||
}
|
||||
if last_emit.elapsed().as_millis() >= 100 {
|
||||
last_emit = Instant::now();
|
||||
on_progress(ScanProgress {
|
||||
scan_id,
|
||||
phase: "enumerate".into(),
|
||||
seen: stats.seen,
|
||||
current_dir: dir.to_string_lossy().into_owned(),
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
|
||||
}
|
||||
|
||||
// 스냅샷에 남은 행 = 디스크에서 사라진 파일 → 이동 감지 후 소프트 삭제
|
||||
let missing: Vec<SnapRow> = snapshot.into_values().collect();
|
||||
let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
|
||||
stats.moved = moved;
|
||||
stats.removed = soft_delete(db, to_delete)?;
|
||||
|
||||
// 디스크에서 사라진 폴더 행 정리 (파일 참조가 없는 것만, 자식 먼저)
|
||||
prune_stale_folders(db, source_id, &folder_cache)?;
|
||||
|
||||
let stats_json = serde_json::to_string(&stats).unwrap_or_default();
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE scans SET status = 1, finished_at = ?2, stats = ?3 WHERE id = ?1",
|
||||
rusqlite::params![scan_id, now_ms(), stats_json],
|
||||
)
|
||||
})?;
|
||||
|
||||
on_progress(ScanProgress {
|
||||
scan_id,
|
||||
phase: "done".into(),
|
||||
seen: stats.seen,
|
||||
current_dir: String::new(),
|
||||
done: true,
|
||||
});
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let source_id = db
|
||||
.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO sources(kind, name, root) VALUES ('local', '테스트', ?1)",
|
||||
[&root],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.unwrap();
|
||||
(dbdir, media, db, source_id)
|
||||
}
|
||||
|
||||
fn count_active(db: &Db) -> i64 {
|
||||
db.with_read(|c| {
|
||||
c.query_row("SELECT count(*) FROM files WHERE deleted_at IS NULL", [], |r| r.get(0))
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_scan_then_incremental() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join("여행/제주")).unwrap();
|
||||
std::fs::write(root.join("a.jpg"), b"aaa").unwrap();
|
||||
std::fs::write(root.join("여행/b.png"), b"bbbb").unwrap();
|
||||
std::fs::write(root.join("여행/제주/c.mp4"), b"ccccc").unwrap();
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.seen, 3);
|
||||
assert_eq!(stats.added, 3);
|
||||
assert_eq!(count_active(&db), 3);
|
||||
|
||||
// 변경 없는 재스캔: added/changed/removed 전부 0
|
||||
let stats2 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats2.seen, 3);
|
||||
assert_eq!(stats2.added, 0);
|
||||
assert_eq!(stats2.changed, 0);
|
||||
assert_eq!(stats2.removed, 0);
|
||||
assert_eq!(count_active(&db), 3);
|
||||
|
||||
// 내용 변경(크기 변화) → changed
|
||||
std::fs::write(root.join("a.jpg"), b"aaaa-longer").unwrap();
|
||||
let stats3 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats3.changed, 1);
|
||||
assert_eq!(stats3.added, 0);
|
||||
|
||||
// 파일 삭제 후 재스캔 → 소프트 삭제
|
||||
std::fs::remove_file(root.join("a.jpg")).unwrap();
|
||||
let stats4 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats4.removed, 1);
|
||||
assert_eq!(count_active(&db), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_detection_preserves_row_id() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join("old")).unwrap();
|
||||
std::fs::create_dir_all(root.join("new")).unwrap();
|
||||
std::fs::write(root.join("old/사진.jpg"), b"unique-content-123").unwrap();
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
let old_id: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT id FROM files WHERE name='사진.jpg'", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
|
||||
std::fs::rename(root.join("old/사진.jpg"), root.join("new/사진.jpg")).unwrap();
|
||||
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.moved, 1);
|
||||
assert_eq!(stats.removed, 0);
|
||||
|
||||
let (new_id, folder_path): (i64, String) = db
|
||||
.with_read(|c| {
|
||||
c.query_row(
|
||||
"SELECT f.id, fo.path FROM files f JOIN folders fo ON fo.id=f.folder_id
|
||||
WHERE f.name='사진.jpg' AND f.deleted_at IS NULL",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(new_id, old_id, "행 id가 보존되어야 태그/썸네일이 유지된다");
|
||||
assert!(folder_path.ends_with("new"));
|
||||
assert_eq!(count_active(&db), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_deleted_file_resurrects_on_return() {
|
||||
let (_d, media, db, sid) = setup();
|
||||
let root = media.path().to_path_buf();
|
||||
std::fs::write(root.join("x.jpg"), b"data").unwrap();
|
||||
let cancel = AtomicBool::new(false);
|
||||
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
std::fs::remove_file(root.join("x.jpg")).unwrap();
|
||||
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(count_active(&db), 0);
|
||||
|
||||
// 같은 이름 파일이 돌아옴 → deleted_at 해제
|
||||
std::fs::write(root.join("x.jpg"), b"data").unwrap();
|
||||
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
|
||||
assert_eq!(stats.added, 1);
|
||||
assert_eq!(count_active(&db), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 스캔 파이프라인 1단계: 병렬 디렉터리 열거.
|
||||
//! (증분 조정·이동감지 로직은 M1에서 이 위에 얹는다)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawEntry {
|
||||
pub path: PathBuf,
|
||||
pub size: u64,
|
||||
pub mtime_ms: i64,
|
||||
}
|
||||
|
||||
fn mtime_ms(meta: &std::fs::Metadata) -> i64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 워크 항목: 디렉터리(빈 폴더 포함 — 이동 대상용) 또는 미디어 파일.
|
||||
pub enum WalkItem {
|
||||
Dir(PathBuf),
|
||||
File(RawEntry),
|
||||
}
|
||||
|
||||
/// jwalk 기반 병렬 열거 — 디렉터리와 미디어 파일을 모두 반환한다.
|
||||
pub fn enumerate_items(root: &Path) -> impl Iterator<Item = WalkItem> {
|
||||
jwalk::WalkDir::new(root)
|
||||
.skip_hidden(true)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
if e.file_type().is_dir() {
|
||||
return Some(WalkItem::Dir(e.path()));
|
||||
}
|
||||
if !e.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let path = e.path();
|
||||
let ext = crate::model::ext_of(&path)?;
|
||||
if !crate::model::is_media_ext(&ext) {
|
||||
return None;
|
||||
}
|
||||
let meta = e.metadata().ok()?;
|
||||
Some(WalkItem::File(RawEntry {
|
||||
path,
|
||||
size: meta.len(),
|
||||
mtime_ms: mtime_ms(&meta),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// 미디어 파일만 열거 (테스트/유틸용).
|
||||
pub fn enumerate(root: &Path) -> impl Iterator<Item = RawEntry> {
|
||||
enumerate_items(root).filter_map(|i| match i {
|
||||
WalkItem::File(e) => Some(e),
|
||||
WalkItem::Dir(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enumerates_media_files_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("sub/깊은폴더")).unwrap();
|
||||
std::fs::write(dir.path().join("a.jpg"), b"1").unwrap();
|
||||
std::fs::write(dir.path().join("sub/b.mkv"), b"22").unwrap();
|
||||
std::fs::write(dir.path().join("sub/깊은폴더/한글 이름.mp4"), b"333").unwrap();
|
||||
std::fs::write(dir.path().join("ignore.txt"), b"x").unwrap();
|
||||
|
||||
let mut entries: Vec<_> = enumerate(dir.path()).collect();
|
||||
entries.sort_by_key(|e| e.size);
|
||||
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(entries[0].size, 1);
|
||||
assert!(entries[2].path.ends_with("한글 이름.mp4"));
|
||||
assert!(entries.iter().all(|e| e.mtime_ms > 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 태그 CRUD + 파일-태그 할당. 모든 변경은 ops_journal에 기록되어 undo 가능하다.
|
||||
|
||||
use archive_db::Db;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, archive_db::DbError>;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tag {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
pub file_count: i64,
|
||||
}
|
||||
|
||||
pub fn list_tags(db: &Db) -> Result<Vec<Tag>> {
|
||||
db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT t.id, t.name, t.color,
|
||||
(SELECT count(*) FROM file_tags ft
|
||||
JOIN files f ON f.id = ft.file_id
|
||||
WHERE ft.tag_id = t.id AND f.deleted_at IS NULL)
|
||||
FROM tags t ORDER BY t.sort_order, t.name",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(Tag {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
file_count: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_tag(db: &Db, name: String, color: Option<String>) -> Result<i64> {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO tags(name, color) VALUES (?1, ?2)
|
||||
ON CONFLICT(name) DO UPDATE SET color = COALESCE(excluded.color, tags.color)",
|
||||
rusqlite::params![name, color],
|
||||
)?;
|
||||
conn.query_row("SELECT id FROM tags WHERE name = ?1", [&name], |r| r.get(0))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rename_tag(db: &Db, tag_id: i64, new_name: String) -> Result<()> {
|
||||
db.with_write(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE tags SET name = ?2 WHERE id = ?1",
|
||||
rusqlite::params![tag_id, new_name],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_tag(db: &Db, tag_id: i64) -> Result<()> {
|
||||
db.with_write(move |conn| {
|
||||
// file_tags는 FK CASCADE로 정리된다
|
||||
conn.execute("DELETE FROM tags WHERE id = ?1", [tag_id])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// 파일들에 태그 할당. 반환: 새로 할당된 개수 (이미 있던 것 제외).
|
||||
pub fn assign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Result<u64> {
|
||||
let batch_id = batch_id.to_string();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut added = 0u64;
|
||||
{
|
||||
let mut ins = tx.prepare_cached(
|
||||
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
|
||||
)?;
|
||||
let mut journal = tx.prepare_cached(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'tag', ?2, ?3)",
|
||||
)?;
|
||||
for fid in &file_ids {
|
||||
if ins.execute(rusqlite::params![fid, tag_id])? > 0 {
|
||||
journal.execute(rusqlite::params![batch_id, fid, tag_id])?;
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(added)
|
||||
})
|
||||
}
|
||||
|
||||
/// 파일들에서 태그 제거.
|
||||
pub fn unassign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Result<u64> {
|
||||
let batch_id = batch_id.to_string();
|
||||
db.with_write(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let mut removed = 0u64;
|
||||
{
|
||||
let mut del = tx.prepare_cached(
|
||||
"DELETE FROM file_tags WHERE file_id = ?1 AND tag_id = ?2",
|
||||
)?;
|
||||
let mut journal = tx.prepare_cached(
|
||||
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'untag', ?2, ?3)",
|
||||
)?;
|
||||
for fid in &file_ids {
|
||||
if del.execute(rusqlite::params![fid, tag_id])? > 0 {
|
||||
journal.execute(rusqlite::params![batch_id, fid, tag_id])?;
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(removed)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn file_tags(db: &Db, file_id: i64) -> Result<Vec<Tag>> {
|
||||
db.with_read(|conn| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT t.id, t.name, t.color, 0 FROM tags t
|
||||
JOIN file_tags ft ON ft.tag_id = t.id WHERE ft.file_id = ?1
|
||||
ORDER BY t.name",
|
||||
)?;
|
||||
let rows = stmt.query_map([file_id], |r| {
|
||||
Ok(Tag {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
file_count: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// 태그 전체를 JSON으로 내보낸다 (재해 복구 보험 — 계획서 §7).
|
||||
/// 형식: [{ tag, color, files: ["<절대경로>", ...] }, ...]
|
||||
pub fn export_json(db: &Db) -> Result<String> {
|
||||
#[derive(serde::Serialize)]
|
||||
struct TagExport {
|
||||
tag: String,
|
||||
color: Option<String>,
|
||||
files: Vec<String>,
|
||||
}
|
||||
let exports: Vec<TagExport> = db.with_read(|conn| {
|
||||
let mut tag_stmt = conn.prepare("SELECT id, name, color FROM tags ORDER BY name")?;
|
||||
let tags: Vec<(i64, String, Option<String>)> = tag_stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
|
||||
let mut file_stmt = conn.prepare(
|
||||
"SELECT fo.path, f.name FROM file_tags ft
|
||||
JOIN files f ON f.id = ft.file_id
|
||||
JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL",
|
||||
)?;
|
||||
let mut out = Vec::with_capacity(tags.len());
|
||||
for (id, name, color) in tags {
|
||||
let files: Vec<String> = file_stmt
|
||||
.query_map([id], |r| {
|
||||
let dir: String = r.get(0)?;
|
||||
let name: String = r.get(1)?;
|
||||
Ok(format!("{dir}{}{name}", std::path::MAIN_SEPARATOR))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
out.push(TagExport { tag: name, color, files });
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(serde_json::to_string_pretty(&exports).unwrap_or_else(|_| "[]".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn setup() -> (tempfile::TempDir, Arc<archive_db::Db>, Vec<i64>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(archive_db::Db::open(&dir.path().join("t.db")).unwrap());
|
||||
let ids = db
|
||||
.with_write(|conn| {
|
||||
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
|
||||
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms)
|
||||
VALUES(1,1,?1,'jpg',0,100,0)",
|
||||
[format!("f{i}.jpg")],
|
||||
)?;
|
||||
ids.push(conn.last_insert_rowid());
|
||||
}
|
||||
Ok(ids)
|
||||
})
|
||||
.unwrap();
|
||||
(dir, db, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_crud_and_assignment() {
|
||||
let (_d, db, ids) = setup();
|
||||
let tag = create_tag(&db, "가족".into(), Some("#ff0000".into())).unwrap();
|
||||
|
||||
let added = assign(&db, "b1", ids.clone(), tag).unwrap();
|
||||
assert_eq!(added, 3);
|
||||
// 중복 할당은 0
|
||||
assert_eq!(assign(&db, "b2", ids.clone(), tag).unwrap(), 0);
|
||||
|
||||
let tags = list_tags(&db).unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].file_count, 3);
|
||||
|
||||
let ft = file_tags(&db, ids[0]).unwrap();
|
||||
assert_eq!(ft[0].name, "가족");
|
||||
|
||||
let removed = unassign(&db, "b3", vec![ids[0]], tag).unwrap();
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(list_tags(&db).unwrap()[0].file_count, 2);
|
||||
|
||||
// 저널 기록 확인
|
||||
let journal_count: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT count(*) FROM ops_journal", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(journal_count, 4); // tag 3 + untag 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_contains_paths() {
|
||||
let (_d, db, ids) = setup();
|
||||
let tag = create_tag(&db, "여행".into(), None).unwrap();
|
||||
assign(&db, "b1", vec![ids[0]], tag).unwrap();
|
||||
|
||||
let json = export_json(&db).unwrap();
|
||||
assert!(json.contains("여행"));
|
||||
assert!(json.contains("f0.jpg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! 썸네일 생성 큐 — 2밴드 우선순위(뷰포트 hi / 백그라운드 lo) 워커 풀.
|
||||
//! 완료 id는 150ms 배치로 콜백에 전달된다 (프론트 셀 갱신 이벤트용).
|
||||
|
||||
use crate::thumbs;
|
||||
use archive_db::Db;
|
||||
use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct ThumbQueue {
|
||||
hi_tx: Sender<i64>,
|
||||
lo_tx: Sender<i64>,
|
||||
}
|
||||
|
||||
struct Ctx {
|
||||
db: Arc<Db>,
|
||||
thumb_root: PathBuf,
|
||||
tools: Option<crate::ffmpeg::FfTools>,
|
||||
in_flight: Mutex<HashSet<i64>>,
|
||||
done_tx: Sender<i64>,
|
||||
}
|
||||
|
||||
impl ThumbQueue {
|
||||
/// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다.
|
||||
/// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리).
|
||||
pub fn start(
|
||||
db: Arc<Db>,
|
||||
thumb_root: PathBuf,
|
||||
tools: Option<crate::ffmpeg::FfTools>,
|
||||
workers: usize,
|
||||
on_done: impl Fn(Vec<i64>) + Send + 'static,
|
||||
) -> ThumbQueue {
|
||||
let (hi_tx, hi_rx) = unbounded::<i64>();
|
||||
let (lo_tx, lo_rx) = unbounded::<i64>();
|
||||
let (done_tx, done_rx) = unbounded::<i64>();
|
||||
|
||||
let ctx = Arc::new(Ctx {
|
||||
db,
|
||||
thumb_root,
|
||||
tools,
|
||||
in_flight: Mutex::new(HashSet::new()),
|
||||
done_tx,
|
||||
});
|
||||
|
||||
for i in 0..workers.max(1) {
|
||||
let ctx = ctx.clone();
|
||||
let hi_rx = hi_rx.clone();
|
||||
let lo_rx = lo_rx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("thumb-{i}"))
|
||||
.spawn(move || worker_loop(ctx, hi_rx, lo_rx))
|
||||
.expect("썸네일 워커 생성 실패");
|
||||
}
|
||||
|
||||
// 완료 배치 이미터
|
||||
std::thread::Builder::new()
|
||||
.name("thumb-emit".into())
|
||||
.spawn(move || {
|
||||
let mut batch: Vec<i64> = Vec::new();
|
||||
loop {
|
||||
match done_rx.recv_timeout(Duration::from_millis(150)) {
|
||||
Ok(id) => {
|
||||
batch.push(id);
|
||||
if batch.len() >= 256 {
|
||||
on_done(std::mem::take(&mut batch));
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if !batch.is_empty() {
|
||||
on_done(std::mem::take(&mut batch));
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
if !batch.is_empty() {
|
||||
on_done(batch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("썸네일 이미터 생성 실패");
|
||||
|
||||
ThumbQueue { hi_tx, lo_tx }
|
||||
}
|
||||
|
||||
/// 뷰포트 우선 생성 요청 (프론트가 스크롤 시 호출).
|
||||
pub fn prioritize(&self, ids: &[i64]) {
|
||||
for id in ids {
|
||||
let _ = self.hi_tx.send(*id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 대기 중인 이미지 전체를 백그라운드 밴드에 넣는다.
|
||||
pub fn enqueue_pending(&self, db: &Db, source_id: Option<i64>) {
|
||||
let ids: Vec<i64> = db
|
||||
.with_read(|conn| match source_id {
|
||||
Some(sid) => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL AND source_id=?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([sid], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
None => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for id in ids {
|
||||
let _ = self.lo_tx.send(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_key(source_id: i64, path: &str, size: i64, mtime_ms: i64) -> String {
|
||||
let key = xxhash_rust::xxh3::xxh3_128(format!("{source_id}|{path}|{size}|{mtime_ms}").as_bytes());
|
||||
format!("{key:032x}")
|
||||
}
|
||||
|
||||
/// cache_key → 썸네일 파일 경로 (256샤딩)
|
||||
pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> PathBuf {
|
||||
thumb_root
|
||||
.join(size_class.to_string())
|
||||
.join(&key[0..2])
|
||||
.join(format!("{key}.jpg"))
|
||||
}
|
||||
|
||||
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
|
||||
loop {
|
||||
// hi 우선, 없으면 lo를 짧게 대기
|
||||
let id = match hi_rx.try_recv() {
|
||||
Ok(id) => id,
|
||||
Err(_) => match lo_rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(id) => id,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
// hi 채널이 닫혔으면 종료
|
||||
if hi_rx.is_empty() && lo_rx.is_empty() && hi_rx.try_recv().is_err() {
|
||||
match hi_rx.recv_timeout(Duration::from_millis(1)) {
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
Ok(id) => id,
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
},
|
||||
};
|
||||
|
||||
if !ctx.in_flight.lock().unwrap().insert(id) {
|
||||
continue; // 다른 워커가 처리 중
|
||||
}
|
||||
let result = process_one(&ctx, id);
|
||||
ctx.in_flight.lock().unwrap().remove(&id);
|
||||
if let Ok(true) = result {
|
||||
let _ = ctx.done_tx.send(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// true = 새로 생성/등록됨, false = 스킵
|
||||
fn process_one(ctx: &Ctx, id: i64) -> Result<bool, archive_db::DbError> {
|
||||
struct Row {
|
||||
dir: String,
|
||||
name: String,
|
||||
ext: String,
|
||||
size: i64,
|
||||
mtime_ms: i64,
|
||||
source_id: i64,
|
||||
thumb_state: i64,
|
||||
kind: i64,
|
||||
duration_ms: Option<i64>,
|
||||
}
|
||||
let row = ctx.db.with_read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT fo.path, f.name, f.ext, f.size, f.mtime_ms, f.source_id, f.thumb_state, f.kind, f.duration_ms
|
||||
FROM files f JOIN folders fo ON fo.id = f.folder_id
|
||||
WHERE f.id = ?1 AND f.deleted_at IS NULL",
|
||||
[id],
|
||||
|r| {
|
||||
Ok(Row {
|
||||
dir: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
ext: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||
size: r.get(3)?,
|
||||
mtime_ms: r.get(4)?,
|
||||
source_id: r.get(5)?,
|
||||
thumb_state: r.get(6)?,
|
||||
kind: r.get(7)?,
|
||||
duration_ms: r.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
});
|
||||
let Ok(row) = row else { return Ok(false) };
|
||||
if row.thumb_state != 0 || row.kind > 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
// HEIC/HEIF는 image 크레이트가 못 읽음 — ffmpeg(HEVC 네이티브 디코더) 경로 사용
|
||||
let needs_ffmpeg = row.kind == 1 || matches!(row.ext.as_str(), "heic" | "heif");
|
||||
// ffmpeg가 없으면 보류 (thumb_state 0 유지 — 사이드카 생기면 재시도)
|
||||
if needs_ffmpeg && ctx.tools.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let src = std::path::Path::new(&row.dir).join(&row.name);
|
||||
let src_str = src.to_string_lossy().into_owned();
|
||||
let key = cache_key(row.source_id, &src_str, row.size, row.mtime_ms);
|
||||
let dst = thumb_path(&ctx.thumb_root, 0, &key);
|
||||
|
||||
let generated = if dst.exists() {
|
||||
// 캐시 히트 — 크기만 읽어 등록
|
||||
image::image_dimensions(&dst).ok()
|
||||
} else if needs_ffmpeg {
|
||||
// 영상: 길이의 10% 지점 프레임 (인트로/블랙프레임 회피), 미상이면 1초. HEIC: 0초.
|
||||
let at = if row.kind == 1 {
|
||||
row.duration_ms
|
||||
.map(|d| (d as f64 / 1000.0) * 0.1)
|
||||
.filter(|t| *t > 0.1)
|
||||
.unwrap_or(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let tools = ctx.tools.as_ref().unwrap();
|
||||
match crate::ffmpeg::extract_frame_jpeg(tools, &src, &dst, at, thumbs::GRID_LONG_EDGE) {
|
||||
Ok(dims) => Some(dims),
|
||||
Err(e) => {
|
||||
tracing::debug!(file = %src_str, "ffmpeg 썸네일 실패: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match thumbs::make_image_thumb(&src, &dst, thumbs::GRID_LONG_EDGE, thumbs::JPEG_QUALITY) {
|
||||
Ok((_, _, dw, dh)) => Some((dw, dh)),
|
||||
Err(e) => {
|
||||
tracing::debug!(file = %src_str, "썸네일 생성 실패: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match generated {
|
||||
Some((dw, dh)) => {
|
||||
let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0);
|
||||
ctx.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![id, key, dw, dh, bytes],
|
||||
)?;
|
||||
tx.execute("UPDATE files SET thumb_state = 1 WHERE id = ?1", [id])?;
|
||||
tx.commit()
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
ctx.db
|
||||
.with_write(move |conn| conn.execute("UPDATE files SET thumb_state = 2 WHERE id = ?1", [id]))?;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn generates_thumbs_for_scanned_images() {
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let media = tempfile::tempdir().unwrap();
|
||||
let thumb_root = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
|
||||
|
||||
for i in 0..5 {
|
||||
let img = image::RgbImage::from_pixel(600, 400, image::Rgb([i as u8 * 40, 10, 200]));
|
||||
img.save(media.path().join(format!("img{i}.png"))).unwrap();
|
||||
}
|
||||
|
||||
let root = media.path().to_string_lossy().into_owned();
|
||||
let sid = db
|
||||
.with_write({
|
||||
let root = root.clone();
|
||||
move |conn| {
|
||||
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let cancel = std::sync::atomic::AtomicBool::new(false);
|
||||
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
|
||||
|
||||
let done_count = Arc::new(AtomicUsize::new(0));
|
||||
let dc = done_count.clone();
|
||||
let q = ThumbQueue::start(db.clone(), thumb_root.path().to_path_buf(), None, 2, move |ids| {
|
||||
dc.fetch_add(ids.len(), Ordering::SeqCst);
|
||||
});
|
||||
q.enqueue_pending(&db, Some(sid));
|
||||
|
||||
// 완료 대기 (최대 10초)
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while done_count.load(Ordering::SeqCst) < 5 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert_eq!(done_count.load(Ordering::SeqCst), 5);
|
||||
|
||||
let ready: i64 = db
|
||||
.with_read(|c| c.query_row("SELECT count(*) FROM files WHERE thumb_state=1", [], |r| r.get(0)))
|
||||
.unwrap();
|
||||
assert_eq!(ready, 5);
|
||||
|
||||
// thumbs 테이블의 cache_key로 실제 파일 존재 확인
|
||||
let keys: Vec<String> = db
|
||||
.with_read(|c| {
|
||||
let mut stmt = c.prepare("SELECT cache_key FROM thumbs")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
rows.collect()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(keys.len(), 5);
|
||||
for k in keys {
|
||||
assert!(thumb_path(thumb_root.path(), 0, &k).exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 썸네일 생성 — 이미지: image 디코드 → EXIF 회전 → SIMD 리사이즈 → JPEG.
|
||||
//! (영상 썸네일은 M2에서 ffmpeg 사이드카로 추가)
|
||||
|
||||
use fast_image_resize::images::Image as FirImage;
|
||||
use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer};
|
||||
use image::DynamicImage;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ThumbError {
|
||||
#[error("I/O 오류: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("이미지 디코드 오류: {0}")]
|
||||
Image(#[from] image::ImageError),
|
||||
#[error("리사이즈 오류: {0}")]
|
||||
Resize(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ThumbError>;
|
||||
|
||||
/// 썸네일 크기 클래스 (계획서 §5)
|
||||
pub const GRID_LONG_EDGE: u32 = 256;
|
||||
pub const PREVIEW_LONG_EDGE: u32 = 1280;
|
||||
pub const JPEG_QUALITY: u8 = 80;
|
||||
|
||||
/// EXIF Orientation 값(1–8)을 읽는다. 없으면 1(정상).
|
||||
pub fn read_orientation(path: &Path) -> u32 {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return 1;
|
||||
};
|
||||
let mut reader = std::io::BufReader::new(&file);
|
||||
exif::Reader::new()
|
||||
.read_from_container(&mut reader)
|
||||
.ok()
|
||||
.and_then(|ex| {
|
||||
ex.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
.and_then(|f| f.value.get_uint(0))
|
||||
})
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
fn apply_orientation(img: DynamicImage, orientation: u32) -> DynamicImage {
|
||||
match orientation {
|
||||
2 => img.fliph(),
|
||||
3 => img.rotate180(),
|
||||
4 => img.flipv(),
|
||||
5 => img.rotate90().fliph(),
|
||||
6 => img.rotate90(),
|
||||
7 => img.rotate270().fliph(),
|
||||
8 => img.rotate270(),
|
||||
_ => img,
|
||||
}
|
||||
}
|
||||
|
||||
fn target_dims(w: u32, h: u32, long_edge: u32) -> (u32, u32) {
|
||||
if w.max(h) <= long_edge {
|
||||
return (w, h);
|
||||
}
|
||||
if w >= h {
|
||||
let nh = ((h as u64 * long_edge as u64) / w as u64).max(1) as u32;
|
||||
(long_edge, nh)
|
||||
} else {
|
||||
let nw = ((w as u64 * long_edge as u64) / h as u64).max(1) as u32;
|
||||
(nw, long_edge)
|
||||
}
|
||||
}
|
||||
|
||||
/// 이미지 파일에서 JPEG 썸네일을 생성한다. 반환값은 (원본 w, 원본 h, 썸네일 w, 썸네일 h).
|
||||
/// (HEIC/HEIF는 image 크레이트가 못 읽으므로 썸네일 큐가 ffmpeg 경로로 라우팅한다)
|
||||
pub fn make_image_thumb(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
) -> Result<(u32, u32, u32, u32)> {
|
||||
let decoded = image::ImageReader::open(src)?
|
||||
.with_guessed_format()?
|
||||
.decode()?;
|
||||
let oriented = apply_orientation(decoded, read_orientation(src));
|
||||
let (w, h) = (oriented.width(), oriented.height());
|
||||
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
|
||||
Ok((w, h, dw, dh))
|
||||
}
|
||||
|
||||
/// 이미 디코드된 이미지에서 썸네일 생성(원격 EXIF 내장 썸네일 등). 반환: (썸네일 w, h).
|
||||
pub fn make_image_thumb_from(
|
||||
img: &DynamicImage,
|
||||
dst: &Path,
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
) -> Result<(u32, u32)> {
|
||||
resize_encode(img, dst, long_edge, quality)
|
||||
}
|
||||
|
||||
/// JPEG/PNG 등 인메모리 바이트에서 썸네일 생성. `min_long_edge`보다 작으면 거부(None).
|
||||
/// 원격 이미지 전체 다운로드 후, 또는 EXIF 내장 썸네일 바이트에서 사용.
|
||||
pub fn make_thumb_from_bytes(
|
||||
bytes: &[u8],
|
||||
dst: &Path,
|
||||
long_edge: u32,
|
||||
quality: u8,
|
||||
min_long_edge: u32,
|
||||
) -> Option<(u32, u32)> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
if img.width().max(img.height()) < min_long_edge {
|
||||
return None;
|
||||
}
|
||||
resize_encode(&img, dst, long_edge, quality).ok()
|
||||
}
|
||||
|
||||
/// EXIF APP1 내 내장 썸네일(두 번째 JPEG SOI…EOI)을 스캔한다.
|
||||
pub fn scan_embedded_jpeg(data: &[u8]) -> Option<&[u8]> {
|
||||
let mut starts = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 1 < data.len() {
|
||||
if data[i] == 0xFF && data[i + 1] == 0xD8 {
|
||||
starts.push(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let start = *starts.get(1)?; // 첫째=본 이미지, 둘째=내장 썸네일
|
||||
let mut j = start + 2;
|
||||
while j + 1 < data.len() {
|
||||
if data[j] == 0xFF && data[j + 1] == 0xD9 {
|
||||
return Some(&data[start..j + 2]);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resize_encode(img: &DynamicImage, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let (dw, dh) = target_dims(w, h, long_edge);
|
||||
|
||||
let rgb = img.to_rgb8();
|
||||
let src_img = FirImage::from_vec_u8(w, h, rgb.into_raw(), PixelType::U8x3)
|
||||
.map_err(|e| ThumbError::Resize(e.to_string()))?;
|
||||
let mut dst_img = FirImage::new(dw, dh, PixelType::U8x3);
|
||||
let mut resizer = Resizer::new();
|
||||
resizer
|
||||
.resize(
|
||||
&src_img,
|
||||
&mut dst_img,
|
||||
&ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3)),
|
||||
)
|
||||
.map_err(|e| ThumbError::Resize(e.to_string()))?;
|
||||
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(dst)?);
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
|
||||
encoder.encode(dst_img.buffer(), dw, dh, image::ExtendedColorType::Rgb8)?;
|
||||
Ok((dw, dh))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_dims_math() {
|
||||
assert_eq!(target_dims(4000, 3000, 256), (256, 192));
|
||||
assert_eq!(target_dims(3000, 4000, 256), (192, 256));
|
||||
assert_eq!(target_dims(100, 50, 256), (100, 50)); // 업스케일 안 함
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_thumb_from_generated_png() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("src.png");
|
||||
let dst = dir.path().join("thumbs/aa/key.jpg");
|
||||
|
||||
// 1000x600 그라데이션 이미지 생성
|
||||
let img = image::RgbImage::from_fn(1000, 600, |x, y| {
|
||||
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
|
||||
});
|
||||
img.save(&src).unwrap();
|
||||
|
||||
let (w, h, dw, dh) = make_image_thumb(&src, &dst, 256, 80).unwrap();
|
||||
assert_eq!((w, h), (1000, 600));
|
||||
assert_eq!((dw, dh), (256, 153));
|
||||
|
||||
let reopened = image::ImageReader::open(&dst).unwrap().decode().unwrap();
|
||||
assert_eq!(reopened.width(), 256);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! ffmpeg 사이드카 실기 테스트 — src-tauri/binaries에 사이드카가 있을 때만 실행된다.
|
||||
//! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
|
||||
|
||||
use archive_indexer::ffmpeg::{self, FfTools};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn tools() -> Option<FfTools> {
|
||||
// crates/archive-indexer → ../../binaries
|
||||
let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../binaries");
|
||||
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
|
||||
"x86_64-pc-windows-msvc"
|
||||
} else if cfg!(all(windows, target_arch = "aarch64")) {
|
||||
"aarch64-pc-windows-msvc"
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
"aarch64-apple-darwin"
|
||||
} else {
|
||||
"x86_64-apple-darwin"
|
||||
};
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let ffmpeg = bin.join(format!("ffmpeg-{triple}{ext}"));
|
||||
let ffprobe = bin.join(format!("ffprobe-{triple}{ext}"));
|
||||
if ffmpeg.is_file() && ffprobe.is_file() {
|
||||
Some(FfTools { ffmpeg, ffprobe })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn make_test_video(t: &FfTools, dir: &Path) -> PathBuf {
|
||||
let out = dir.join("테스트영상.mp4");
|
||||
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
|
||||
let status = ffmpeg::command(&t.ffmpeg)
|
||||
.args([
|
||||
"-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", "testsrc2=duration=2:size=320x240:rate=25",
|
||||
"-c:v", vcodec, "-b:v", "300k",
|
||||
])
|
||||
.arg(&out)
|
||||
.status()
|
||||
.expect("ffmpeg 실행 실패");
|
||||
assert!(status.success(), "테스트 영상 생성 실패");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_and_thumbnail_roundtrip() {
|
||||
let Some(t) = tools() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀 (scripts/fetch-ffmpeg.ps1 실행 후 재시도)");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let video = make_test_video(&t, dir.path());
|
||||
|
||||
// ffprobe 메타
|
||||
let info = ffmpeg::probe(&t, &video).unwrap();
|
||||
assert_eq!(info.vcodec.as_deref(), Some("h264"));
|
||||
assert_eq!(info.width, Some(320));
|
||||
assert_eq!(info.height, Some(240));
|
||||
let dur = info.duration_ms.expect("duration 없음");
|
||||
assert!((1500..=2500).contains(&dur), "duration {dur}ms");
|
||||
assert!(info.fps.unwrap() > 20.0);
|
||||
|
||||
// 프레임 썸네일
|
||||
let thumb = dir.path().join("thumbs/aa/key.jpg");
|
||||
let (w, h) = ffmpeg::extract_frame_jpeg(&t, &video, &thumb, 0.5, 256).unwrap();
|
||||
assert!(thumb.is_file());
|
||||
assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 성능 측정 (기본 무시). 실행:
|
||||
//! ARCHIVE_PERF_DIR=<50k폴더> cargo test -p archive-indexer --release --test perf_scan -- --ignored --nocapture
|
||||
|
||||
use archive_db::Db;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scan_perf() {
|
||||
let Ok(dir) = std::env::var("ARCHIVE_PERF_DIR") else {
|
||||
eprintln!("ARCHIVE_PERF_DIR 미설정 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dbdir = tempfile::tempdir().unwrap();
|
||||
let db = Arc::new(Db::open(&dbdir.path().join("perf.db")).unwrap());
|
||||
let root = std::path::Path::new(&dir);
|
||||
let cancel = AtomicBool::new(false);
|
||||
|
||||
let dir_owned = dir.clone();
|
||||
db.with_write(move |c| {
|
||||
c.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','perf',?1)", [&dir_owned])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// 1차 스캔 (전체)
|
||||
let t0 = Instant::now();
|
||||
let stats = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
|
||||
let scan1 = t0.elapsed();
|
||||
|
||||
// 메타데이터
|
||||
let t1 = Instant::now();
|
||||
let meta = archive_indexer::meta::extract_image_meta(&db, 1, &cancel).unwrap();
|
||||
let meta_dur = t1.elapsed();
|
||||
|
||||
// 2차 스캔 (무변경 — 스냅샷 비교)
|
||||
let t2 = Instant::now();
|
||||
let stats2 = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
|
||||
let scan2 = t2.elapsed();
|
||||
|
||||
eprintln!("=== 성능 (릴리스) ===");
|
||||
eprintln!("파일 수: {}", stats.seen);
|
||||
eprintln!("1차 스캔: {:.2}s ({} added)", scan1.as_secs_f64(), stats.added);
|
||||
eprintln!("메타 추출: {:.2}s ({} 이미지)", meta_dur.as_secs_f64(), meta);
|
||||
eprintln!("무변경 재스캔: {:.2}s (added={}, changed={})", scan2.as_secs_f64(), stats2.added, stats2.changed);
|
||||
|
||||
assert_eq!(stats2.added, 0);
|
||||
assert_eq!(stats2.changed, 0);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "archive-media"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
bytes = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
mime_guess = "2"
|
||||
rand = "0.9"
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,385 @@
|
||||
//! localhost 미디어 스트리밍 서버.
|
||||
//!
|
||||
//! Tauri asset/커스텀 프로토콜은 대용량 영상 Range/스트리밍에 결함이 있어
|
||||
//! (tauri#6375, #7355, #11371) 모든 미디어 바이트는 이 서버를 통해 전달한다.
|
||||
//! 소비자: 웹뷰 `<video>/<img>`, (M2+) ffmpeg 썸네일러/트랜스코더.
|
||||
|
||||
pub mod range;
|
||||
pub mod stream;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path as AxumPath, State};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use range::{parse_range, RangeSpec};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
/// 원격 파일 스트리밍 핸들 (전체 길이 + AsyncRead).
|
||||
pub struct RemoteStream {
|
||||
pub total_len: u64,
|
||||
pub reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
|
||||
pub mime: String,
|
||||
}
|
||||
|
||||
/// file_id → 실제 경로 해석. 앱 쪽에서 DB 조회로 구현한다.
|
||||
#[async_trait::async_trait]
|
||||
pub trait PathResolver: Send + Sync + 'static {
|
||||
fn resolve(&self, file_id: i64) -> Option<PathBuf>;
|
||||
|
||||
/// 썸네일 캐시 파일 경로 해석 (size_class: 0=grid, 1=preview).
|
||||
fn resolve_thumb(&self, file_id: i64, size_class: u8) -> Option<PathBuf> {
|
||||
let _ = (file_id, size_class);
|
||||
None
|
||||
}
|
||||
|
||||
/// 원격 파일 여부 (true면 resolve 대신 open_remote 사용).
|
||||
fn is_remote(&self, file_id: i64) -> bool {
|
||||
let _ = file_id;
|
||||
false
|
||||
}
|
||||
|
||||
/// 원격 파일의 지정 범위를 스트리밍한다.
|
||||
async fn open_remote(&self, file_id: i64, range: Option<std::ops::Range<u64>>) -> Option<RemoteStream> {
|
||||
let _ = (file_id, range);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MediaServer {
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
pub streams: Arc<stream::StreamManager>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl MediaServer {
|
||||
pub fn media_url(&self, file_id: i64) -> String {
|
||||
format!("http://127.0.0.1:{}/media/{}/{}", self.port, self.token, file_id)
|
||||
}
|
||||
|
||||
pub fn stream_url(&self, job_id: u64) -> String {
|
||||
format!("http://127.0.0.1:{}/stream/{}/{}", self.port, self.token, job_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MediaServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerState {
|
||||
token: String,
|
||||
resolver: Arc<dyn PathResolver>,
|
||||
streams: Arc<stream::StreamManager>,
|
||||
}
|
||||
|
||||
/// 서버를 127.0.0.1의 임의 포트에 기동한다. `ffmpeg`가 None이면 스트림 라우트는 501.
|
||||
pub async fn start(
|
||||
resolver: Arc<dyn PathResolver>,
|
||||
ffmpeg: Option<PathBuf>,
|
||||
) -> std::io::Result<MediaServer> {
|
||||
let token = generate_token();
|
||||
let streams = Arc::new(stream::StreamManager::new(ffmpeg));
|
||||
let state = Arc::new(ServerState {
|
||||
token: token.clone(),
|
||||
resolver,
|
||||
streams: streams.clone(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/media/{token}/{id}", get(serve_media))
|
||||
.route("/thumb/{token}/{id}", get(serve_thumb))
|
||||
.route("/stream/{token}/{id}", get(serve_stream))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
tracing::error!("미디어 서버 종료 오류: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(port, "미디어 서버 시작");
|
||||
Ok(MediaServer {
|
||||
port,
|
||||
token,
|
||||
streams,
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve_stream(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
AxumPath((token, job_id)): AxumPath<(String, u64)>,
|
||||
) -> Response {
|
||||
if token != state.token {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
if !state.streams.available() {
|
||||
return StatusCode::NOT_IMPLEMENTED.into_response();
|
||||
}
|
||||
let Some(stdout) = state.streams.spawn(job_id) else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let body = Body::from_stream(ReaderStream::with_capacity(stdout, 256 * 1024));
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "video/mp4")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
let v: u128 = rand::random();
|
||||
format!("{v:032x}")
|
||||
}
|
||||
|
||||
async fn serve_media(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
AxumPath((token, file_id)): AxumPath<(String, i64)>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
// 토큰 불일치는 404 — 존재 여부를 노출하지 않는다
|
||||
if token != state.token {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
// 원격 파일은 VFS 스트리밍 경로로
|
||||
if state.resolver.is_remote(file_id) {
|
||||
return serve_remote(&state.resolver, file_id, &headers).await;
|
||||
}
|
||||
|
||||
let resolver = state.resolver.clone();
|
||||
let path = tokio::task::spawn_blocking(move || resolver.resolve(file_id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(path) = path else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let Ok(mut file) = tokio::fs::File::open(&path).await else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let Ok(meta) = file.metadata().await else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
let len = meta.len();
|
||||
|
||||
let mime = mime_guess::from_path(&path).first_or_octet_stream();
|
||||
let range_header = headers
|
||||
.get(header::RANGE)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
match parse_range(range_header, len) {
|
||||
RangeSpec::Unsatisfiable => Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
|
||||
RangeSpec::Full => {
|
||||
let stream = ReaderStream::new(file);
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime.as_ref())
|
||||
.header(header::CONTENT_LENGTH, len)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
RangeSpec::Partial(start, end) => {
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
let window = end - start + 1;
|
||||
let stream = ReaderStream::new(file.take(window));
|
||||
Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, mime.as_ref())
|
||||
.header(header::CONTENT_LENGTH, window)
|
||||
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 원격 파일 스트리밍 — VFS의 open_remote로 범위를 받아 서빙한다.
|
||||
async fn serve_remote(
|
||||
resolver: &Arc<dyn PathResolver>,
|
||||
file_id: i64,
|
||||
headers: &HeaderMap,
|
||||
) -> Response {
|
||||
// 먼저 전체 길이를 알아야 Range 계산이 가능 — HEAD 격으로 0바이트 요청
|
||||
let head = resolver.open_remote(file_id, Some(0..0)).await;
|
||||
let Some(head) = head else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let len = head.total_len;
|
||||
let mime = head.mime.clone();
|
||||
drop(head);
|
||||
|
||||
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
|
||||
match parse_range(range_header, len) {
|
||||
RangeSpec::Unsatisfiable => Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
RangeSpec::Full => {
|
||||
let Some(stream) = resolver.open_remote(file_id, None).await else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.header(header::CONTENT_LENGTH, len)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from_stream(ReaderStream::new(stream.reader)))
|
||||
.unwrap()
|
||||
}
|
||||
RangeSpec::Partial(start, end) => {
|
||||
let Some(stream) = resolver.open_remote(file_id, Some(start..end + 1)).await else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
let window = end - start + 1;
|
||||
Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.header(header::CONTENT_LENGTH, window)
|
||||
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(Body::from_stream(ReaderStream::new(stream.reader)))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ThumbQuery {
|
||||
#[serde(default)]
|
||||
s: u8,
|
||||
}
|
||||
|
||||
async fn serve_thumb(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
AxumPath((token, file_id)): AxumPath<(String, i64)>,
|
||||
axum::extract::Query(q): axum::extract::Query<ThumbQuery>,
|
||||
) -> Response {
|
||||
if token != state.token {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let resolver = state.resolver.clone();
|
||||
let path = tokio::task::spawn_blocking(move || resolver.resolve_thumb(file_id, q.s))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(path) = path else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let Ok(bytes) = tokio::fs::read(&path).await else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CONTENT_LENGTH, bytes.len())
|
||||
.header(header::CACHE_CONTROL, "max-age=3600")
|
||||
.body(Body::from(bytes))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct StaticResolver(HashMap<i64, PathBuf>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PathResolver for StaticResolver {
|
||||
fn resolve(&self, file_id: i64) -> Option<PathBuf> {
|
||||
self.0.get(&file_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_full_and_range_requests() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("video.mp4");
|
||||
let data: Vec<u8> = (0..=255u8).cycle().take(100_000).collect();
|
||||
std::fs::write(&p, &data).unwrap();
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(42i64, p);
|
||||
let server = start(Arc::new(StaticResolver(map)), None).await.unwrap();
|
||||
let base = server.media_url(42);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// 전체 응답
|
||||
let res = client.get(&base).send().await.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
assert_eq!(res.headers()["accept-ranges"], "bytes");
|
||||
assert_eq!(res.headers()["content-type"], "video/mp4");
|
||||
assert_eq!(res.bytes().await.unwrap().len(), 100_000);
|
||||
|
||||
// Range 응답 (영상 시킹 시나리오)
|
||||
let res = client
|
||||
.get(&base)
|
||||
.header("Range", "bytes=1000-1999")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 206);
|
||||
assert_eq!(res.headers()["content-range"], "bytes 1000-1999/100000");
|
||||
let body = res.bytes().await.unwrap();
|
||||
assert_eq!(body.len(), 1000);
|
||||
assert_eq!(&body[..], &data[1000..2000]);
|
||||
|
||||
// suffix range (mp4 moov-at-end 케이스)
|
||||
let res = client
|
||||
.get(&base)
|
||||
.header("Range", "bytes=-500")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 206);
|
||||
assert_eq!(&res.bytes().await.unwrap()[..], &data[99_500..]);
|
||||
|
||||
// 416
|
||||
let res = client
|
||||
.get(&base)
|
||||
.header("Range", "bytes=200000-")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 416);
|
||||
assert_eq!(res.headers()["content-range"], "bytes */100000");
|
||||
|
||||
// 잘못된 토큰 → 404
|
||||
let bad = format!("http://127.0.0.1:{}/media/wrongtoken/42", server.port);
|
||||
assert_eq!(client.get(&bad).send().await.unwrap().status(), 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! HTTP Range 헤더 파싱 (단일 범위만 지원 — 웹뷰 `<video>`/`<img>`는 단일 범위만 보낸다).
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RangeSpec {
|
||||
/// Range 헤더 없음 또는 무시 가능한 형식 → 전체 응답 (200)
|
||||
Full,
|
||||
/// 206 — [start, end] (inclusive)
|
||||
Partial(u64, u64),
|
||||
/// 416 — 만족 불가
|
||||
Unsatisfiable,
|
||||
}
|
||||
|
||||
/// `Range: bytes=a-b` / `bytes=a-` / `bytes=-suffix` 파싱.
|
||||
pub fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
|
||||
let Some(header) = header else {
|
||||
return RangeSpec::Full;
|
||||
};
|
||||
let Some(spec) = header.strip_prefix("bytes=") else {
|
||||
return RangeSpec::Full; // 알 수 없는 단위는 무시하고 전체 응답
|
||||
};
|
||||
// 다중 범위는 첫 범위만 사용
|
||||
let first = spec.split(',').next().unwrap_or("").trim();
|
||||
let Some((start_s, end_s)) = first.split_once('-') else {
|
||||
return RangeSpec::Full;
|
||||
};
|
||||
|
||||
if len == 0 {
|
||||
return RangeSpec::Unsatisfiable;
|
||||
}
|
||||
|
||||
match (start_s.is_empty(), end_s.is_empty()) {
|
||||
// bytes=-suffix : 마지막 suffix 바이트
|
||||
(true, false) => match end_s.parse::<u64>() {
|
||||
Ok(0) => RangeSpec::Unsatisfiable,
|
||||
Ok(suffix) => {
|
||||
let start = len.saturating_sub(suffix);
|
||||
RangeSpec::Partial(start, len - 1)
|
||||
}
|
||||
Err(_) => RangeSpec::Full,
|
||||
},
|
||||
// bytes=start- : start부터 끝까지
|
||||
(false, true) => match start_s.parse::<u64>() {
|
||||
Ok(start) if start < len => RangeSpec::Partial(start, len - 1),
|
||||
Ok(_) => RangeSpec::Unsatisfiable,
|
||||
Err(_) => RangeSpec::Full,
|
||||
},
|
||||
// bytes=start-end
|
||||
(false, false) => match (start_s.parse::<u64>(), end_s.parse::<u64>()) {
|
||||
(Ok(start), Ok(end)) => {
|
||||
if start > end || start >= len {
|
||||
RangeSpec::Unsatisfiable
|
||||
} else {
|
||||
RangeSpec::Partial(start, end.min(len - 1))
|
||||
}
|
||||
}
|
||||
_ => RangeSpec::Full,
|
||||
},
|
||||
(true, true) => RangeSpec::Full,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_header_is_full() {
|
||||
assert_eq!(parse_range(None, 100), RangeSpec::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_range() {
|
||||
assert_eq!(parse_range(Some("bytes=0-49"), 100), RangeSpec::Partial(0, 49));
|
||||
assert_eq!(parse_range(Some("bytes=50-"), 100), RangeSpec::Partial(50, 99));
|
||||
assert_eq!(parse_range(Some("bytes=-10"), 100), RangeSpec::Partial(90, 99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_clamped_to_len() {
|
||||
assert_eq!(parse_range(Some("bytes=90-200"), 100), RangeSpec::Partial(90, 99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsatisfiable() {
|
||||
assert_eq!(parse_range(Some("bytes=100-"), 100), RangeSpec::Unsatisfiable);
|
||||
assert_eq!(parse_range(Some("bytes=200-300"), 100), RangeSpec::Unsatisfiable);
|
||||
assert_eq!(parse_range(Some("bytes=5-2"), 100), RangeSpec::Unsatisfiable);
|
||||
assert_eq!(parse_range(Some("bytes=0-"), 0), RangeSpec::Unsatisfiable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_falls_back_to_full() {
|
||||
assert_eq!(parse_range(Some("items=0-5"), 100), RangeSpec::Full);
|
||||
assert_eq!(parse_range(Some("bytes=abc-def"), 100), RangeSpec::Full);
|
||||
assert_eq!(parse_range(Some("bytes=-"), 100), RangeSpec::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_range_uses_first() {
|
||||
assert_eq!(
|
||||
parse_range(Some("bytes=0-10, 20-30"), 100),
|
||||
RangeSpec::Partial(0, 10)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! ffmpeg 리먹스/트랜스코딩 스트림 잡 관리.
|
||||
//!
|
||||
//! 웹뷰가 재생 못 하는 컨테이너/코덱은 ffmpeg로 fMP4를 만들어 chunked로
|
||||
//! 흘려보낸다. 시킹은 잡 재생성(-ss 입력 시킹) + 프론트 seekBase 오프셋 방식.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VideoMode {
|
||||
/// 스트림 카피 (컨테이너만 교체)
|
||||
Copy,
|
||||
/// 카피 + hvc1 태그 (macOS HEVC)
|
||||
CopyHvc1,
|
||||
/// H.264 하드웨어 인코더로 변환
|
||||
Transcode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AudioMode {
|
||||
Copy,
|
||||
/// AAC 변환 (AC-3/DTS/Vorbis 등)
|
||||
Aac,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamSpec {
|
||||
pub input: PathBuf,
|
||||
pub start_seconds: f64,
|
||||
pub video: VideoMode,
|
||||
pub audio: AudioMode,
|
||||
}
|
||||
|
||||
pub struct StreamManager {
|
||||
ffmpeg: Option<PathBuf>,
|
||||
specs: Mutex<HashMap<u64, StreamSpec>>,
|
||||
procs: Mutex<HashMap<u64, tokio::process::Child>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl StreamManager {
|
||||
pub fn new(ffmpeg: Option<PathBuf>) -> Self {
|
||||
StreamManager {
|
||||
ffmpeg,
|
||||
specs: Mutex::new(HashMap::new()),
|
||||
procs: Mutex::new(HashMap::new()),
|
||||
next_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available(&self) -> bool {
|
||||
self.ffmpeg.is_some()
|
||||
}
|
||||
|
||||
/// 잡을 등록하고 id를 돌려준다 (프로세스는 GET 시점에 기동).
|
||||
pub fn create(&self, spec: StreamSpec) -> u64 {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
self.specs.lock().unwrap().insert(id, spec);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn stop(&self, id: u64) {
|
||||
self.specs.lock().unwrap().remove(&id);
|
||||
if let Some(mut child) = self.procs.lock().unwrap().remove(&id) {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_all(&self) {
|
||||
self.specs.lock().unwrap().clear();
|
||||
let mut procs = self.procs.lock().unwrap();
|
||||
for (_, mut child) in procs.drain() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// GET 핸들러에서 호출 — ffmpeg를 기동하고 stdout을 돌려준다.
|
||||
pub(crate) fn spawn(&self, id: u64) -> Option<tokio::process::ChildStdout> {
|
||||
let spec = self.specs.lock().unwrap().get(&id).cloned()?;
|
||||
let ffmpeg = self.ffmpeg.clone()?;
|
||||
|
||||
// 같은 잡의 이전 프로세스는 종료 (시킹 재요청 등)
|
||||
if let Some(mut old) = self.procs.lock().unwrap().remove(&id) {
|
||||
let _ = old.start_kill();
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&ffmpeg);
|
||||
cmd.args(["-v", "error"]);
|
||||
if spec.start_seconds > 0.01 {
|
||||
cmd.args(["-ss", &format!("{:.3}", spec.start_seconds)]);
|
||||
}
|
||||
cmd.arg("-i").arg(&spec.input);
|
||||
cmd.args(["-map", "0:v:0", "-map", "0:a:0?"]);
|
||||
|
||||
match spec.video {
|
||||
VideoMode::Copy => {
|
||||
cmd.args(["-c:v", "copy"]);
|
||||
}
|
||||
VideoMode::CopyHvc1 => {
|
||||
cmd.args(["-c:v", "copy", "-tag:v", "hvc1"]);
|
||||
}
|
||||
VideoMode::Transcode => {
|
||||
#[cfg(windows)]
|
||||
cmd.args(["-c:v", "h264_mf", "-b:v", "6M"]);
|
||||
#[cfg(target_os = "macos")]
|
||||
cmd.args(["-c:v", "h264_videotoolbox", "-b:v", "6M"]);
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
cmd.args(["-c:v", "libx264", "-preset", "veryfast", "-crf", "23"]);
|
||||
// H.264는 짝수 해상도 필수
|
||||
cmd.args(["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"]);
|
||||
}
|
||||
}
|
||||
match spec.audio {
|
||||
AudioMode::Copy => {
|
||||
cmd.args(["-c:a", "copy"]);
|
||||
}
|
||||
AudioMode::Aac => {
|
||||
cmd.args(["-c:a", "aac", "-b:a", "192k", "-ac", "2"]);
|
||||
}
|
||||
}
|
||||
cmd.args([
|
||||
"-movflags",
|
||||
"frag_keyframe+empty_moov+default_base_moof",
|
||||
"-f",
|
||||
"mp4",
|
||||
"pipe:1",
|
||||
]);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());
|
||||
cmd.kill_on_drop(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("ffmpeg 스트림 기동 실패: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let stdout = child.stdout.take()?;
|
||||
self.procs.lock().unwrap().insert(id, child);
|
||||
Some(stdout)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamManager {
|
||||
fn drop(&mut self) {
|
||||
let mut procs = self.procs.lock().unwrap();
|
||||
for (_, mut child) in procs.drain() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! ffmpeg 스트림(리먹스) 실기 테스트 — 사이드카가 있을 때만 실행.
|
||||
|
||||
use archive_media::stream::{AudioMode, StreamSpec, VideoMode};
|
||||
use archive_media::PathResolver;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn ffmpeg_path() -> Option<PathBuf> {
|
||||
let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../binaries");
|
||||
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
|
||||
"x86_64-pc-windows-msvc"
|
||||
} else if cfg!(all(windows, target_arch = "aarch64")) {
|
||||
"aarch64-pc-windows-msvc"
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
"aarch64-apple-darwin"
|
||||
} else {
|
||||
"x86_64-apple-darwin"
|
||||
};
|
||||
let ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let p = bin.join(format!("ffmpeg-{triple}{ext}"));
|
||||
p.is_file().then_some(p)
|
||||
}
|
||||
|
||||
struct NoResolver;
|
||||
#[async_trait::async_trait]
|
||||
impl PathResolver for NoResolver {
|
||||
fn resolve(&self, _: i64) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn make_mkv(ffmpeg: &Path, dir: &Path) -> PathBuf {
|
||||
let out = dir.join("입력.mkv");
|
||||
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
|
||||
let status = std::process::Command::new(ffmpeg)
|
||||
.args([
|
||||
"-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", "testsrc2=duration=3:size=320x240:rate=25",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
|
||||
"-c:v", vcodec, "-b:v", "300k", "-c:a", "ac3", "-shortest",
|
||||
])
|
||||
.arg(&out)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remux_mkv_to_fmp4_stream() {
|
||||
let Some(ffmpeg) = ffmpeg_path() else {
|
||||
eprintln!("사이드카 없음 — 건너뜀");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mkv = make_mkv(&ffmpeg, dir.path());
|
||||
|
||||
let server = archive_media::start(Arc::new(NoResolver), Some(ffmpeg)).await.unwrap();
|
||||
|
||||
// MKV(h264 + ac3) → 영상 카피 + 오디오 AAC 변환 (모드 3)
|
||||
let job = server.streams.create(StreamSpec {
|
||||
input: mkv,
|
||||
start_seconds: 0.0,
|
||||
video: VideoMode::Copy,
|
||||
audio: AudioMode::Aac,
|
||||
});
|
||||
let url = server.stream_url(job);
|
||||
|
||||
let res = reqwest::get(&url).await.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
assert_eq!(res.headers()["content-type"], "video/mp4");
|
||||
|
||||
let body = res.bytes().await.unwrap();
|
||||
assert!(body.len() > 10_000, "스트림이 너무 짧음: {} bytes", body.len());
|
||||
// fMP4 시그니처: 4번째 바이트부터 'ftyp'
|
||||
assert_eq!(&body[4..8], b"ftyp", "fMP4 헤더가 아님");
|
||||
// fragmented mp4는 moof 박스를 포함
|
||||
assert!(
|
||||
body.windows(4).any(|w| w == b"moof"),
|
||||
"fragmented MP4가 아님 (moof 없음)"
|
||||
);
|
||||
|
||||
server.streams.stop(job);
|
||||
|
||||
// 시킹 시나리오: 1.5초 지점에서 재기동
|
||||
let job2 = server.streams.create(StreamSpec {
|
||||
input: dir.path().join("입력.mkv"),
|
||||
start_seconds: 1.5,
|
||||
video: VideoMode::Copy,
|
||||
audio: AudioMode::Aac,
|
||||
});
|
||||
let res2 = reqwest::get(server.stream_url(job2)).await.unwrap();
|
||||
assert_eq!(res2.status(), 200);
|
||||
let body2 = res2.bytes().await.unwrap();
|
||||
assert!(body2.len() > 5_000);
|
||||
assert!(body2.len() < body.len(), "1.5초 이후 스트림이 전체보다 짧아야 함");
|
||||
server.streams.stop(job2);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "archive-vfs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
bitflags = "2"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# 네트워크 백엔드 — 전부 순수 Rust + rustls/ring 조합으로 C 툴체인 리스크 제거.
|
||||
russh = { version = "0.62", default-features = false, features = ["ring", "flate2", "rsa"] }
|
||||
russh-sftp = "2"
|
||||
suppaftp = { version = "10", features = ["tokio-rustls-ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
reqwest_dav = "0.3"
|
||||
futures-util = "0.3"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# 자격증명: OS 키체인(우선) + 포터블 볼트(ChaCha20-Poly1305 + Argon2id)
|
||||
keyring = { version = "3.6", default-features = false }
|
||||
chacha20poly1305 = "0.10"
|
||||
argon2 = "0.5"
|
||||
rand = "0.9"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "3.6", default-features = false, features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,202 @@
|
||||
//! 개발/테스트용 최소 WebDAV 서버 — 실제 디렉터리를 서빙한다.
|
||||
//! 사용: cargo run -p archive-vfs --example webdav_server -- <포트> <디렉터리>
|
||||
//! PROPFIND(Depth 0/1) + GET(Range) + MOVE + DELETE 지원. 인증 없음(로컬 전용).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let port: u16 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(8080);
|
||||
let root = PathBuf::from(args.get(2).cloned().unwrap_or_else(|| ".".into()));
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
println!("WebDAV 서버: http://127.0.0.1:{port}/ root={}", root.display());
|
||||
loop {
|
||||
let (sock, _) = listener.accept().await.unwrap();
|
||||
let root = root.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle(sock, root).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(mut sock: TcpStream, root: PathBuf) -> std::io::Result<()> {
|
||||
let mut buf = vec![0u8; 16384];
|
||||
let n = sock.read(&mut buf).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let mut lines = req.lines();
|
||||
let first = lines.next().unwrap_or("");
|
||||
let mut parts = first.split_whitespace();
|
||||
let method = parts.next().unwrap_or("");
|
||||
let raw_path = parts.next().unwrap_or("/");
|
||||
let path = urldecode(raw_path);
|
||||
let depth = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("depth:"))
|
||||
.and_then(|l| l.split(':').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "1".into());
|
||||
|
||||
let fs_path = root.join(path.trim_start_matches('/'));
|
||||
|
||||
match method {
|
||||
"OPTIONS" => {
|
||||
write_head(&mut sock, "200 OK", "text/plain", 0, None, "DAV: 1\r\nAllow: OPTIONS, GET, PROPFIND, MOVE, DELETE, MKCOL, PUT\r\n").await?;
|
||||
}
|
||||
"PROPFIND" => {
|
||||
let xml = build_propfind(&root, &fs_path, &path, &depth);
|
||||
write_head(&mut sock, "207 Multi-Status", "application/xml; charset=utf-8", xml.len(), None, "").await?;
|
||||
sock.write_all(xml.as_bytes()).await?;
|
||||
}
|
||||
"GET" => {
|
||||
if let Ok(data) = tokio::fs::read(&fs_path).await {
|
||||
let range = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("range:"))
|
||||
.and_then(|l| l.split("bytes=").nth(1))
|
||||
.map(|s| s.trim().to_string());
|
||||
match range {
|
||||
Some(spec) => {
|
||||
let (s, e) = spec.split_once('-').unwrap_or(("0", ""));
|
||||
let start: usize = s.trim().parse().unwrap_or(0);
|
||||
let end: usize = e.trim().parse().unwrap_or(data.len().saturating_sub(1));
|
||||
let end = end.min(data.len().saturating_sub(1));
|
||||
let slice = &data[start..=end];
|
||||
let cr = format!("bytes {}-{}/{}", start, end, data.len());
|
||||
write_head(&mut sock, "206 Partial Content", "application/octet-stream", slice.len(), Some(&cr), "").await?;
|
||||
sock.write_all(slice).await?;
|
||||
}
|
||||
None => {
|
||||
write_head(&mut sock, "200 OK", "application/octet-stream", data.len(), None, "").await?;
|
||||
sock.write_all(&data).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write_head(&mut sock, "404 Not Found", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
}
|
||||
"MOVE" => {
|
||||
let dest = req
|
||||
.lines()
|
||||
.find(|l| l.to_ascii_lowercase().starts_with("destination:"))
|
||||
.and_then(|l| l.splitn(2, ':').nth(1))
|
||||
.map(|s| urldecode(s.trim()))
|
||||
.unwrap_or_default();
|
||||
// Destination은 전체 URL — 경로 부분만 추출
|
||||
let dest_path = dest.split("//").nth(1).and_then(|s| s.split_once('/')).map(|(_, p)| format!("/{p}")).unwrap_or(dest);
|
||||
let dfs = root.join(dest_path.trim_start_matches('/'));
|
||||
let _ = tokio::fs::rename(&fs_path, &dfs).await;
|
||||
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
"DELETE" => {
|
||||
let _ = tokio::fs::remove_file(&fs_path).await;
|
||||
write_head(&mut sock, "204 No Content", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
"MKCOL" => {
|
||||
let _ = tokio::fs::create_dir_all(&fs_path).await;
|
||||
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
_ => {
|
||||
write_head(&mut sock, "405 Method Not Allowed", "text/plain", 0, None, "").await?;
|
||||
}
|
||||
}
|
||||
sock.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_head(
|
||||
sock: &mut TcpStream,
|
||||
status: &str,
|
||||
ctype: &str,
|
||||
len: usize,
|
||||
content_range: Option<&str>,
|
||||
extra: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {len}\r\nAccept-Ranges: bytes\r\n"
|
||||
);
|
||||
if let Some(cr) = content_range {
|
||||
head.push_str(&format!("Content-Range: {cr}\r\n"));
|
||||
}
|
||||
head.push_str(extra);
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
sock.write_all(head.as_bytes()).await
|
||||
}
|
||||
|
||||
fn build_propfind(root: &Path, fs_path: &Path, url_path: &str, depth: &str) -> String {
|
||||
let mut responses = String::new();
|
||||
let base = url_path.trim_end_matches('/');
|
||||
|
||||
// 요청 대상 자신
|
||||
if fs_path.is_dir() {
|
||||
responses.push_str(&dav_folder(&format!("{base}/")));
|
||||
if depth != "0" {
|
||||
if let Ok(rd) = std::fs::read_dir(fs_path) {
|
||||
for entry in rd.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let child_url = format!("{base}/{}", urlencode(&name));
|
||||
if entry.path().is_dir() {
|
||||
responses.push_str(&dav_folder(&format!("{child_url}/")));
|
||||
} else {
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
responses.push_str(&dav_file(&child_url, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let size = fs_path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
responses.push_str(&dav_file(base, size));
|
||||
}
|
||||
let _ = root;
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">{responses}</D:multistatus>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn dav_folder(href: &str) -> String {
|
||||
format!(
|
||||
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn dav_file(href: &str, size: u64) -> String {
|
||||
format!(
|
||||
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype/><D:getcontentlength>{size}</D:getcontentlength><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! 자격증명 저장 — 기본은 OS 키체인, 포터블 모드는 암호화 볼트(opt-in).
|
||||
//! SQLite에는 비밀번호를 절대 저장하지 않는다 (계획서 §9).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SERVICE: &str = "kr.co.elio.archive";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CredError {
|
||||
#[error("키체인 오류: {0}")]
|
||||
Keyring(String),
|
||||
#[error("볼트 오류: {0}")]
|
||||
Vault(String),
|
||||
#[error("잠긴 볼트 — 패스프레이즈 필요")]
|
||||
Locked,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CredError>;
|
||||
|
||||
/// 자격증명 백엔드.
|
||||
pub enum CredStore {
|
||||
/// OS 키체인 (Windows Credential Manager / macOS Keychain)
|
||||
Keychain,
|
||||
/// 포터블 암호화 볼트 (exe 옆 vault.bin)
|
||||
Vault(Vault),
|
||||
}
|
||||
|
||||
impl CredStore {
|
||||
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
entry.set_password(secret).map_err(|e| CredError::Keyring(e.to_string()))
|
||||
}
|
||||
CredStore::Vault(v) => v.set(key, secret),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
match entry.get_password() {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(CredError::Keyring(e.to_string())),
|
||||
}
|
||||
}
|
||||
CredStore::Vault(v) => v.get(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<()> {
|
||||
match self {
|
||||
CredStore::Keychain => {
|
||||
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(CredError::Keyring(e.to_string())),
|
||||
}
|
||||
}
|
||||
CredStore::Vault(v) => v.delete(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ChaCha20-Poly1305 + Argon2id 암호화 볼트 (포터블 모드).
|
||||
pub struct Vault {
|
||||
path: PathBuf,
|
||||
key: [u8; 32],
|
||||
data: std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
/// 패스프레이즈로 볼트를 열거나(기존) 새로 만든다.
|
||||
pub fn open(path: PathBuf, passphrase: &str) -> Result<Vault> {
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
|
||||
// 볼트 파일의 salt를 재사용하거나 새로 생성
|
||||
let (salt_b64, ciphertext) = if path.exists() {
|
||||
let bytes = std::fs::read(&path).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let (salt, ct) = content.split_once('\n').ok_or_else(|| CredError::Vault("형식 오류".into()))?;
|
||||
(salt.to_string(), Some(ct.to_string()))
|
||||
} else {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
(salt.as_str().to_string(), None)
|
||||
};
|
||||
|
||||
let salt = SaltString::from_b64(&salt_b64).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(passphrase.as_bytes(), &salt)
|
||||
.map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let hash_bytes = hash.hash.ok_or_else(|| CredError::Vault("해시 없음".into()))?;
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash_bytes.as_bytes()[..32]);
|
||||
|
||||
let data = match ciphertext {
|
||||
Some(ct) if !ct.is_empty() => decrypt_map(&key, &ct)?,
|
||||
_ => std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let vault = Vault {
|
||||
path,
|
||||
key,
|
||||
data: std::sync::Mutex::new(data),
|
||||
};
|
||||
// salt를 즉시 기록 (새 볼트)
|
||||
vault.persist(&salt_b64)?;
|
||||
Ok(vault)
|
||||
}
|
||||
|
||||
fn persist(&self, salt_b64: &str) -> Result<()> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let ct = encrypt_map(&self.key, &data)?;
|
||||
std::fs::write(&self.path, format!("{salt_b64}\n{ct}")).map_err(|e| CredError::Vault(e.to_string()))
|
||||
}
|
||||
|
||||
fn salt_from_file(&self) -> Result<String> {
|
||||
let bytes = std::fs::read(&self.path).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
Ok(content.split_once('\n').map(|(s, _)| s.to_string()).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
|
||||
self.data.lock().unwrap().insert(key.to_string(), secret.to_string());
|
||||
let salt = self.salt_from_file()?;
|
||||
self.persist(&salt)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
Ok(self.data.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<()> {
|
||||
self.data.lock().unwrap().remove(key);
|
||||
let salt = self.salt_from_file()?;
|
||||
self.persist(&salt)
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_map(key: &[u8; 32], map: &std::collections::HashMap<String, String>) -> Result<String> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
|
||||
let plaintext = serde_json::to_vec(map).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ct = cipher.encrypt(nonce, plaintext.as_ref()).map_err(|e| CredError::Vault(e.to_string()))?;
|
||||
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ct);
|
||||
Ok(base64_encode(&combined))
|
||||
}
|
||||
|
||||
fn decrypt_map(key: &[u8; 32], b64: &str) -> Result<std::collections::HashMap<String, String>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
|
||||
|
||||
let combined = base64_decode(b64).map_err(|e| CredError::Vault(e))?;
|
||||
if combined.len() < 12 {
|
||||
return Err(CredError::Vault("암호문 길이 오류".into()));
|
||||
}
|
||||
let (nonce_bytes, ct) = combined.split_at(12);
|
||||
let cipher = ChaCha20Poly1305::new(key.into());
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ct)
|
||||
.map_err(|_| CredError::Vault("복호화 실패 (패스프레이즈 불일치?)".into()))?;
|
||||
serde_json::from_slice(&plaintext).map_err(|e| CredError::Vault(e.to_string()))
|
||||
}
|
||||
|
||||
// 표준 base64 (URL-safe 아님) — 외부 크레이트 없이 구현
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
||||
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
|
||||
out.push(T[((n >> 18) & 63) as usize] as char);
|
||||
out.push(T[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
fn val(c: u8) -> std::result::Result<u32, String> {
|
||||
match c {
|
||||
b'A'..=b'Z' => Ok((c - b'A') as u32),
|
||||
b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
|
||||
b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
|
||||
b'+' => Ok(62),
|
||||
b'/' => Ok(63),
|
||||
_ => Err("잘못된 base64 문자".into()),
|
||||
}
|
||||
}
|
||||
let clean: Vec<u8> = s.bytes().filter(|c| *c != b'=' && !c.is_ascii_whitespace()).collect();
|
||||
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
|
||||
for chunk in clean.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
let mut bits = 0;
|
||||
for &c in chunk {
|
||||
n = (n << 6) | val(c)?;
|
||||
bits += 6;
|
||||
}
|
||||
n <<= 24 - bits;
|
||||
out.push((n >> 16) as u8);
|
||||
if chunk.len() > 2 {
|
||||
out.push((n >> 8) as u8);
|
||||
}
|
||||
if chunk.len() > 3 {
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_roundtrip() {
|
||||
for input in [&b""[..], b"a", b"ab", b"abc", b"abcd", b"hello world!"] {
|
||||
let enc = base64_encode(input);
|
||||
assert_eq!(base64_decode(&enc).unwrap(), input, "input={input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_encrypt_persist_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("vault.bin");
|
||||
|
||||
{
|
||||
let v = Vault::open(path.clone(), "correct-horse").unwrap();
|
||||
v.set("sftp:1:password", "s3cr3t비밀").unwrap();
|
||||
v.set("ftp:2:password", "another").unwrap();
|
||||
}
|
||||
// 재오픈 (같은 패스프레이즈)
|
||||
{
|
||||
let v = Vault::open(path.clone(), "correct-horse").unwrap();
|
||||
assert_eq!(v.get("sftp:1:password").unwrap().as_deref(), Some("s3cr3t비밀"));
|
||||
assert_eq!(v.get("ftp:2:password").unwrap().as_deref(), Some("another"));
|
||||
v.delete("ftp:2:password").unwrap();
|
||||
assert_eq!(v.get("ftp:2:password").unwrap(), None);
|
||||
}
|
||||
// 잘못된 패스프레이즈 → 복호화 실패
|
||||
{
|
||||
let result = Vault::open(path.clone(), "wrong-passphrase");
|
||||
assert!(result.is_err(), "잘못된 패스프레이즈는 실패해야 함");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! FTP/FTPS 백엔드 (suppaftp, tokio + rustls).
|
||||
//! FTP는 데이터 커넥션당 전송 하나라 Range는 REST 오프셋으로 구현한다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use suppaftp::tokio::AsyncFtpStream;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FtpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
pub struct FtpFs {
|
||||
stream: Arc<Mutex<AsyncFtpStream>>,
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl FtpFs {
|
||||
pub async fn connect(cfg: &FtpConfig) -> Result<FtpFs> {
|
||||
let mut stream = AsyncFtpStream::connect((cfg.host.as_str(), cfg.port))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("FTP 연결: {e}")))?;
|
||||
stream
|
||||
.login(&cfg.username, &cfg.password)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("FTP 로그인: {e}")))?;
|
||||
// 바이너리 모드
|
||||
let _ = stream.transfer_type(suppaftp::types::FileType::Binary).await;
|
||||
Ok(FtpFs {
|
||||
stream: Arc::new(Mutex::new(stream)),
|
||||
base: cfg.base_path.trim_end_matches('/').to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn full(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else if p.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for FtpFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
// FTP는 병렬 읽기·서버측 rename 지원, 하지만 Range는 REST 재연결 기반
|
||||
Caps::SERVER_MOVE
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let list = stream
|
||||
.list(Some(&full))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("LIST: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for line in list {
|
||||
if let Some(entry) = parse_list_line(&line) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let size = stream.size(&full).await.unwrap_or(0) as u64;
|
||||
let name = full.rsplit('/').next().unwrap_or("").to_string();
|
||||
Ok(VEntry {
|
||||
name,
|
||||
is_dir: size == 0,
|
||||
size,
|
||||
mtime_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
if let Some(r) = &range {
|
||||
if r.start > 0 {
|
||||
stream
|
||||
.resume_transfer(r.start as usize)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("REST: {e}")))?;
|
||||
}
|
||||
}
|
||||
let mut data_stream = stream
|
||||
.retr_as_stream(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("RETR: {e}")))?;
|
||||
// Range 길이만큼(또는 전체) 메모리로 읽는다
|
||||
let mut buf = Vec::new();
|
||||
match &range {
|
||||
Some(r) => {
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
let mut limited = (&mut data_stream).take(len);
|
||||
limited.read_to_end(&mut buf).await?;
|
||||
}
|
||||
None => {
|
||||
data_stream.read_to_end(&mut buf).await?;
|
||||
}
|
||||
}
|
||||
// 데이터 커넥션 정리
|
||||
let _ = stream.finalize_retr_stream(data_stream).await;
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let (f, t) = (self.full(from), self.full(to));
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.rename(&f, &t).await.map_err(|e| VfsError::Protocol(format!("RNFR/RNTO: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
stream.rm(&full).await.map_err(|e| VfsError::Protocol(format!("DELE: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let mut stream = self.stream.lock().await;
|
||||
let _ = stream.mkdir(&full).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// UNIX 스타일 LIST 라인 파싱 (권한 파싱 최소화 — 디렉터리 여부 + 이름 + 크기)
|
||||
fn parse_list_line(line: &str) -> Option<VEntry> {
|
||||
// 예: "drwxr-xr-x 2 user group 4096 Jan 01 12:00 폴더명"
|
||||
let parts: Vec<&str> = line.splitn(9, char::is_whitespace).filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() < 9 {
|
||||
return None;
|
||||
}
|
||||
let perms = parts[0];
|
||||
let is_dir = perms.starts_with('d');
|
||||
let size: u64 = parts[4].parse().unwrap_or(0);
|
||||
let name = parts[8].to_string();
|
||||
if name == "." || name == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(VEntry {
|
||||
name,
|
||||
is_dir,
|
||||
size,
|
||||
mtime_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_unix_list() {
|
||||
let dir = parse_list_line("drwxr-xr-x 2 user group 4096 Jan 01 12:00 여행폴더").unwrap();
|
||||
assert!(dir.is_dir);
|
||||
assert_eq!(dir.name, "여행폴더");
|
||||
|
||||
let file = parse_list_line("-rw-r--r-- 1 user group 102400 Feb 15 09:30 사진.jpg").unwrap();
|
||||
assert!(!file.is_dir);
|
||||
assert_eq!(file.size, 102400);
|
||||
assert_eq!(file.name, "사진.jpg");
|
||||
|
||||
assert!(parse_list_line("drwxr-xr-x 2 u g 4096 Jan 01 12:00 .").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! VFS 추상화 — 로컬/원격 소스가 스캔·인덱싱·썸네일·재생 파이프라인을 공유하기
|
||||
//! 위한 단일 트레이트. 모든 소비자는 `VfsProvider`만 알고, 프로토콜별 구현은
|
||||
//! 백엔드 모듈에 격리된다.
|
||||
|
||||
pub mod creds;
|
||||
pub mod ftp;
|
||||
pub mod local;
|
||||
pub mod sftp;
|
||||
pub mod webdav;
|
||||
|
||||
use std::ops::Range;
|
||||
use tokio::io::AsyncRead;
|
||||
|
||||
bitflags::bitflags! {
|
||||
/// 소스별 지원 기능 — UI와 파이프라인 동작을 게이트한다.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Caps: u32 {
|
||||
/// 임의 오프셋 읽기 (재생 시킹, EXIF 썸네일 부분 다운로드)
|
||||
const RANGE_READ = 1 << 0;
|
||||
/// 서버측 이동/이름변경
|
||||
const SERVER_MOVE = 1 << 1;
|
||||
/// OS 휴지통 (없으면 앱 레벨 휴지통 사용)
|
||||
const NATIVE_TRASH = 1 << 2;
|
||||
/// 수정시각 보존 쓰기
|
||||
const MTIME_WRITE = 1 << 3;
|
||||
/// 병렬 읽기에 안전
|
||||
const PARALLEL_READS = 1 << 4;
|
||||
/// 파일 변경 감시 신뢰 가능
|
||||
const WATCHABLE = 1 << 5;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
pub mtime_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VfsError {
|
||||
#[error("I/O 오류: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("경로를 찾을 수 없음: {0}")]
|
||||
NotFound(String),
|
||||
#[error("아직 구현되지 않음: {0}")]
|
||||
NotImplemented(&'static str),
|
||||
#[error("프로토콜 오류: {0}")]
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, VfsError>;
|
||||
|
||||
/// 스트리밍 읽기 핸들.
|
||||
pub type VfsRead = Box<dyn AsyncRead + Send + Unpin>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait VfsProvider: Send + Sync {
|
||||
fn capabilities(&self) -> Caps;
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>>;
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry>;
|
||||
|
||||
/// 핵심 읽기 프리미티브. `range`가 None이면 파일 전체를 스트리밍한다.
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead>;
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()>;
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()>;
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
pub(crate) fn system_time_to_ms(t: std::time::SystemTime) -> i64 {
|
||||
t.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! 로컬 파일시스템 백엔드 — OS가 마운트한 네트워크 드라이브(UNC, Z:\, /Volumes)도
|
||||
//! 이 백엔드로 처리된다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
pub struct LocalFs;
|
||||
|
||||
impl LocalFs {
|
||||
pub fn new() -> Self {
|
||||
LocalFs
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalFs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_from_meta(name: String, meta: &std::fs::Metadata) -> VEntry {
|
||||
VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.len(),
|
||||
mtime_ms: meta
|
||||
.modified()
|
||||
.map(crate::system_time_to_ms)
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for LocalFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ
|
||||
| Caps::SERVER_MOVE
|
||||
| Caps::NATIVE_TRASH
|
||||
| Caps::MTIME_WRITE
|
||||
| Caps::PARALLEL_READS
|
||||
| Caps::WATCHABLE
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let mut rd = tokio::fs::read_dir(path).await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(e) = rd.next_entry().await? {
|
||||
let meta = match e.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue, // 접근 불가 항목은 건너뜀
|
||||
};
|
||||
out.push(entry_from_meta(e.file_name().to_string_lossy().into_owned(), &meta));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let name = Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
Ok(entry_from_meta(name, &meta))
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
match range {
|
||||
None => Ok(Box::new(file)),
|
||||
Some(r) => {
|
||||
file.seek(std::io::SeekFrom::Start(r.start)).await?;
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
Ok(Box::new(file.take(len)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
tokio::fs::rename(from, to).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
// 주의: 하드 삭제. 휴지통 삭제는 fileops 계층(trash 크레이트)이 담당하고,
|
||||
// VFS delete는 앱 레벨 휴지통 rename 이후의 퍼지 등 내부 용도로만 쓴다.
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
if meta.is_dir() {
|
||||
return Err(VfsError::Protocol("디렉터리 삭제는 지원하지 않음".into()));
|
||||
}
|
||||
tokio::fs::remove_file(path).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
tokio::fs::create_dir_all(path).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn range_read_returns_exact_window() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("a.bin");
|
||||
std::fs::write(&p, b"0123456789").unwrap();
|
||||
|
||||
let fs = LocalFs::new();
|
||||
let mut r = fs
|
||||
.open_range(p.to_str().unwrap(), Some(2..6))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"2345");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_dir_and_stat() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("한글파일.jpg"), b"xx").unwrap();
|
||||
std::fs::create_dir(dir.path().join("sub")).unwrap();
|
||||
|
||||
let fs = LocalFs::new();
|
||||
let entries = fs.list_dir(dir.path().to_str().unwrap()).await.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
let file = entries.iter().find(|e| !e.is_dir).unwrap();
|
||||
assert_eq!(file.name, "한글파일.jpg");
|
||||
assert_eq!(file.size, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! SFTP 백엔드 (russh + russh-sftp, 순수 Rust, ring 백엔드).
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use russh::client;
|
||||
use russh_sftp::client::SftpSession;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SftpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
struct ClientHandler;
|
||||
|
||||
impl client::Handler for ClientHandler {
|
||||
type Error = russh::Error;
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
// v1: TOFU 미구현 — 서버 키를 신뢰 (LAN/개인 NAS 가정).
|
||||
// 프로덕션 강화 시 known_hosts 저장/검증 추가.
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SftpFs {
|
||||
sftp: Arc<Mutex<SftpSession>>,
|
||||
base: String,
|
||||
// 세션 유지용 핸들 (drop되면 연결 종료)
|
||||
_handle: Arc<client::Handle<ClientHandler>>,
|
||||
}
|
||||
|
||||
impl SftpFs {
|
||||
pub async fn connect(cfg: &SftpConfig) -> Result<SftpFs> {
|
||||
let config = Arc::new(client::Config::default());
|
||||
let mut handle = client::connect(config, (cfg.host.as_str(), cfg.port), ClientHandler)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SSH 연결 실패: {e}")))?;
|
||||
let authed = handle
|
||||
.authenticate_password(&cfg.username, &cfg.password)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SSH 인증 실패: {e}")))?;
|
||||
if !authed.success() {
|
||||
return Err(VfsError::Protocol("SSH 인증 거부됨".into()));
|
||||
}
|
||||
let channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("채널 오류: {e}")))?;
|
||||
channel
|
||||
.request_subsystem(true, "sftp")
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SFTP 서브시스템 오류: {e}")))?;
|
||||
let sftp = SftpSession::new(channel.into_stream())
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("SFTP 세션 오류: {e}")))?;
|
||||
|
||||
Ok(SftpFs {
|
||||
sftp: Arc::new(Mutex::new(sftp)),
|
||||
base: cfg.base_path.trim_end_matches('/').to_string(),
|
||||
_handle: Arc::new(handle),
|
||||
})
|
||||
}
|
||||
|
||||
fn full(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else if p.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for SftpFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let entries = sftp
|
||||
.read_dir(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("read_dir: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for e in entries {
|
||||
let name = e.file_name();
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
let meta = e.metadata();
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.size.unwrap_or(0),
|
||||
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let meta = sftp
|
||||
.metadata(&full)
|
||||
.await
|
||||
.map_err(|_| VfsError::NotFound(path.to_string()))?;
|
||||
let name = full.rsplit('/').next().unwrap_or("").to_string();
|
||||
Ok(VEntry {
|
||||
name,
|
||||
is_dir: meta.is_dir(),
|
||||
size: meta.size.unwrap_or(0),
|
||||
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
let mut file = sftp
|
||||
.open(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("open: {e}")))?;
|
||||
match range {
|
||||
None => {
|
||||
// 전체를 메모리로 (SftpSession 핸들 수명 문제 회피)
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).await?;
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
Some(r) => {
|
||||
file.seek(std::io::SeekFrom::Start(r.start)).await?;
|
||||
let len = r.end.saturating_sub(r.start);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let n = file.read(&mut buf).await?;
|
||||
buf.truncate(n);
|
||||
Ok(Box::new(std::io::Cursor::new(buf)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let (f, t) = (self.full(from), self.full(to));
|
||||
let sftp = self.sftp.lock().await;
|
||||
sftp.rename(&f, &t)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("rename: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
sftp.remove_file(&full)
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("delete: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let full = self.full(path);
|
||||
let sftp = self.sftp.lock().await;
|
||||
// 존재하면 무시
|
||||
let _ = sftp.create_dir(&full).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! WebDAV 백엔드 (reqwest + rustls). 시놀로지 WebDAV Server 대상.
|
||||
//! Range를 그대로 지원해 원격 재생 시킹에 가장 유리하다.
|
||||
|
||||
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
|
||||
use reqwest_dav::{Auth, Client, ClientBuilder, Depth};
|
||||
use std::ops::Range;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebDavConfig {
|
||||
pub url: String, // 예: https://nas.example.com:5006/dav
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
pub struct WebDavFs {
|
||||
client: Client,
|
||||
base: String,
|
||||
// 원격 재생용 raw HTTP (Range 헤더 직접 전달)
|
||||
http: reqwest::Client,
|
||||
root_url: String,
|
||||
auth: (String, String),
|
||||
}
|
||||
|
||||
impl WebDavFs {
|
||||
pub async fn connect(cfg: &WebDavConfig) -> Result<WebDavFs> {
|
||||
let client = ClientBuilder::new()
|
||||
.set_host(cfg.url.clone())
|
||||
.set_auth(Auth::Basic(cfg.username.clone(), cfg.password.clone()))
|
||||
.build()
|
||||
.map_err(|e| VfsError::Protocol(format!("WebDAV 클라이언트: {e}")))?;
|
||||
let http = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|e| VfsError::Protocol(e.to_string()))?;
|
||||
Ok(WebDavFs {
|
||||
client,
|
||||
base: cfg.base_path.trim_matches('/').to_string(),
|
||||
http,
|
||||
root_url: cfg.url.trim_end_matches('/').to_string(),
|
||||
auth: (cfg.username.clone(), cfg.password.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
fn rel(&self, path: &str) -> String {
|
||||
let p = path.trim_start_matches('/');
|
||||
if self.base.is_empty() {
|
||||
format!("/{p}")
|
||||
} else {
|
||||
format!("/{}/{}", self.base, p)
|
||||
}
|
||||
}
|
||||
|
||||
fn abs_url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.root_url, self.rel(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl VfsProvider for WebDavFs {
|
||||
fn capabilities(&self) -> Caps {
|
||||
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
|
||||
}
|
||||
|
||||
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
|
||||
let rel = self.rel(path);
|
||||
let items = self
|
||||
.client
|
||||
.list(&rel, Depth::Number(1))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("PROPFIND: {e}")))?;
|
||||
// 요청 디렉터리 자신의 정규화 경로 (마지막 세그먼트)
|
||||
let req_seg = urldecode(rel.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
reqwest_dav::list_cmd::ListEntity::File(f) => {
|
||||
let name = urldecode(f.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: false,
|
||||
size: f.content_length.max(0) as u64,
|
||||
mtime_ms: f.last_modified.timestamp_millis(),
|
||||
});
|
||||
}
|
||||
reqwest_dav::list_cmd::ListEntity::Folder(d) => {
|
||||
let name = urldecode(d.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
|
||||
// 자기 자신(요청 디렉터리)은 제외 — 이름이 같으면 스킵
|
||||
if name.is_empty() || name == req_seg {
|
||||
continue;
|
||||
}
|
||||
out.push(VEntry {
|
||||
name,
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime_ms: d.last_modified.timestamp_millis(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<VEntry> {
|
||||
let rel = self.rel(path);
|
||||
let items = self
|
||||
.client
|
||||
.list(&rel, Depth::Number(0))
|
||||
.await
|
||||
.map_err(|_| VfsError::NotFound(path.to_string()))?;
|
||||
let name = rel.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string();
|
||||
match items.into_iter().next() {
|
||||
Some(reqwest_dav::list_cmd::ListEntity::File(f)) => Ok(VEntry {
|
||||
name: urldecode(&name),
|
||||
is_dir: false,
|
||||
size: f.content_length.max(0) as u64,
|
||||
mtime_ms: f.last_modified.timestamp_millis(),
|
||||
}),
|
||||
Some(reqwest_dav::list_cmd::ListEntity::Folder(d)) => Ok(VEntry {
|
||||
name: urldecode(&name),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
mtime_ms: d.last_modified.timestamp_millis(),
|
||||
}),
|
||||
None => Err(VfsError::NotFound(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
|
||||
use futures_util::TryStreamExt;
|
||||
let url = self.abs_url(path);
|
||||
let mut req = self.http.get(&url).basic_auth(&self.auth.0, Some(&self.auth.1));
|
||||
if let Some(r) = &range {
|
||||
req = req.header("Range", format!("bytes={}-{}", r.start, r.end.saturating_sub(1)));
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| VfsError::Protocol(format!("GET: {e}")))?;
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 206 {
|
||||
return Err(VfsError::Protocol(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
|
||||
Ok(Box::new(tokio_util_compat(stream)))
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
self.client
|
||||
.mv(&self.rel(from), &self.rel(to))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("MOVE: {e}")))
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
self.client
|
||||
.delete(&self.rel(path))
|
||||
.await
|
||||
.map_err(|e| VfsError::Protocol(format!("DELETE: {e}")))
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let _ = self.client.mkcol(&self.rel(path)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// bytes 스트림을 AsyncRead로 변환
|
||||
fn tokio_util_compat<S>(stream: S) -> impl tokio::io::AsyncRead + Send + Unpin
|
||||
where
|
||||
S: futures_util::Stream<Item = std::io::Result<bytes::Bytes>> + Send + Unpin + 'static,
|
||||
{
|
||||
tokio_util::io::StreamReader::new(stream)
|
||||
}
|
||||
|
||||
/// 최소 URL 디코드 (%XX + 경로 세그먼트)
|
||||
fn urldecode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_decode() {
|
||||
assert_eq!(urldecode("%ED%95%9C%EA%B8%80"), "한글");
|
||||
assert_eq!(urldecode("plain.jpg"), "plain.jpg");
|
||||
assert_eq!(urldecode("a%20b.png"), "a b.png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! WebDAV 백엔드 통합 테스트 — 자체 완결형(in-process) 최소 WebDAV 서버 대상.
|
||||
//! PROPFIND(Depth 1) + Range GET을 검증한다 (브라우즈 + 원격 재생 경로).
|
||||
|
||||
use archive_vfs::webdav::{WebDavConfig, WebDavFs};
|
||||
use archive_vfs::VfsProvider;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// 아주 작은 WebDAV 서버: 고정 디렉터리 하나(/dav)에 파일 2개.
|
||||
/// PROPFIND → multistatus XML, GET → Range 지원.
|
||||
async fn run_server() -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = handle(&mut sock).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
const FILE_A: &[u8] = b"0123456789ABCDEF"; // 16바이트
|
||||
const FILE_B: &[u8] = b"hello webdav world"; // 18바이트
|
||||
|
||||
async fn handle(sock: &mut tokio::net::TcpStream) -> Result<(), Infallible> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let method = req.split_whitespace().next().unwrap_or("");
|
||||
let path = req.split_whitespace().nth(1).unwrap_or("/");
|
||||
|
||||
let response: Vec<u8> = match method {
|
||||
"PROPFIND" => {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:">
|
||||
<D:response><D:href>/dav/</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
|
||||
<D:response><D:href>/dav/a.jpg</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>{}</D:getcontentlength>
|
||||
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
|
||||
<D:response><D:href>/dav/b.mp4</D:href><D:propstat><D:prop>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength>{}</D:getcontentlength>
|
||||
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
|
||||
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
|
||||
</D:multistatus>"#,
|
||||
FILE_A.len(),
|
||||
FILE_B.len()
|
||||
);
|
||||
http_response("207 Multi-Status", "application/xml", xml.as_bytes(), None)
|
||||
}
|
||||
"GET" => {
|
||||
let body = if path.contains("a.jpg") { FILE_A } else { FILE_B };
|
||||
// Range 헤더 파싱
|
||||
if let Some(range_line) = req.lines().find(|l| l.to_ascii_lowercase().starts_with("range:")) {
|
||||
let spec = range_line.split("bytes=").nth(1).unwrap_or("").trim();
|
||||
let (s, e) = spec.split_once('-').unwrap_or(("0", ""));
|
||||
let start: usize = s.trim().parse().unwrap_or(0);
|
||||
let end: usize = e.trim().parse().unwrap_or(body.len() - 1);
|
||||
let slice = &body[start..=end.min(body.len() - 1)];
|
||||
let cr = format!("bytes {}-{}/{}", start, end.min(body.len() - 1), body.len());
|
||||
http_response("206 Partial Content", "application/octet-stream", slice, Some(&cr))
|
||||
} else {
|
||||
http_response("200 OK", "application/octet-stream", body, None)
|
||||
}
|
||||
}
|
||||
_ => http_response("405 Method Not Allowed", "text/plain", b"", None),
|
||||
};
|
||||
let _ = sock.write_all(&response).await;
|
||||
let _ = sock.flush().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_response(status: &str, ctype: &str, body: &[u8], content_range: Option<&str>) -> Vec<u8> {
|
||||
let mut head = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n",
|
||||
body.len()
|
||||
);
|
||||
if let Some(cr) = content_range {
|
||||
head.push_str(&format!("Content-Range: {cr}\r\n"));
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
let mut out = head.into_bytes();
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webdav_list_and_range_read() {
|
||||
let addr = run_server().await;
|
||||
let fs = WebDavFs::connect(&WebDavConfig {
|
||||
url: format!("http://{addr}/dav"),
|
||||
username: "u".into(),
|
||||
password: "p".into(),
|
||||
base_path: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// list_dir → 파일 2개 (디렉터리 자신 제외)
|
||||
let entries = fs.list_dir("").await.unwrap();
|
||||
let files: Vec<_> = entries.iter().filter(|e| !e.is_dir).collect();
|
||||
assert_eq!(files.len(), 2, "파일 2개여야 함: {entries:?}");
|
||||
let a = files.iter().find(|e| e.name == "a.jpg").expect("a.jpg 없음");
|
||||
assert_eq!(a.size, FILE_A.len() as u64);
|
||||
|
||||
// 전체 읽기
|
||||
let mut r = fs.open_range("a.jpg", None).await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, FILE_A);
|
||||
|
||||
// Range 읽기 (재생 시킹 시나리오)
|
||||
let mut r = fs.open_range("a.jpg", Some(4..10)).await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
r.read_to_end(&mut buf).await.unwrap();
|
||||
assert_eq!(buf, &FILE_A[4..10]);
|
||||
}
|
||||
Reference in New Issue
Block a user