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