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>
112 lines
3.4 KiB
Rust
112 lines
3.4 KiB
Rust
//! 미디어 서버용 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,
|
|
})
|
|
}
|
|
}
|