Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램

Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼
사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드,
인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포.

- archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram
- archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트)
- archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수
- archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡
- 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널

Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-16 22:04:06 +09:00
co-authored by Claude Opus 4.8
commit 394d1b9805
132 changed files with 23468 additions and 0 deletions
+133
View File
@@ -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);
}
}