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>
106 lines
3.5 KiB
Rust
106 lines
3.5 KiB
Rust
//! 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)
|
|
);
|
|
}
|
|
}
|