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
+105
View File
@@ -0,0 +1,105 @@
//! HTTP Range 헤더 파싱 (단일 범위만 지원 — 웹뷰 `<video>`/`<img>`는 단일 범위만 보낸다).
#[derive(Debug, PartialEq, Eq)]
pub enum RangeSpec {
/// Range 헤더 없음 또는 무시 가능한 형식 → 전체 응답 (200)
Full,
/// 206 — [start, end] (inclusive)
Partial(u64, u64),
/// 416 — 만족 불가
Unsatisfiable,
}
/// `Range: bytes=a-b` / `bytes=a-` / `bytes=-suffix` 파싱.
pub fn parse_range(header: Option<&str>, len: u64) -> RangeSpec {
let Some(header) = header else {
return RangeSpec::Full;
};
let Some(spec) = header.strip_prefix("bytes=") else {
return RangeSpec::Full; // 알 수 없는 단위는 무시하고 전체 응답
};
// 다중 범위는 첫 범위만 사용
let first = spec.split(',').next().unwrap_or("").trim();
let Some((start_s, end_s)) = first.split_once('-') else {
return RangeSpec::Full;
};
if len == 0 {
return RangeSpec::Unsatisfiable;
}
match (start_s.is_empty(), end_s.is_empty()) {
// bytes=-suffix : 마지막 suffix 바이트
(true, false) => match end_s.parse::<u64>() {
Ok(0) => RangeSpec::Unsatisfiable,
Ok(suffix) => {
let start = len.saturating_sub(suffix);
RangeSpec::Partial(start, len - 1)
}
Err(_) => RangeSpec::Full,
},
// bytes=start- : start부터 끝까지
(false, true) => match start_s.parse::<u64>() {
Ok(start) if start < len => RangeSpec::Partial(start, len - 1),
Ok(_) => RangeSpec::Unsatisfiable,
Err(_) => RangeSpec::Full,
},
// bytes=start-end
(false, false) => match (start_s.parse::<u64>(), end_s.parse::<u64>()) {
(Ok(start), Ok(end)) => {
if start > end || start >= len {
RangeSpec::Unsatisfiable
} else {
RangeSpec::Partial(start, end.min(len - 1))
}
}
_ => RangeSpec::Full,
},
(true, true) => RangeSpec::Full,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_header_is_full() {
assert_eq!(parse_range(None, 100), RangeSpec::Full);
}
#[test]
fn simple_range() {
assert_eq!(parse_range(Some("bytes=0-49"), 100), RangeSpec::Partial(0, 49));
assert_eq!(parse_range(Some("bytes=50-"), 100), RangeSpec::Partial(50, 99));
assert_eq!(parse_range(Some("bytes=-10"), 100), RangeSpec::Partial(90, 99));
}
#[test]
fn end_clamped_to_len() {
assert_eq!(parse_range(Some("bytes=90-200"), 100), RangeSpec::Partial(90, 99));
}
#[test]
fn unsatisfiable() {
assert_eq!(parse_range(Some("bytes=100-"), 100), RangeSpec::Unsatisfiable);
assert_eq!(parse_range(Some("bytes=200-300"), 100), RangeSpec::Unsatisfiable);
assert_eq!(parse_range(Some("bytes=5-2"), 100), RangeSpec::Unsatisfiable);
assert_eq!(parse_range(Some("bytes=0-"), 0), RangeSpec::Unsatisfiable);
}
#[test]
fn garbage_falls_back_to_full() {
assert_eq!(parse_range(Some("items=0-5"), 100), RangeSpec::Full);
assert_eq!(parse_range(Some("bytes=abc-def"), 100), RangeSpec::Full);
assert_eq!(parse_range(Some("bytes=-"), 100), RangeSpec::Full);
}
#[test]
fn multi_range_uses_first() {
assert_eq!(
parse_range(Some("bytes=0-10, 20-30"), 100),
RangeSpec::Partial(0, 10)
);
}
}