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,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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user