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
+111
View File
@@ -0,0 +1,111 @@
//! 미디어 서버용 file_id → 경로/원격스트림 리졸버 (DB 조회 + VFS).
use crate::remote::RemotePool;
use archive_db::Db;
use archive_media::RemoteStream;
use std::path::PathBuf;
use std::sync::Arc;
pub struct DbResolver {
pub db: Arc<Db>,
pub thumb_root: PathBuf,
pub remote: Arc<RemotePool>,
}
struct FileLoc {
kind: String, // sources.kind
source_id: i64,
dir: String,
name: String,
size: i64,
}
impl DbResolver {
fn locate(&self, file_id: i64) -> Option<FileLoc> {
self.db
.with_read(|conn| {
conn.query_row(
"SELECT s.kind, f.source_id, fo.path, f.name, f.size
FROM files f
JOIN folders fo ON fo.id = f.folder_id
JOIN sources s ON s.id = f.source_id
WHERE f.id = ?1 AND f.deleted_at IS NULL",
[file_id],
|r| {
Ok(FileLoc {
kind: r.get(0)?,
source_id: r.get(1)?,
dir: r.get(2)?,
name: r.get(3)?,
size: r.get(4)?,
})
},
)
})
.ok()
}
}
#[async_trait::async_trait]
impl archive_media::PathResolver for DbResolver {
fn resolve(&self, file_id: i64) -> Option<PathBuf> {
let loc = self.locate(file_id)?;
if loc.kind != "local" {
return None;
}
Some(PathBuf::from(loc.dir).join(loc.name))
}
fn resolve_thumb(&self, file_id: i64, size_class: u8) -> Option<PathBuf> {
let key: String = self
.db
.with_read(|conn| {
conn.query_row(
"SELECT cache_key FROM thumbs WHERE file_id = ?1 AND size_class = ?2",
rusqlite::params![file_id, size_class],
|r| r.get(0),
)
})
.ok()?;
let path = archive_indexer::thumbq::thumb_path(&self.thumb_root, size_class, &key);
path.exists().then_some(path)
}
fn is_remote(&self, file_id: i64) -> bool {
self.locate(file_id).map(|l| l.kind != "local").unwrap_or(false)
}
async fn open_remote(
&self,
file_id: i64,
range: Option<std::ops::Range<u64>>,
) -> Option<RemoteStream> {
let loc = self.locate(file_id)?;
let provider = self.remote.get(loc.source_id).await?;
// 원격 상대 경로: 폴더 path + name (루트는 빈 문자열)
let rel = if loc.dir == "/" || loc.dir.is_empty() {
loc.name.clone()
} else {
format!("{}/{}", loc.dir.trim_start_matches('/'), loc.name)
};
let mime = mime_guess::from_path(&loc.name).first_or_octet_stream().to_string();
// 0바이트 요청(HEAD 격)이면 길이만 반환
if let Some(r) = &range {
if r.start == 0 && r.end == 0 {
return Some(RemoteStream {
total_len: loc.size.max(0) as u64,
reader: Box::new(tokio::io::empty()),
mime,
});
}
}
let reader = provider.open_range(&rel, range).await.ok()?;
Some(RemoteStream {
total_len: loc.size.max(0) as u64,
reader,
mime,
})
}
}