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
+13
View File
@@ -0,0 +1,13 @@
# 텍스트 파일은 저장소에 LF로 정규화, 체크아웃 시 OS 기본
* text=auto eol=lf
# Windows 스크립트는 CRLF 유지
*.cmd text eol=crlf
*.ps1 text eol=crlf
# 바이너리
*.png binary
*.ico binary
*.icns binary
*.jpg binary
*.mp4 binary
+111
View File
@@ -0,0 +1,111 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
# ── Windows x64: 테스트 + 포터블 zip ───────────────────────
windows-x64:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: npm ci
- name: ffmpeg 사이드카 (x64)
run: pwsh scripts/fetch-ffmpeg.ps1 -Target win64
- name: 코어 크레이트 테스트 (ffmpeg/네트워크 통합 포함)
working-directory: src-tauri
run: cargo test --workspace
- name: Tauri 빌드 (x64)
run: npm run tauri build -- --no-bundle
- name: 포터블 zip 패키징
run: pwsh scripts/package-portable.ps1 -Target x64
- uses: actions/upload-artifact@v4
with:
name: archive-windows-x64-portable
path: dist-portable/*.zip
# ── Windows ARM64: 크로스컴파일 (스파이크 ① 상시 검증) ──────
windows-arm64-cross:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
key: arm64-cross
- run: npm ci
- name: ffmpeg 사이드카 (ARM64)
run: pwsh scripts/fetch-ffmpeg.ps1 -Target winarm64
- name: Tauri 빌드 (ARM64 크로스)
run: npm run tauri build -- --target aarch64-pc-windows-msvc --no-bundle
- name: 포터블 zip 패키징 (ARM64)
run: pwsh scripts/package-portable.ps1 -Target arm64
- uses: actions/upload-artifact@v4
with:
name: archive-windows-arm64-portable
path: dist-portable/*.zip
# ── Windows ARM64 네이티브 러너: 실제 ARM64에서 테스트 실행 ──
windows-arm64-native:
runs-on: windows-11-arm
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
key: arm64-native
- name: 코어 크레이트 테스트 (ARM64 네이티브)
working-directory: src-tauri
run: cargo test --workspace
# ── macOS: Intel + Apple Silicon ────────────────────────────
macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: npm ci
- name: ffmpeg 사이드카 (universal — lipo)
run: pwsh scripts/fetch-ffmpeg-macos.ps1
- name: 코어 크레이트 테스트 (헤드리스)
working-directory: src-tauri
run: cargo test --workspace
# 배포 빌드 시 서명/공증: APPLE_CERTIFICATE 등 시크릿이 있으면 tauri-action이 처리.
# 미서명 빌드는 Gatekeeper에서 우클릭-열기 필요 (README 안내).
- name: Tauri 빌드 (universal .app + dmg)
run: npm run tauri build -- --target universal-apple-darwin
- uses: actions/upload-artifact@v4
with:
name: archive-macos-universal
path: src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
.fixtures/
.devdata/
.devdata-*/
dist-portable/
src-tauri/binaries/
src-tauri/target/
src-tauri/gen/
data/
*.log
.DS_Store
Thumbs.db
+67
View File
@@ -0,0 +1,67 @@
# Archive
수만 개의 사진·영상을 빠르게 조회·재생하면서 삭제/태그/이동으로 관리하고,
중복(완전 동일 + 유사)을 찾아 제거하는 크로스플랫폼 데스크톱 앱.
- **플랫폼**: Windows x64 / Windows ARM64 / macOS (Intel + Apple Silicon)
- **배포**: 포터블(무설치) 우선 — exe 옆 `data/` 폴더가 있으면 포터블 모드로 동작
- **스택**: Tauri v2 (Rust) + SolidJS + SQLite + ffmpeg 사이드카
- **소스**: 로컬 디스크, OS 마운트 드라이브(UNC/SMB), 앱내 FTP/SFTP/WebDAV (시놀로지 등)
## 개발
요구사항: Node 22+, Rust stable(1.95+), (Windows) WebView2 런타임 — Win10/11 기본 탑재.
> **이 PC 주의사항**: VS Community 2026에는 MSVC 데스크톱 라이브러리가 없어
> rustc가 링크에 실패한다(LNK1104: msvcrt.lib). 반드시 **VS Build Tools 2026의
> vcvars64 환경**에서 cargo를 실행해야 하며, 아래 스크립트가 이를 대신 처리한다.
```bat
npm install
scripts\dev.cmd :: 개발 실행 (vcvars64 + tauri dev)
scripts\test.cmd :: 코어 크레이트 헤드리스 테스트 (vcvars64 + cargo test)
```
Windows ARM64 크로스컴파일은 로컬에 ARM64 MSVC 라이브러리가 없어 CI에서만 검증된다.
로컬에서 하려면 VS Installer에서 "MSVC ARM64/AArch64 빌드 도구" 컴포넌트를 설치할 것.
## 구조
```
src/ SolidJS 프론트엔드 (3-pane UI, 가상 그리드)
src-tauri/ Tauri 셸 (커맨드, 데이터 디렉터리, 미디어 서버 기동)
crates/
archive-db SQLite 스키마·마이그레이션·단일 writer 스레드
archive-vfs VFS 추상화 (local / sftp / ftp / webdav)
archive-indexer 스캔·해시·썸네일·중복탐지 엔진
archive-media localhost HTTP 미디어 서버 (Range 스트리밍, ffmpeg 잡)
```
## 배포
**Windows 포터블 (무설치)**
```bat
scripts\fetch-ffmpeg.ps1 -Target win64 :: ffmpeg 사이드카 다운로드
npm run tauri build -- --no-bundle :: 릴리스 빌드
scripts\package-portable.ps1 -Target x64 :: Archive-<ver>-windows-x64.zip 생성
```
zip을 풀고 `Archive.exe`를 실행하면 됩니다. exe 옆 `data\` 폴더에 인덱스·썸네일이
저장되므로 폴더째 옮겨도 동작합니다. WebView2 런타임(Win10/11 기본 탑재)이 필요합니다.
**macOS (universal)**
CI(macos-14)에서 `scripts/fetch-ffmpeg-macos.ps1`로 arm64+x64 ffmpeg를 lipo 결합한 뒤
`tauri build --target universal-apple-darwin`으로 `.dmg`를 만듭니다. 배포 시 Apple
Developer ID 서명·공증이 필요하며, 미서명 빌드는 첫 실행 시 우클릭 → 열기로 Gatekeeper를
통과시켜야 합니다.
**Windows ARM64**: CI에서 크로스컴파일로 검증됩니다(로컬은 ARM64 MSVC 툴체인 필요).
## 유지보수
앱 시작 시 백그라운드로 DB 백업(`archive.db.bak`, VACUUM INTO), 30일 지난 삭제 항목
하드 퍼지, 썸네일 캐시 LRU 정리(기본 상한 4GB)를 수행합니다.
설계 문서: 계획서 참조 (`~/.claude/plans/reflective-watching-toucan.md`).
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Archive</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2855
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "archive",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-shell": "^2",
"solid-js": "^1.9.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"@tauri-apps/cli": "^2",
"tailwindcss": "^4",
"typescript": "^5.6.0",
"vite": "^6",
"vite-plugin-solid": "^2.11.0"
}
}
+53
View File
@@ -0,0 +1,53 @@
# WebView2 CDP (Chrome DevTools Protocol) E2E helper.
# Usage: . .\scripts\cdp.ps1; Invoke-Cdp "document.title"
# Prereq: app launched with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9223
# NOTE: ASCII only in this file (PS 5.1 reads BOM-less files as ANSI).
function Invoke-Cdp {
param(
[Parameter(Mandatory)] [string]$Expression,
[int]$Port = 9223,
[int]$TimeoutSec = 20
)
$targets = Invoke-RestMethod "http://127.0.0.1:$Port/json"
$page = $targets | Where-Object { $_.type -eq 'page' -and $_.url -notlike 'devtools*' } | Select-Object -First 1
if (-not $page) { throw "no CDP page target" }
$ws = New-Object System.Net.WebSockets.ClientWebSocket
$ct = [System.Threading.CancellationToken]::None
$uri = [Uri]$page.webSocketDebuggerUrl
$ws.ConnectAsync($uri, $ct).GetAwaiter().GetResult() | Out-Null
$msg = @{
id = 1
method = "Runtime.evaluate"
params = @{
expression = $Expression
returnByValue = $true
awaitPromise = $true
}
} | ConvertTo-Json -Depth 6 -Compress
$bytes = [System.Text.Encoding]::UTF8.GetBytes($msg)
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(,$bytes)
$ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult() | Out-Null
$buffer = New-Object byte[] 1048576
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$response = $null
while ((Get-Date) -lt $deadline) {
$sb = New-Object System.Text.StringBuilder
do {
$rseg = New-Object System.ArraySegment[byte] -ArgumentList @(,$buffer)
$result = $ws.ReceiveAsync($rseg, $ct).GetAwaiter().GetResult()
$sb.Append([System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)) | Out-Null
} while (-not $result.EndOfMessage)
$obj = $sb.ToString() | ConvertFrom-Json
if ($obj.id -eq 1) { $response = $obj; break }
}
$ws.Abort()
if (-not $response) { throw "CDP response timeout" }
if ($response.result.exceptionDetails) {
throw "JS exception: $($response.result.exceptionDetails.text) $($response.result.exceptionDetails.exception.description)"
}
return $response.result.result.value
}
+6
View File
@@ -0,0 +1,6 @@
@echo off
rem Archive 개발 실행 — rustc가 라이브러리 없는 VS Community를 잡지 않도록
rem Build Tools vcvars64 환경에서 tauri dev를 띄운다.
call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul
cd /d "%~dp0.."
npm run tauri dev %*
+34
View File
@@ -0,0 +1,34 @@
# macOS용 ffmpeg/ffprobe 사이드카 — arm64 + x64를 받아 lipo로 universal 생성.
# CI(macos-14)에서 실행. 실패 시 개별 아키텍처 바이너리라도 배치한다.
# 참고: Martin Riedl 빌드(ffmpeg.martin-riedl.de) — 서명·공증됨.
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
$binDir = Join-Path $root "src-tauri/binaries"
New-Item -ItemType Directory -Force $binDir | Out-Null
# Riedl 최신 릴리스 API (arm64/amd64 각각 ffmpeg/ffprobe zip)
$api = "https://ffmpeg.martin-riedl.de/api/v1/latest"
$release = Invoke-RestMethod $api
function Get-Tool($arch, $tool) {
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "ff-$arch-$tool"
New-Item -ItemType Directory -Force $tmp | Out-Null
$entry = $release.$tool.$arch
if (-not $entry) { throw "$tool/$arch 다운로드 정보 없음" }
$zip = Join-Path $tmp "$tool.zip"
Invoke-WebRequest $entry.url -OutFile $zip
Expand-Archive $zip -DestinationPath $tmp -Force
$bin = Get-ChildItem $tmp -Recurse -Filter $tool | Select-Object -First 1
return $bin.FullName
}
foreach ($tool in @("ffmpeg", "ffprobe")) {
$arm = Get-Tool "arm64" $tool
$x64 = Get-Tool "amd64" $tool
$out = Join-Path $binDir "$tool-universal-apple-darwin"
# lipo로 universal 결합
& lipo -create $arm $x64 -output $out
& chmod +x $out
Write-Host "[fetch-ffmpeg-macos] $out (universal)"
}
+61
View File
@@ -0,0 +1,61 @@
# ffmpeg/ffprobe 사이드카 다운로드 (BtbN FFmpeg-Builds, LGPL 정적 빌드)
# 사용: .\scripts\fetch-ffmpeg.ps1 [-Target win64|winarm64]
# 결과: src-tauri\binaries\ffmpeg-<triple>.exe, ffprobe-<triple>.exe + manifest
param(
[ValidateSet("win64", "winarm64")]
[string]$Target = "win64"
)
$ErrorActionPreference = "Stop"
$repo = "BtbN/FFmpeg-Builds"
$triple = if ($Target -eq "win64") { "x86_64-pc-windows-msvc" } else { "aarch64-pc-windows-msvc" }
$root = Split-Path $PSScriptRoot -Parent
$binDir = Join-Path $root "src-tauri\binaries"
New-Item -ItemType Directory -Force $binDir | Out-Null
if ((Test-Path "$binDir\ffmpeg-$triple.exe") -and (Test-Path "$binDir\ffprobe-$triple.exe")) {
Write-Host "[fetch-ffmpeg] 이미 존재: $triple — 건너뜀 (강제 갱신은 파일 삭제 후 재실행)"
exit 0
}
Write-Host "[fetch-ffmpeg] BtbN 'latest' 릴리스 자산 조회..."
$release = Invoke-RestMethod "https://api.github.com/repos/$repo/releases/tags/latest" -Headers @{ "User-Agent" = "archive-build" }
# 버전 브랜치(nX.Y) LGPL 정적 zip 우선, 없으면 master
$pattern = "^ffmpeg-n[\d\.]+-latest-$Target-lgpl-[\d\.]+\.zip$"
$asset = $release.assets | Where-Object { $_.name -match $pattern } | Sort-Object name -Descending | Select-Object -First 1
if (-not $asset) {
$asset = $release.assets | Where-Object { $_.name -eq "ffmpeg-master-latest-$Target-lgpl.zip" } | Select-Object -First 1
}
if (-not $asset) { throw "LGPL $Target 자산을 찾을 수 없습니다" }
Write-Host "[fetch-ffmpeg] 다운로드: $($asset.name) ($([math]::Round($asset.size / 1MB, 1)) MB)"
$tmp = Join-Path $env:TEMP "ffmpeg-fetch"
New-Item -ItemType Directory -Force $tmp | Out-Null
$zipPath = Join-Path $tmp $asset.name
Invoke-WebRequest $asset.browser_download_url -OutFile $zipPath -UseBasicParsing
$sha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash
Write-Host "[fetch-ffmpeg] SHA256: $sha256"
$extract = Join-Path $tmp "extract"
Remove-Item -Recurse -Force $extract -ErrorAction SilentlyContinue
Expand-Archive $zipPath -DestinationPath $extract
$ffmpeg = Get-ChildItem $extract -Recurse -Filter "ffmpeg.exe" | Select-Object -First 1
$ffprobe = Get-ChildItem $extract -Recurse -Filter "ffprobe.exe" | Select-Object -First 1
if (-not $ffmpeg -or -not $ffprobe) { throw "zip 안에서 ffmpeg/ffprobe.exe를 찾지 못함" }
Copy-Item $ffmpeg.FullName "$binDir\ffmpeg-$triple.exe" -Force
Copy-Item $ffprobe.FullName "$binDir\ffprobe-$triple.exe" -Force
@{
asset = $asset.name
sha256 = $sha256
target = $Target
triple = $triple
fetched = (Get-Date).ToString("o")
} | ConvertTo-Json | Set-Content "$binDir\manifest-$Target.json" -Encoding utf8
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
Write-Host "[fetch-ffmpeg] 완료: $binDir\ffmpeg-$triple.exe"
+62
View File
@@ -0,0 +1,62 @@
# Windows 포터블 zip 패키징.
# 사용: .\scripts\package-portable.ps1 [-Target x64|arm64]
# 결과: dist-portable\Archive-<ver>-windows-<arch>.zip
# 내용: Archive.exe, ffmpeg.exe, ffprobe.exe, data\(빈 폴더 → 포터블 모드), README.txt
param(
[ValidateSet("x64", "arm64")]
[string]$Target = "x64"
)
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
$triple = if ($Target -eq "x64") { "x86_64-pc-windows-msvc" } else { "aarch64-pc-windows-msvc" }
# 버전 (tauri.conf.json)
$conf = Get-Content "$root\src-tauri\tauri.conf.json" -Raw | ConvertFrom-Json
$ver = $conf.version
$exe = if ($Target -eq "x64") {
"$root\src-tauri\target\release\archive.exe"
} else {
"$root\src-tauri\target\$triple\release\archive.exe"
}
if (-not (Test-Path $exe)) {
throw "빌드 산출물이 없습니다: $exe (먼저 tauri build --no-bundle 실행)"
}
$ffmpeg = "$root\src-tauri\binaries\ffmpeg-$triple.exe"
$ffprobe = "$root\src-tauri\binaries\ffprobe-$triple.exe"
if (-not (Test-Path $ffmpeg)) {
throw "ffmpeg 사이드카 없음: $ffmpeg (scripts\fetch-ffmpeg.ps1 -Target win$($Target -replace 'x','') 먼저 실행)"
}
$stage = "$root\dist-portable\stage-$Target"
Remove-Item -Recurse -Force $stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force "$stage\data" | Out-Null
Copy-Item $exe "$stage\Archive.exe"
Copy-Item $ffmpeg "$stage\ffmpeg.exe"
Copy-Item $ffprobe "$stage\ffprobe.exe"
@"
Archive $ver ()
: Archive.exe .
- data\ (archive.db) .
USB/ PC .
- WebView2 . Windows 11 Windows 10
. :
https://developer.microsoft.com/microsoft-edge/webview2/
data\logs\ .
"@ | Set-Content "$stage\README.txt" -Encoding utf8
$outDir = "$root\dist-portable"
$zip = "$outDir\Archive-$ver-windows-$Target.zip"
Remove-Item $zip -ErrorAction SilentlyContinue
Compress-Archive -Path "$stage\*" -DestinationPath $zip -CompressionLevel Optimal
Remove-Item -Recurse -Force $stage
$sizeMB = [math]::Round((Get-Item $zip).Length / 1MB, 1)
Write-Host "[package-portable] 완료: $zip ($sizeMB MB)"
+5
View File
@@ -0,0 +1,5 @@
@echo off
rem 코어 크레이트 헤드리스 테스트 (Build Tools vcvars64 환경)
call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" >nul
cd /d "%~dp0..\src-tauri"
cargo test --workspace %*
+8568
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
[workspace]
members = [
"crates/archive-db",
"crates/archive-vfs",
"crates/archive-indexer",
"crates/archive-media",
]
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
bytes = "1"
async-trait = "0.1"
crossbeam-channel = "0.5"
[package]
name = "archive"
version = "0.1.0"
description = "사진/영상 라이브러리 관리 프로그램"
edition = "2021"
rust-version = "1.80"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
archive-db = { path = "crates/archive-db" }
archive-vfs = { path = "crates/archive-vfs" }
archive-indexer = { path = "crates/archive-indexer" }
archive-media = { path = "crates/archive-media" }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dirs = "6"
rusqlite = { version = "0.40", features = ["bundled"] }
notify-debouncer-full = "0.7"
crossbeam-channel = { workspace = true }
async-trait = { workspace = true }
mime_guess = "2"
[profile.release]
lto = "thin"
strip = "symbols"
# 개발 빌드에서도 의존성(이미지 디코더, 해시, sqlite 등)은 최적화한다.
# 자체 크레이트만 디버그 빌드 — 이미지 디코드가 10배 이상 빨라진다.
[profile.dev.package."*"]
opt-level = 2
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Archive 메인 윈도우 기본 권한",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"shell:default"
]
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "archive-db"
version = "0.1.0"
edition = "2021"
[dependencies]
rusqlite = { version = "0.40", features = ["bundled"] }
crossbeam-channel = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = "3"
+239
View File
@@ -0,0 +1,239 @@
//! SQLite 인덱스 접근 계층.
//!
//! 아키텍처: **단일 writer 스레드**(crossbeam 채널로 잡 수신, 배치 트랜잭션)
//! + 읽기전용 커넥션 풀. WAL 모드라 읽기는 쓰기에 블록되지 않는다.
pub mod migrations;
use crossbeam_channel::{unbounded, Sender};
use rusqlite::{Connection, OpenFlags};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::thread::JoinHandle;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("sqlite 오류: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("DB가 이미 닫혔습니다")]
Closed,
}
pub type Result<T> = std::result::Result<T, DbError>;
type WriteJob = Box<dyn FnOnce(&mut Connection) + Send + 'static>;
pub struct Db {
path: PathBuf,
write_tx: Option<Sender<WriteJob>>,
writer: Option<JoinHandle<()>>,
readers: Mutex<Vec<Connection>>,
pub fts_enabled: bool,
}
impl Db {
/// DB를 열고(없으면 생성) 마이그레이션을 적용한 뒤 writer 스레드를 시작한다.
pub fn open(db_path: &Path) -> Result<Db> {
let mut conn = Connection::open(db_path)?;
apply_writer_pragmas(&conn)?;
let fts_enabled = migrations::migrate(&mut conn)?;
let (tx, rx) = unbounded::<WriteJob>();
let writer = std::thread::Builder::new()
.name("db-writer".into())
.spawn(move || {
while let Ok(job) = rx.recv() {
job(&mut conn);
}
// 종료 정리: 체크포인트 + 통계 갱신
let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA optimize;");
})
.expect("db-writer 스레드 생성 실패");
Ok(Db {
path: db_path.to_path_buf(),
write_tx: Some(tx),
writer: Some(writer),
readers: Mutex::new(Vec::new()),
fts_enabled,
})
}
/// writer 스레드에서 클로저를 실행하고 결과를 동기적으로 기다린다.
pub fn with_write<T, F>(&self, f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
let tx = self.write_tx.as_ref().ok_or(DbError::Closed)?;
let (rtx, rrx) = std::sync::mpsc::sync_channel::<rusqlite::Result<T>>(1);
tx.send(Box::new(move |conn| {
let _ = rtx.send(f(conn));
}))
.map_err(|_| DbError::Closed)?;
rrx.recv().map_err(|_| DbError::Closed)?.map_err(Into::into)
}
/// 결과를 기다리지 않는 쓰기(스캔 배치 등 대량 파이프라인용).
pub fn write_detached<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut Connection) + Send + 'static,
{
let tx = self.write_tx.as_ref().ok_or(DbError::Closed)?;
tx.send(Box::new(f)).map_err(|_| DbError::Closed)
}
/// 읽기전용 커넥션으로 쿼리를 실행한다 (풀에서 재사용).
pub fn with_read<T>(&self, f: impl FnOnce(&Connection) -> rusqlite::Result<T>) -> Result<T> {
let conn = match self.readers.lock().unwrap().pop() {
Some(c) => c,
None => self.open_reader()?,
};
let result = f(&conn);
// 풀 크기 상한: 커넥션 4개
let mut pool = self.readers.lock().unwrap();
if pool.len() < 4 {
pool.push(conn);
}
drop(pool);
result.map_err(Into::into)
}
fn open_reader(&self) -> Result<Connection> {
let conn = Connection::open_with_flags(
&self.path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
apply_reader_pragmas(&conn)?;
Ok(conn)
}
}
impl Drop for Db {
fn drop(&mut self) {
// 채널을 닫으면 writer 루프가 종료된다
self.write_tx.take();
if let Some(h) = self.writer.take() {
let _ = h.join();
}
}
}
fn apply_writer_pragmas(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
common_pragmas(conn)
}
fn apply_reader_pragmas(conn: &Connection) -> rusqlite::Result<()> {
// journal_mode는 DB 영속 속성이라 읽기전용 커넥션에서는 건드리지 않는다
common_pragmas(conn)
}
fn common_pragmas(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "cache_size", -64000)?;
conn.pragma_update(None, "mmap_size", 268_435_456i64)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn open_temp() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("test.db")).unwrap();
(dir, db)
}
fn insert_fixture_file(db: &Db, name: &str) -> i64 {
db.with_write({
let name = name.to_string();
move |conn| {
conn.execute(
"INSERT OR IGNORE INTO sources(id, kind, name, root) VALUES (1,'local','테스트','C:\\media')",
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO folders(id, source_id, path, name) VALUES (1,1,'C:\\media','media')",
[],
)?;
conn.execute(
"INSERT INTO files(source_id, folder_id, name, ext, kind, size, mtime_ms)
VALUES (1, 1, ?1, 'jpg', 0, 1000, 0)",
[&name],
)?;
Ok(conn.last_insert_rowid())
}
})
.unwrap()
}
/// M0 스파이크 ③: bundled SQLite의 FTS5 trigram 가용성 확인.
#[test]
fn fts5_trigram_smoke() {
let (_dir, db) = open_temp();
assert!(db.fts_enabled, "bundled SQLite에 FTS5 trigram이 없음 — LIKE 폴백 필요");
insert_fixture_file(&db, "가족여행_제주도_2025.jpg");
insert_fixture_file(&db, "회사_워크샵.png");
// 한글 부분 문자열(3자 이상) 매칭
let hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH ?1",
["제주도"],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(hits, 1);
}
#[test]
fn writer_roundtrip_and_read_pool() {
let (_dir, db) = open_temp();
let id = insert_fixture_file(&db, "test.jpg");
let name: String = db
.with_read(|conn| {
conn.query_row("SELECT name FROM files WHERE id = ?1", [id], |r| r.get(0))
})
.unwrap();
assert_eq!(name, "test.jpg");
}
#[test]
fn fts_sync_triggers() {
let (_dir, db) = open_temp();
let id = insert_fixture_file(&db, "바다사진.jpg");
db.with_write(move |conn| {
conn.execute("UPDATE files SET name = '산사진.jpg' WHERE id = ?1", [id])
})
.unwrap();
let old_hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH '바다사진'",
[],
|r| r.get(0),
)
})
.unwrap();
let new_hits: i64 = db
.with_read(|conn| {
conn.query_row(
"SELECT count(*) FROM files_fts WHERE files_fts MATCH '산사진'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(old_hits, 0);
assert_eq!(new_hits, 1);
}
}
@@ -0,0 +1,213 @@
//! 스키마 마이그레이션 — `PRAGMA user_version` 기반 순차 적용.
use rusqlite::Connection;
/// 스키마 v1: 코어 테이블 전체.
const V1_DDL: &str = r#"
CREATE TABLE sources (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('local','sftp','ftp','webdav')),
name TEXT NOT NULL,
root TEXT NOT NULL,
config TEXT,
status INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000)
);
CREATE TABLE folders (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES folders(id),
path TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE(source_id, path)
);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TABLE files (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
folder_id INTEGER NOT NULL REFERENCES folders(id),
name TEXT NOT NULL,
ext TEXT,
kind INTEGER NOT NULL DEFAULT 2,
size INTEGER NOT NULL,
mtime_ms INTEGER NOT NULL,
width INTEGER,
height INTEGER,
orientation INTEGER,
duration_ms INTEGER,
vcodec TEXT,
acodec TEXT,
fps REAL,
taken_at INTEGER,
quick_hash INTEGER,
full_hash BLOB,
phash INTEGER,
vhash BLOB,
meta_state INTEGER NOT NULL DEFAULT 0,
thumb_state INTEGER NOT NULL DEFAULT 0,
scan_id INTEGER,
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
deleted_at INTEGER,
UNIQUE(folder_id, name)
);
CREATE INDEX idx_files_folder ON files(folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_size ON files(size);
CREATE INDEX idx_files_qhash ON files(quick_hash) WHERE quick_hash IS NOT NULL;
CREATE INDEX idx_files_fhash ON files(full_hash) WHERE full_hash IS NOT NULL;
CREATE INDEX idx_files_taken ON files(taken_at);
CREATE INDEX idx_files_pending_meta ON files(id) WHERE meta_state = 0 AND deleted_at IS NULL;
CREATE INDEX idx_files_pending_thumb ON files(id) WHERE thumb_state = 0 AND deleted_at IS NULL;
CREATE TABLE tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT,
parent_id INTEGER REFERENCES tags(id),
sort_order INTEGER
);
CREATE TABLE file_tags (
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
PRIMARY KEY (file_id, tag_id)
) WITHOUT ROWID;
CREATE INDEX idx_file_tags_tag ON file_tags(tag_id);
CREATE TABLE thumbs (
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
size_class INTEGER NOT NULL,
cache_key TEXT NOT NULL,
width INTEGER,
height INTEGER,
bytes INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
PRIMARY KEY (file_id, size_class)
);
CREATE INDEX idx_thumbs_key ON thumbs(cache_key);
CREATE TABLE scans (
id INTEGER PRIMARY KEY,
source_id INTEGER REFERENCES sources(id),
started_at INTEGER NOT NULL,
finished_at INTEGER,
status INTEGER NOT NULL DEFAULT 0,
stats TEXT
);
CREATE TABLE ops_journal (
id INTEGER PRIMARY KEY,
batch_id TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('move','rename','trash','tag','untag')),
file_id INTEGER,
src_path TEXT,
dst_path TEXT,
tag_id INTEGER,
performed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
undone INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_ops_batch ON ops_journal(batch_id);
CREATE TABLE dupe_groups (
id INTEGER PRIMARY KEY,
kind INTEGER NOT NULL,
score REAL,
status TEXT NOT NULL DEFAULT 'open',
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000)
);
CREATE TABLE dupe_members (
group_id INTEGER NOT NULL REFERENCES dupe_groups(id) ON DELETE CASCADE,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
is_keeper INTEGER NOT NULL DEFAULT 0,
distance INTEGER,
resolved_action TEXT,
PRIMARY KEY (group_id, file_id)
);
CREATE TABLE dupe_dismissed (
file_a INTEGER NOT NULL,
file_b INTEGER NOT NULL,
dismissed_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
PRIMARY KEY (file_a, file_b)
) WITHOUT ROWID;
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
source_id INTEGER,
state TEXT NOT NULL DEFAULT 'pending',
cursor TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now','subsec') * 1000),
updated_at INTEGER
);
CREATE TABLE smart_folders (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
query TEXT NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
) WITHOUT ROWID;
"#;
/// FTS5 trigram — 한글 파일명 부분일치 검색. bundled SQLite가 trigram을
/// 지원하지 않는 환경이면 생성 실패를 허용하고 LIKE 폴백을 쓴다.
const V1_FTS_DDL: &str = r#"
CREATE VIRTUAL TABLE files_fts USING fts5(
name,
tokenize = 'trigram',
content = 'files',
content_rowid = 'id'
);
CREATE TRIGGER files_fts_ai AFTER INSERT ON files BEGIN
INSERT INTO files_fts(rowid, name) VALUES (new.id, new.name);
END;
CREATE TRIGGER files_fts_ad AFTER DELETE ON files BEGIN
INSERT INTO files_fts(files_fts, rowid, name) VALUES ('delete', old.id, old.name);
END;
CREATE TRIGGER files_fts_au AFTER UPDATE OF name ON files BEGIN
INSERT INTO files_fts(files_fts, rowid, name) VALUES ('delete', old.id, old.name);
INSERT INTO files_fts(rowid, name) VALUES (new.id, new.name);
END;
"#;
/// 마이그레이션을 적용하고 FTS 사용 가능 여부를 반환한다.
pub fn migrate(conn: &mut Connection) -> rusqlite::Result<bool> {
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version < 1 {
let tx = conn.transaction()?;
tx.execute_batch(V1_DDL)?;
tx.pragma_update(None, "user_version", 1)?;
tx.commit()?;
// FTS는 별도 트랜잭션 — 실패해도 코어 스키마는 유지
let fts_ok = conn.execute_batch(V1_FTS_DDL).is_ok();
conn.execute(
"INSERT OR REPLACE INTO settings(key, value) VALUES ('fts', ?1)",
[if fts_ok { "on" } else { "off" }],
)?;
if !fts_ok {
tracing::warn!("FTS5 trigram 사용 불가 — LIKE 검색으로 폴백");
}
return Ok(fts_ok);
}
let fts: Option<String> = conn
.query_row("SELECT value FROM settings WHERE key = 'fts'", [], |r| r.get(0))
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
e => Err(e),
})?;
Ok(fts.as_deref() == Some("on"))
}
@@ -0,0 +1,28 @@
[package]
name = "archive-indexer"
version = "0.1.0"
edition = "2021"
[dependencies]
archive-db = { path = "../archive-db" }
archive-vfs = { path = "../archive-vfs" }
jwalk = "0.8"
rayon = "1"
crossbeam-channel = { workspace = true }
xxhash-rust = { version = "0.8", features = ["xxh3"] }
blake3 = { version = "1", features = ["mmap", "rayon"] }
image = "0.25"
image_hasher = "3"
fast_image_resize = "6"
imagesize = "0.14"
kamadak-exif = "0.6"
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
rusqlite = { version = "0.40", features = ["bundled"] }
trash = "5"
filetime = "0.2"
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,502 @@
//! 중복 탐지 파이프라인.
//!
//! 완전 동일: size 그룹 → quick_hash → BLAKE3 전체 해시 (그룹 내만).
//! 유사 이미지: dHash(썸네일에서) 병렬 브루트포스 → Union-Find 클러스터.
//! 유사 영상: duration ±5% 사전필터 → 프레임 시그니처 중앙값 해밍.
//! 결과는 dupe_groups/dupe_members에 영속화된다.
use crate::ffmpeg::FfTools;
use crate::{hash, phash};
use archive_db::Db;
use rayon::prelude::*;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Debug, thiserror::Error)]
pub enum DedupeError {
#[error("DB 오류: {0}")]
Db(#[from] archive_db::DbError),
#[error("취소됨")]
Cancelled,
}
pub type Result<T> = std::result::Result<T, DedupeError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum GroupKind {
Exact = 0,
Image = 1,
Video = 2,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DedupeStats {
pub exact_groups: u64,
pub image_groups: u64,
pub video_groups: u64,
pub total_dupes: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DedupeProgress {
pub phase: String, // exact | image | video | done
pub current: u64,
pub total: u64,
}
struct FileMeta {
id: i64,
path: PathBuf,
size: i64,
width: Option<i64>,
height: Option<i64>,
mtime_ms: i64,
duration_ms: Option<i64>,
}
fn load_files(db: &Db, kind: u8, scope: Option<i64>) -> Result<Vec<FileMeta>> {
Ok(db.with_read(move |conn| {
let base = "SELECT f.id, fo.path, f.name, f.size, f.width, f.height, f.mtime_ms, f.duration_ms
FROM files f JOIN folders fo ON fo.id = f.folder_id
WHERE f.kind = ?1 AND f.deleted_at IS NULL";
let map = |r: &rusqlite::Row<'_>| {
let dir: String = r.get(1)?;
let name: String = r.get(2)?;
Ok(FileMeta {
id: r.get(0)?,
path: Path::new(&dir).join(name),
size: r.get(3)?,
width: r.get(4)?,
height: r.get(5)?,
mtime_ms: r.get(6)?,
duration_ms: r.get(7)?,
})
};
match scope {
Some(sid) => {
let mut stmt = conn.prepare(&format!("{base} AND f.source_id = ?2"))?;
let rows = stmt.query_map(rusqlite::params![kind, sid], map)?;
rows.collect()
}
None => {
let mut stmt = conn.prepare(base)?;
let rows = stmt.query_map([kind], map)?;
rows.collect()
}
}
})?)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// keeper 자동 선정: 해상도↓ → mtime(오래된 것)↓ → id↓.
/// 반환된 첫 요소가 keeper.
fn rank_keeper<'a>(group: &'a [&'a FileMeta]) -> Vec<i64> {
let mut sorted: Vec<&FileMeta> = group.to_vec();
sorted.sort_by(|a, b| {
let pa = a.width.unwrap_or(0) * a.height.unwrap_or(0);
let pb = b.width.unwrap_or(0) * b.height.unwrap_or(0);
pb.cmp(&pa) // 해상도 큰 것 우선
.then(a.size.cmp(&b.size).reverse()) // 파일 큰 것 우선
.then(a.mtime_ms.cmp(&b.mtime_ms)) // 오래된 것 우선
.then(a.id.cmp(&b.id))
});
sorted.iter().map(|f| f.id).collect()
}
/// 그룹을 dupe_groups/dupe_members에 저장한다 (keeper 자동 선정).
fn persist_group(
db: &Db,
kind: GroupKind,
members: &[&FileMeta],
distances: &HashMap<i64, u32>,
) -> Result<()> {
let ranked = rank_keeper(members);
let kind_val = kind as i64;
let dists: Vec<(i64, Option<i64>)> = ranked
.iter()
.map(|id| (*id, distances.get(id).map(|d| *d as i64)))
.collect();
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO dupe_groups(kind, created_at) VALUES (?1, ?2)",
rusqlite::params![kind_val, now_ms()],
)?;
let gid = tx.last_insert_rowid();
{
let mut stmt = tx.prepare_cached(
"INSERT INTO dupe_members(group_id, file_id, is_keeper, distance) VALUES (?1, ?2, ?3, ?4)",
)?;
for (i, (id, dist)) in dists.iter().enumerate() {
stmt.execute(rusqlite::params![gid, id, if i == 0 { 1 } else { 0 }, dist])?;
}
}
tx.commit()
})?;
Ok(())
}
/// 이전 미해결 그룹을 지운다 (재탐지 시). 해결/무시(dismissed)는 보존.
fn clear_open_groups(db: &Db) -> Result<()> {
db.with_write(|conn| {
conn.execute("DELETE FROM dupe_groups WHERE status = 'open'", [])?;
Ok(())
})?;
Ok(())
}
fn is_dismissed(dismissed: &std::collections::HashSet<(i64, i64)>, a: i64, b: i64) -> bool {
let key = if a < b { (a, b) } else { (b, a) };
dismissed.contains(&key)
}
fn load_dismissed(db: &Db) -> Result<std::collections::HashSet<(i64, i64)>> {
Ok(db.with_read(|conn| {
let mut stmt = conn.prepare("SELECT file_a, file_b FROM dupe_dismissed")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()
})?)
}
/// 완전 동일 탐지: size → quick_hash → BLAKE3.
/// 그룹으로 묶인 파일 id는 `grouped`에 누적된다 (유사 패스에서 제외용).
fn detect_exact(
db: &Db,
files: &[FileMeta],
grouped: &mut std::collections::HashSet<i64>,
cancel: &AtomicBool,
mut progress: impl FnMut(u64, u64),
) -> Result<u64> {
// 1. size로 그룹핑
let mut by_size: HashMap<i64, Vec<&FileMeta>> = HashMap::new();
for f in files {
by_size.entry(f.size).or_default().push(f);
}
let candidates: Vec<Vec<&FileMeta>> =
by_size.into_values().filter(|g| g.len() >= 2).collect();
let total = candidates.iter().map(|g| g.len()).sum::<usize>() as u64;
let mut processed = 0u64;
let mut group_count = 0u64;
for size_group in candidates {
if cancel.load(Ordering::Relaxed) {
return Err(DedupeError::Cancelled);
}
// 2. quick_hash로 세분화
let with_qh: Vec<(u64, &FileMeta)> = size_group
.par_iter()
.filter_map(|f| hash::quick_hash(&f.path).ok().map(|h| (h, *f)))
.collect();
processed += size_group.len() as u64;
progress(processed, total);
let mut by_qh: HashMap<u64, Vec<&FileMeta>> = HashMap::new();
for (qh, f) in with_qh {
by_qh.entry(qh).or_default().push(f);
}
for qh_group in by_qh.into_values().filter(|g| g.len() >= 2) {
// 3. BLAKE3 전체 해시로 확정
let with_fh: Vec<([u8; 32], &FileMeta)> = qh_group
.par_iter()
.filter_map(|f| hash::full_hash(&f.path).ok().map(|h| (h, *f)))
.collect();
let mut by_fh: HashMap<[u8; 32], Vec<&FileMeta>> = HashMap::new();
for (fh, f) in with_fh {
by_fh.entry(fh).or_default().push(f);
}
for exact in by_fh.into_values().filter(|g| g.len() >= 2) {
// keeper(첫 요소)는 유사 패스에 남겨 교차 유사도를 잡되,
// 나머지 완전 동일본은 제외해 중복 표시를 막는다.
let ranked = rank_keeper(&exact);
for id in ranked.iter().skip(1) {
grouped.insert(*id);
}
persist_group(db, GroupKind::Exact, &exact, &HashMap::new())?;
group_count += 1;
}
}
}
Ok(group_count)
}
/// 유사 이미지 탐지: dHash → 브루트포스 → 클러스터.
/// `exclude`에 속한 파일(이미 완전 동일 그룹으로 처리됨)은 제외한다.
fn detect_similar_images(
db: &Db,
files: &[FileMeta],
threshold: u32,
dismissed: &std::collections::HashSet<(i64, i64)>,
exclude: &std::collections::HashSet<i64>,
cancel: &AtomicBool,
mut progress: impl FnMut(u64, u64),
) -> Result<u64> {
let total = files.len() as u64;
// dHash 병렬 계산 (썸네일 우선 — thumbs 캐시가 있으면 그것으로, 없으면 원본)
let hashes: Vec<(i64, u64)> = files
.par_iter()
.filter(|f| !exclude.contains(&f.id))
.filter_map(|f| phash::dhash_file(&f.path).map(|h| (f.id, h)))
.collect();
progress(total, total);
if cancel.load(Ordering::Relaxed) {
return Err(DedupeError::Cancelled);
}
let pairs: Vec<(i64, i64, u32)> = phash::find_near_pairs(&hashes, threshold)
.into_iter()
.filter(|(a, b, _)| !is_dismissed(dismissed, *a, *b))
.collect();
let ids: Vec<i64> = hashes.iter().map(|(id, _)| *id).collect();
let groups = phash::cluster(&ids, &pairs);
// 거리 맵 (keeper 대비 근사 — 그룹 내 최소 거리)
let by_id: HashMap<i64, &FileMeta> = files.iter().map(|f| (f.id, f)).collect();
let mut group_count = 0u64;
for group in groups {
let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect();
if members.len() < 2 {
continue;
}
persist_group(db, GroupKind::Image, &members, &HashMap::new())?;
group_count += 1;
}
Ok(group_count)
}
/// 영상 프레임 시그니처: 5/15/…/95% 10프레임 dHash.
fn video_signature(tools: &FfTools, path: &Path) -> Option<Vec<u64>> {
let offsets = [0.05, 0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85, 0.95];
let dir = std::env::temp_dir().join(format!("archive-vsig-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok()?;
let info = crate::ffmpeg::probe(tools, path).ok()?;
let dur = info.duration_ms? as f64 / 1000.0;
if dur <= 0.0 {
return None;
}
let mut sig = Vec::with_capacity(offsets.len());
for (i, off) in offsets.iter().enumerate() {
let frame = dir.join(format!("f{i}.jpg"));
if crate::ffmpeg::extract_frame_jpeg(tools, path, &frame, dur * off, 64).is_ok() {
if let Some(h) = phash::dhash_file(&frame) {
sig.push(h);
}
let _ = std::fs::remove_file(&frame);
}
}
let _ = std::fs::remove_dir_all(&dir);
if sig.len() >= 5 {
Some(sig)
} else {
None
}
}
/// 두 시그니처의 프레임별 해밍 중앙값.
fn signature_distance(a: &[u64], b: &[u64]) -> u32 {
let n = a.len().min(b.len());
if n == 0 {
return u32::MAX;
}
let mut dists: Vec<u32> = (0..n).map(|i| phash::hamming(a[i], b[i])).collect();
dists.sort_unstable();
dists[n / 2]
}
fn detect_similar_videos(
db: &Db,
files: &[FileMeta],
tools: &FfTools,
threshold: u32,
dismissed: &std::collections::HashSet<(i64, i64)>,
cancel: &AtomicBool,
mut progress: impl FnMut(u64, u64),
) -> Result<u64> {
// duration 있는 영상만
let with_dur: Vec<&FileMeta> = files.iter().filter(|f| f.duration_ms.unwrap_or(0) > 0).collect();
let total = with_dur.len() as u64;
// 시그니처 계산 (동시 2 — 각 ffmpeg가 ~1코어)
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().ok();
let sigs: Vec<(i64, i64, Vec<u64>)> = match &pool {
Some(p) => p.install(|| {
with_dur
.par_iter()
.filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s)))
.collect()
}),
None => with_dur
.iter()
.filter_map(|f| video_signature(tools, &f.path).map(|s| (f.id, f.duration_ms.unwrap_or(0), s)))
.collect(),
};
progress(total, total);
if cancel.load(Ordering::Relaxed) {
return Err(DedupeError::Cancelled);
}
// duration ±5% 사전필터 후 시그니처 비교
let mut pairs: Vec<(i64, i64, u32)> = Vec::new();
for i in 0..sigs.len() {
for j in (i + 1)..sigs.len() {
let (ida, da, ref sa) = sigs[i];
let (idb, db_dur, ref sb) = sigs[j];
let ratio = da as f64 / db_dur.max(1) as f64;
if !(0.95..=1.05).contains(&ratio) {
continue;
}
let dist = signature_distance(sa, sb);
if dist <= threshold && !is_dismissed(dismissed, ida, idb) {
let (lo, hi) = if ida < idb { (ida, idb) } else { (idb, ida) };
pairs.push((lo, hi, dist));
}
}
}
let ids: Vec<i64> = sigs.iter().map(|(id, _, _)| *id).collect();
let groups = phash::cluster(&ids, &pairs);
let by_id: HashMap<i64, &FileMeta> = files.iter().map(|f| (f.id, f)).collect();
let mut group_count = 0u64;
for group in groups {
let members: Vec<&FileMeta> = group.iter().filter_map(|id| by_id.get(id).copied()).collect();
if members.len() >= 2 {
persist_group(db, GroupKind::Video, &members, &HashMap::new())?;
group_count += 1;
}
}
Ok(group_count)
}
/// 전체 중복 탐지 실행. 블로킹 — 전용 스레드에서 호출할 것.
pub fn run(
db: &Arc<Db>,
tools: Option<&FfTools>,
scope_source: Option<i64>,
image_threshold: u32,
include_video: bool,
cancel: &AtomicBool,
mut on_progress: impl FnMut(DedupeProgress),
) -> Result<DedupeStats> {
clear_open_groups(db)?;
let dismissed = load_dismissed(db)?;
let mut stats = DedupeStats::default();
let mut grouped: std::collections::HashSet<i64> = std::collections::HashSet::new();
// 완전 동일 (이미지+영상 모두)
let mut all_files = load_files(db, 0, scope_source)?;
all_files.extend(load_files(db, 1, scope_source)?);
stats.exact_groups = detect_exact(db, &all_files, &mut grouped, cancel, |c, t| {
on_progress(DedupeProgress { phase: "exact".into(), current: c, total: t });
})?;
// 유사 이미지 (완전 동일 그룹에 속한 파일은 제외)
let images = load_files(db, 0, scope_source)?;
stats.image_groups = detect_similar_images(db, &images, image_threshold, &dismissed, &grouped, cancel, |c, t| {
on_progress(DedupeProgress { phase: "image".into(), current: c, total: t });
})?;
// 유사 영상 (opt-in + ffmpeg 필요)
if include_video {
if let Some(tools) = tools {
let videos = load_files(db, 1, scope_source)?;
stats.video_groups = detect_similar_videos(db, &videos, tools, 8, &dismissed, cancel, |c, t| {
on_progress(DedupeProgress { phase: "video".into(), current: c, total: t });
})?;
}
}
stats.total_dupes = db.with_read(|conn| {
conn.query_row("SELECT count(*) FROM dupe_members", [], |r| r.get::<_, i64>(0))
})? as u64;
on_progress(DedupeProgress { phase: "done".into(), current: 0, total: 0 });
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
let dbdir = tempfile::tempdir().unwrap();
let media = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
let root = media.path().to_string_lossy().into_owned();
let sid = db
.with_write(move |conn| {
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
Ok(conn.last_insert_rowid())
})
.unwrap();
(dbdir, media, db, sid)
}
#[test]
fn detects_exact_and_resized_duplicates() {
let (_d, media, db, sid) = setup();
let root = media.path();
// 원본 이미지
let base = image::RgbImage::from_fn(400, 300, |x, y| {
image::Rgb([((x * 255) / 400) as u8, ((y * 255) / 300) as u8, 100])
});
base.save(root.join("original.png")).unwrap();
// 완전 동일 복사본 (바이트 동일)
std::fs::copy(root.join("original.png"), root.join("exact_copy.png")).unwrap();
// 리사이즈본 (유사)
image::DynamicImage::ImageRgb8(base.clone())
.resize_exact(200, 150, image::imageops::FilterType::Lanczos3)
.save(root.join("resized.jpg"))
.unwrap();
// 무관한 이미지
image::RgbImage::from_pixel(300, 300, image::Rgb([10, 200, 30]))
.save(root.join("unrelated.png"))
.unwrap();
let cancel = AtomicBool::new(false);
crate::pipeline::scan_source(&db, sid, root, &cancel, |_| {}).unwrap();
crate::meta::extract_image_meta(&db, sid, &cancel).unwrap();
let stats = run(&db, None, Some(sid), 5, false, &cancel, |_| {}).unwrap();
// 완전 동일 그룹 1개 (original + exact_copy)
assert_eq!(stats.exact_groups, 1, "완전 동일 그룹");
// 유사 이미지 그룹: original/exact_copy/resized 모두 시각적으로 같음
assert!(stats.image_groups >= 1, "유사 이미지 그룹 {}", stats.image_groups);
// 완전 동일 그룹에 keeper가 정확히 1개
let keepers: i64 = db
.with_read(|c| {
c.query_row(
"SELECT count(*) FROM dupe_members dm
JOIN dupe_groups dg ON dg.id = dm.group_id
WHERE dg.kind = 0 AND dm.is_keeper = 1",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(keepers, 1);
}
#[test]
fn signature_distance_median() {
let a = vec![0u64, 0, 0, 0, 0];
let b = vec![0u64, 0, 0xFF, 0, 0]; // 한 프레임만 다름
assert_eq!(signature_distance(&a, &b), 0); // 중앙값은 0
}
}
@@ -0,0 +1,231 @@
//! ffmpeg/ffprobe 사이드카 — 위치 해석, ffprobe 메타 파싱, 프레임 추출.
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(all(windows, target_arch = "x86_64"))]
const TRIPLE: &str = "x86_64-pc-windows-msvc";
#[cfg(all(windows, target_arch = "aarch64"))]
const TRIPLE: &str = "aarch64-pc-windows-msvc";
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
const TRIPLE: &str = "aarch64-apple-darwin";
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
const TRIPLE: &str = "x86_64-apple-darwin";
#[cfg(not(any(windows, target_os = "macos")))]
const TRIPLE: &str = "unknown";
#[cfg(windows)]
const EXE: &str = ".exe";
#[cfg(not(windows))]
const EXE: &str = "";
#[derive(Debug, Clone)]
pub struct FfTools {
pub ffmpeg: PathBuf,
pub ffprobe: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum FfError {
#[error("ffmpeg 실행 오류: {0}")]
Io(#[from] std::io::Error),
#[error("ffprobe 출력 파싱 실패: {0}")]
Parse(String),
#[error("ffmpeg 종료 코드 {0}: {1}")]
Exit(i32, String),
}
/// 사이드카 탐색: ① ARCHIVE_FFMPEG_DIR ② exe 옆 ③ dev(src-tauri/binaries) ④ PATH
pub fn locate() -> Option<FfTools> {
let candidates = |name: &str| -> Vec<PathBuf> {
let mut v = Vec::new();
if let Ok(dir) = std::env::var("ARCHIVE_FFMPEG_DIR") {
v.push(Path::new(&dir).join(format!("{name}{EXE}")));
v.push(Path::new(&dir).join(format!("{name}-{TRIPLE}{EXE}")));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(d) = exe.parent() {
v.push(d.join(format!("{name}{EXE}")));
v.push(d.join(format!("{name}-{TRIPLE}{EXE}")));
// dev: target/debug → src-tauri/binaries
v.push(d.join("../..").join("binaries").join(format!("{name}-{TRIPLE}{EXE}")));
}
}
v
};
let find = |name: &str| -> Option<PathBuf> {
for c in candidates(name) {
if c.is_file() {
return Some(c);
}
}
// PATH 폴백
which(name)
};
Some(FfTools {
ffmpeg: find("ffmpeg")?,
ffprobe: find("ffprobe")?,
})
}
fn which(name: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let p = dir.join(format!("{name}{EXE}"));
if p.is_file() {
return Some(p);
}
}
None
}
/// 콘솔 창 없이 실행되는 Command 생성 (Windows CREATE_NO_WINDOW)
pub fn command(path: &Path) -> Command {
let cmd = Command::new(path);
#[cfg(windows)]
let cmd = {
use std::os::windows::process::CommandExt;
let mut c = cmd;
c.creation_flags(0x0800_0000);
c
};
cmd
}
#[derive(Debug, Clone, Default)]
pub struct MediaInfo {
pub duration_ms: Option<i64>,
pub width: Option<u32>,
pub height: Option<u32>,
pub vcodec: Option<String>,
pub acodec: Option<String>,
pub fps: Option<f64>,
}
/// ffprobe로 영상 메타데이터를 읽는다.
pub fn probe(tools: &FfTools, path: &Path) -> Result<MediaInfo, FfError> {
let out = command(&tools.ffprobe)
.args(["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams"])
.arg(path)
.output()?;
if !out.status.success() {
return Err(FfError::Exit(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).into_owned(),
));
}
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).map_err(|e| FfError::Parse(e.to_string()))?;
let mut info = MediaInfo::default();
info.duration_ms = v["format"]["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.map(|s| (s * 1000.0) as i64);
if let Some(streams) = v["streams"].as_array() {
for s in streams {
match s["codec_type"].as_str() {
Some("video") if info.vcodec.is_none() => {
info.vcodec = s["codec_name"].as_str().map(String::from);
info.width = s["width"].as_u64().map(|w| w as u32);
info.height = s["height"].as_u64().map(|h| h as u32);
info.fps = s["avg_frame_rate"]
.as_str()
.and_then(parse_frame_rate)
.or_else(|| s["r_frame_rate"].as_str().and_then(parse_frame_rate));
// 스트림 duration 폴백
if info.duration_ms.is_none() {
info.duration_ms = s["duration"]
.as_str()
.and_then(|d| d.parse::<f64>().ok())
.map(|d| (d * 1000.0) as i64);
}
}
Some("audio") if info.acodec.is_none() => {
info.acodec = s["codec_name"].as_str().map(String::from);
}
_ => {}
}
}
}
Ok(info)
}
fn parse_frame_rate(s: &str) -> Option<f64> {
let (num, den) = s.split_once('/')?;
let num: f64 = num.parse().ok()?;
let den: f64 = den.parse().ok()?;
if den == 0.0 || num == 0.0 {
None
} else {
Some(num / den)
}
}
/// 영상에서 프레임 하나를 뽑아 JPEG 썸네일로 저장한다. 반환: (썸네일 w, h)
pub fn extract_frame_jpeg(
tools: &FfTools,
src: &Path,
dst: &Path,
at_seconds: f64,
long_edge: u32,
) -> Result<(u32, u32), FfError> {
extract_frame_input(tools, src.as_os_str(), dst, at_seconds, long_edge)
}
/// URL(예: 미디어 서버 bridge)에서 프레임을 뽑는다 — ffmpeg가 Range로 읽어 전체 다운로드 회피.
pub fn extract_frame_url(
tools: &FfTools,
url: &str,
dst: &Path,
at_seconds: f64,
long_edge: u32,
) -> Result<(u32, u32), FfError> {
extract_frame_input(tools, std::ffi::OsStr::new(url), dst, at_seconds, long_edge)
}
fn extract_frame_input(
tools: &FfTools,
input: &std::ffi::OsStr,
dst: &Path,
at_seconds: f64,
long_edge: u32,
) -> Result<(u32, u32), FfError> {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let scale = format!("scale=w={long_edge}:h={long_edge}:force_original_aspect_ratio=decrease");
let out = command(&tools.ffmpeg)
.args(["-y", "-v", "error", "-ss", &format!("{at_seconds:.3}")])
.arg("-i")
.arg(input)
.args(["-frames:v", "1", "-vf", &scale, "-q:v", "4"])
.arg(dst)
.output()?;
if !out.status.success() || !dst.is_file() {
return Err(FfError::Exit(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).into_owned(),
));
}
image::image_dimensions(dst)
.map_err(|e| FfError::Parse(format!("썸네일 치수 읽기 실패: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_rate_parsing() {
assert_eq!(parse_frame_rate("30000/1001").map(|f| (f * 100.0).round()), Some(2997.0));
assert_eq!(parse_frame_rate("25/1"), Some(25.0));
assert_eq!(parse_frame_rate("0/0"), None);
assert_eq!(parse_frame_rate("잘못됨"), None);
}
// ffmpeg 실기 테스트는 tests/ffmpeg_integration.rs (사이드카 존재 시에만 실행)
}
@@ -0,0 +1,548 @@
//! 파일 작업 계층 — 이동/이름변경/휴지통 + ops_journal 기반 undo.
//!
//! 원칙: ① 물리적 파일 변경 성공 후에만 DB 갱신 ② 모든 작업은 저널 기록
//! ③ 삭제는 OS 휴지통 경유만 (영구삭제 없음 — 계획서 §7).
use archive_db::Db;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, thiserror::Error)]
pub enum OpError {
#[error("DB 오류: {0}")]
Db(#[from] archive_db::DbError),
#[error("I/O 오류 ({path}): {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("휴지통 오류 ({path}): {message}")]
Trash { path: String, message: String },
#[error("잘못된 이름: {0}")]
BadName(String),
#[error("대상 폴더를 찾을 수 없음: {0}")]
NoFolder(i64),
}
pub type Result<T> = std::result::Result<T, OpError>;
#[derive(Debug, Default, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpSummary {
pub done: u64,
pub skipped: u64,
pub failed: u64,
pub batch_id: String,
}
static BATCH_COUNTER: AtomicU64 = AtomicU64::new(0);
/// 배치 id 생성 (프로세스 내 유일)
pub fn new_batch_id() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let n = BATCH_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{now:x}-{n:x}")
}
struct FileRow {
id: i64,
dir: String,
name: String,
}
fn load_rows(db: &Db, file_ids: &[i64]) -> Result<Vec<FileRow>> {
let ids = file_ids.to_vec();
Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.id = ?1 AND f.deleted_at IS NULL",
)?;
let mut out = Vec::with_capacity(ids.len());
for id in &ids {
if let Ok(row) = stmt.query_row([id], |r| {
Ok(FileRow {
id: r.get(0)?,
dir: r.get(1)?,
name: r.get(2)?,
})
}) {
out.push(row);
}
}
Ok(out)
})?)
}
/// 충돌 시 "이름 (2).ext" 식으로 비어 있는 이름을 찾는다.
fn resolve_collision(dest_dir: &Path, name: &str) -> String {
if !dest_dir.join(name).exists() {
return name.to_string();
}
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) => (s, format!(".{e}")),
None => (name, String::new()),
};
for i in 2..1000 {
let candidate = format!("{stem} ({i}){ext}");
if !dest_dir.join(&candidate).exists() {
return candidate;
}
}
format!("{stem}-{}{ext}", new_batch_id())
}
/// rename → 크로스 볼륨이면 copy+delete 폴백 (mtime 보존).
/// Windows의 일시적 공유 위반(안티바이러스/인덱서)에는 짧게 재시도한다.
fn physical_move(src: &Path, dst: &Path) -> std::io::Result<()> {
let mut last_err = None;
for attempt in 0..4 {
match std::fs::rename(src, dst) {
Ok(()) => return Ok(()),
Err(e)
if e.raw_os_error() == Some(17) || e.kind() == std::io::ErrorKind::CrossesDevices =>
{
// 크로스 볼륨: copy + mtime 보존 + delete
let meta = std::fs::metadata(src)?;
std::fs::copy(src, dst)?;
if let Ok(mtime) = meta.modified() {
let _ = filetime::set_file_mtime(dst, filetime::FileTime::from_system_time(mtime));
}
std::fs::remove_file(src)?;
return Ok(());
}
// Windows 공유 위반(32)/액세스 거부(5)는 일시적일 수 있어 재시도
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) && attempt < 3 => {
last_err = Some(e);
std::thread::sleep(std::time::Duration::from_millis(50 * (attempt + 1)));
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| std::io::Error::other("move 재시도 소진")))
}
/// 파일들을 대상 폴더(폴더 트리의 로컬 폴더)로 이동한다.
pub fn move_files(
db: &Db,
batch_id: &str,
file_ids: &[i64],
dest_folder_id: i64,
mut on_progress: impl FnMut(u64, u64),
) -> Result<OpSummary> {
let dest_dir: String = db
.with_read(|conn| {
conn.query_row("SELECT path FROM folders WHERE id = ?1", [dest_folder_id], |r| r.get(0))
})
.map_err(|_| OpError::NoFolder(dest_folder_id))?;
let dest_path = PathBuf::from(&dest_dir);
let rows = load_rows(db, file_ids)?;
let total = rows.len() as u64;
let mut summary = OpSummary {
batch_id: batch_id.to_string(),
..Default::default()
};
for (i, row) in rows.iter().enumerate() {
let src = Path::new(&row.dir).join(&row.name);
if row.dir == dest_dir {
summary.skipped += 1;
continue;
}
let new_name = resolve_collision(&dest_path, &row.name);
let dst = dest_path.join(&new_name);
match physical_move(&src, &dst) {
Ok(()) => {
let (bid, fid) = (batch_id.to_string(), row.id);
let (src_s, dst_s) = (src.to_string_lossy().into_owned(), dst.to_string_lossy().into_owned());
let nn = new_name.clone();
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"UPDATE files SET folder_id = ?2, name = ?3 WHERE id = ?1",
rusqlite::params![fid, dest_folder_id, nn],
)?;
tx.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
VALUES (?1, 'move', ?2, ?3, ?4)",
rusqlite::params![bid, fid, src_s, dst_s],
)?;
tx.commit()
})?;
summary.done += 1;
}
Err(e) => {
tracing::warn!(src = %src.display(), "이동 실패: {e}");
summary.failed += 1;
}
}
on_progress(i as u64 + 1, total);
}
Ok(summary)
}
/// 파일 이름 변경 (같은 폴더 내).
pub fn rename_file(db: &Db, batch_id: &str, file_id: i64, new_name: &str) -> Result<()> {
if new_name.is_empty() || new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
return Err(OpError::BadName(new_name.to_string()));
}
let rows = load_rows(db, &[file_id])?;
let row = rows.first().ok_or(OpError::NoFolder(file_id))?;
let src = Path::new(&row.dir).join(&row.name);
let dst = Path::new(&row.dir).join(new_name);
if dst.exists() {
return Err(OpError::BadName(format!("이미 존재하는 이름: {new_name}")));
}
std::fs::rename(&src, &dst).map_err(|e| OpError::Io {
path: src.to_string_lossy().into_owned(),
source: e,
})?;
let (bid, fid, nn) = (batch_id.to_string(), row.id, new_name.to_string());
let (src_s, dst_s) = (src.to_string_lossy().into_owned(), dst.to_string_lossy().into_owned());
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute("UPDATE files SET name = ?2 WHERE id = ?1", rusqlite::params![fid, nn])?;
tx.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, src_path, dst_path)
VALUES (?1, 'rename', ?2, ?3, ?4)",
rusqlite::params![bid, fid, src_s, dst_s],
)?;
tx.commit()
})?;
Ok(())
}
/// 파일들을 OS 휴지통으로 보낸다 (인앱 undo 불가 — OS 휴지통이 복구 경로).
pub fn trash_files(
db: &Db,
batch_id: &str,
file_ids: &[i64],
mut on_progress: impl FnMut(u64, u64),
) -> Result<OpSummary> {
let rows = load_rows(db, file_ids)?;
let total = rows.len() as u64;
let mut summary = OpSummary {
batch_id: batch_id.to_string(),
..Default::default()
};
for (i, row) in rows.iter().enumerate() {
let src = Path::new(&row.dir).join(&row.name);
match trash::delete(&src) {
Ok(()) => {
let (bid, fid) = (batch_id.to_string(), row.id);
let src_s = src.to_string_lossy().into_owned();
db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"UPDATE files SET deleted_at = unixepoch('now','subsec') * 1000 WHERE id = ?1",
[fid],
)?;
tx.execute(
"INSERT INTO ops_journal(batch_id, op, file_id, src_path)
VALUES (?1, 'trash', ?2, ?3)",
rusqlite::params![bid, fid, src_s],
)?;
tx.commit()
})?;
summary.done += 1;
}
Err(e) => {
tracing::warn!(src = %src.display(), "휴지통 이동 실패: {e}");
summary.failed += 1;
}
}
on_progress(i as u64 + 1, total);
}
Ok(summary)
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UndoBatch {
pub batch_id: String,
pub ops: i64,
pub kinds: String,
pub performed_at: i64,
pub undoable: bool,
}
/// 최근 배치 목록 (undo UI용).
pub fn list_batches(db: &Db, limit: i64) -> Result<Vec<UndoBatch>> {
Ok(db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
"SELECT batch_id, count(*), group_concat(DISTINCT op), max(performed_at),
sum(CASE WHEN op = 'trash' THEN 1 ELSE 0 END) = 0
FROM ops_journal WHERE undone = 0
GROUP BY batch_id ORDER BY max(performed_at) DESC LIMIT ?1",
)?;
let rows = stmt.query_map([limit], |r| {
Ok(UndoBatch {
batch_id: r.get(0)?,
ops: r.get(1)?,
kinds: r.get(2)?,
performed_at: r.get(3)?,
undoable: r.get(4)?,
})
})?;
rows.collect()
})?)
}
/// 배치 undo — 저널 역순 재생. trash 항목은 건너뛴다.
pub fn undo_batch(db: &Db, batch_id: &str) -> Result<OpSummary> {
struct JEntry {
id: i64,
op: String,
file_id: Option<i64>,
src_path: Option<String>,
dst_path: Option<String>,
tag_id: Option<i64>,
}
let bid = batch_id.to_string();
let entries: Vec<JEntry> = db.with_read(move |conn| {
let mut stmt = conn.prepare_cached(
"SELECT id, op, file_id, src_path, dst_path, tag_id FROM ops_journal
WHERE batch_id = ?1 AND undone = 0 ORDER BY id DESC",
)?;
let rows = stmt.query_map([&bid], |r| {
Ok(JEntry {
id: r.get(0)?,
op: r.get(1)?,
file_id: r.get(2)?,
src_path: r.get(3)?,
dst_path: r.get(4)?,
tag_id: r.get(5)?,
})
})?;
rows.collect()
})?;
let mut summary = OpSummary {
batch_id: batch_id.to_string(),
..Default::default()
};
for e in entries {
let undone = match e.op.as_str() {
"move" | "rename" => {
let (Some(src), Some(dst), Some(fid)) = (&e.src_path, &e.dst_path, e.file_id) else {
summary.failed += 1;
continue;
};
// dst → src로 되돌리기
match std::fs::rename(dst, src).or_else(|_| {
physical_move(Path::new(dst), Path::new(src))
}) {
Ok(()) => {
let src_dir = Path::new(src)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let src_name = Path::new(src)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
db.with_write(move |conn| {
// 원래 폴더 행을 찾아 복원
let folder_id: Option<i64> = conn
.query_row(
"SELECT fo.id FROM folders fo
JOIN files f ON f.id = ?2
WHERE fo.path = ?1 AND fo.source_id = f.source_id",
rusqlite::params![src_dir, fid],
|r| r.get(0),
)
.ok();
match folder_id {
Some(foid) => {
conn.execute(
"UPDATE files SET folder_id = ?2, name = ?3 WHERE id = ?1",
rusqlite::params![fid, foid, src_name],
)?;
}
None => {
// 폴더 행이 사라졌으면 이름만이라도 복원
conn.execute(
"UPDATE files SET name = ?2 WHERE id = ?1",
rusqlite::params![fid, src_name],
)?;
}
}
Ok(())
})?;
true
}
Err(err) => {
tracing::warn!("undo 이동 실패 ({dst} → {src}): {err}");
summary.failed += 1;
false
}
}
}
"tag" => {
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
db.with_write(move |conn| {
conn.execute(
"DELETE FROM file_tags WHERE file_id = ?1 AND tag_id = ?2",
rusqlite::params![fid, tid],
)
})?;
true
} else {
false
}
}
"untag" => {
if let (Some(fid), Some(tid)) = (e.file_id, e.tag_id) {
db.with_write(move |conn| {
conn.execute(
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
rusqlite::params![fid, tid],
)
})?;
true
} else {
false
}
}
// trash는 인앱 undo 불가 (OS 휴지통에서 복원)
_ => {
summary.skipped += 1;
false
}
};
if undone {
let jid = e.id;
db.with_write(move |conn| {
conn.execute("UPDATE ops_journal SET undone = 1 WHERE id = ?1", [jid])
})?;
summary.done += 1;
}
}
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
let dbdir = tempfile::tempdir().unwrap();
let media = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
std::fs::create_dir_all(media.path().join("a")).unwrap();
std::fs::create_dir_all(media.path().join("b")).unwrap();
std::fs::write(media.path().join("a/사진1.jpg"), b"one").unwrap();
std::fs::write(media.path().join("a/사진2.jpg"), b"two").unwrap();
let root = media.path().to_string_lossy().into_owned();
let sid = db
.with_write(move |conn| {
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
Ok(conn.last_insert_rowid())
})
.unwrap();
let cancel = std::sync::atomic::AtomicBool::new(false);
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
(dbdir, media, db, sid)
}
fn folder_id_of(db: &Db, suffix: &str) -> i64 {
let sfx = suffix.to_string();
db.with_read(move |c| {
c.query_row(
"SELECT id FROM folders WHERE path LIKE '%' || ?1",
[&sfx],
|r| r.get(0),
)
})
.unwrap()
}
fn file_id_of(db: &Db, name: &str) -> i64 {
let n = name.to_string();
db.with_read(move |c| {
c.query_row(
"SELECT id FROM files WHERE name = ?1 AND deleted_at IS NULL",
[&n],
|r| r.get(0),
)
})
.unwrap()
}
#[test]
fn move_and_undo_roundtrip() {
let (_d, media, db, _sid) = setup();
let fid = file_id_of(&db, "사진1.jpg");
let dest = folder_id_of(&db, "b");
let bid = new_batch_id();
let s = move_files(&db, &bid, &[fid], dest, |_, _| {}).unwrap();
assert_eq!(s.done, 1);
assert!(media.path().join("b/사진1.jpg").is_file());
assert!(!media.path().join("a/사진1.jpg").exists());
// DB 반영 + id 보존
assert_eq!(file_id_of(&db, "사진1.jpg"), fid);
// undo → 원위치
let u = undo_batch(&db, &bid).unwrap();
assert_eq!(u.done, 1);
assert!(media.path().join("a/사진1.jpg").is_file());
assert!(!media.path().join("b/사진1.jpg").exists());
}
#[test]
fn move_collision_auto_renames() {
let (_d, media, db, _sid) = setup();
std::fs::write(media.path().join("b/사진1.jpg"), b"existing").unwrap();
let fid = file_id_of(&db, "사진1.jpg");
let dest = folder_id_of(&db, "b");
let s = move_files(&db, &new_batch_id(), &[fid], dest, |_, _| {}).unwrap();
assert_eq!(s.done, 1);
assert!(media.path().join("b/사진1 (2).jpg").is_file());
assert!(media.path().join("b/사진1.jpg").is_file()); // 기존 파일 무사
}
#[test]
fn rename_and_undo() {
let (_d, media, db, _sid) = setup();
let fid = file_id_of(&db, "사진2.jpg");
let bid = new_batch_id();
rename_file(&db, &bid, fid, "새이름.jpg").unwrap();
assert!(media.path().join("a/새이름.jpg").is_file());
assert_eq!(file_id_of(&db, "새이름.jpg"), fid);
assert!(rename_file(&db, &bid, fid, "bad/name.jpg").is_err());
undo_batch(&db, &bid).unwrap();
assert!(media.path().join("a/사진2.jpg").is_file());
}
#[test]
fn tag_undo_roundtrip() {
let (_d, _m, db, _sid) = setup();
let fid = file_id_of(&db, "사진1.jpg");
let tag = crate::tags::create_tag(&db, "테스트태그".into(), None).unwrap();
let bid = new_batch_id();
crate::tags::assign(&db, &bid, vec![fid], tag).unwrap();
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 1);
undo_batch(&db, &bid).unwrap();
assert_eq!(crate::tags::file_tags(&db, fid).unwrap().len(), 0);
}
}
@@ -0,0 +1,88 @@
//! 해시 계층: quick_hash(중복 사전필터·이동감지) / full_hash(확정 중복).
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use xxhash_rust::xxh3::Xxh3;
const CHUNK: usize = 128 * 1024;
/// xxh3(크기 ++ 앞 128KB ++ 뒤 128KB). 최대 256KB만 읽는다.
pub fn quick_hash(path: &Path) -> std::io::Result<u64> {
let mut f = File::open(path)?;
let len = f.metadata()?.len();
let mut hasher = Xxh3::new();
hasher.update(&len.to_le_bytes());
let mut buf = vec![0u8; CHUNK];
let head = read_up_to(&mut f, &mut buf)?;
hasher.update(&buf[..head]);
if len > (2 * CHUNK) as u64 {
f.seek(SeekFrom::End(-(CHUNK as i64)))?;
let tail = read_up_to(&mut f, &mut buf)?;
hasher.update(&buf[..tail]);
}
Ok(hasher.digest())
}
/// BLAKE3 전체 해시 (mmap + rayon — NVMe 포화 수준).
pub fn full_hash(path: &Path) -> std::io::Result<[u8; 32]> {
let mut hasher = blake3::Hasher::new();
hasher.update_mmap_rayon(path)?;
Ok(*hasher.finalize().as_bytes())
}
fn read_up_to(f: &mut File, buf: &mut [u8]) -> std::io::Result<usize> {
let mut total = 0;
while total < buf.len() {
match f.read(&mut buf[total..])? {
0 => break,
n => total += n,
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quick_hash_distinguishes_middle_unchanged() {
// head/tail이 같고 크기도 같으면 quick_hash는 같다 (설계상 허용 — full_hash가 확정)
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.bin");
let b = dir.path().join("b.bin");
let mut data = vec![0u8; 3 * CHUNK];
std::fs::write(&a, &data).unwrap();
data[CHUNK + 500] = 0xFF; // 가운데만 변경
std::fs::write(&b, &data).unwrap();
assert_eq!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
assert_ne!(full_hash(&a).unwrap(), full_hash(&b).unwrap());
}
#[test]
fn quick_hash_detects_head_change() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.bin");
let b = dir.path().join("b.bin");
std::fs::write(&a, b"hello world").unwrap();
std::fs::write(&b, b"hello wOrld").unwrap();
assert_ne!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
}
#[test]
fn identical_files_same_hashes() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.bin");
let b = dir.path().join("b.bin");
let data = vec![7u8; 5 * CHUNK + 13];
std::fs::write(&a, &data).unwrap();
std::fs::write(&b, &data).unwrap();
assert_eq!(quick_hash(&a).unwrap(), quick_hash(&b).unwrap());
assert_eq!(full_hash(&a).unwrap(), full_hash(&b).unwrap());
}
}
@@ -0,0 +1,15 @@
//! 스캔·해시·썸네일·중복탐지 엔진. Tauri 의존성 없음 — 헤드리스 테스트 가능.
pub mod dedupe;
pub mod ffmpeg;
pub mod fileops;
pub mod hash;
pub mod maintenance;
pub mod meta;
pub mod model;
pub mod phash;
pub mod pipeline;
pub mod scan;
pub mod tags;
pub mod thumbq;
pub mod thumbs;
@@ -0,0 +1,178 @@
//! 유지보수 — 썸네일 캐시 LRU 퍼지, 소프트삭제 행 하드 퍼지, DB 백업.
use archive_db::Db;
use std::path::Path;
/// 썸네일 캐시 총량이 상한을 넘으면 오래된 것부터(created_at) 삭제한다.
/// 반환: 삭제한 바이트 수.
pub fn prune_thumb_cache(db: &Db, thumb_root: &Path, max_bytes: i64) -> u64 {
let total: i64 = db
.with_read(|conn| {
conn.query_row("SELECT COALESCE(SUM(bytes), 0) FROM thumbs", [], |r| r.get(0))
})
.unwrap_or(0);
if total <= max_bytes {
return 0;
}
let mut to_free = total - max_bytes;
// 오래된 것부터 (파일당 cache_key/size_class 필요 — 경로 재구성)
let victims: Vec<(i64, i64, String, i64)> = db
.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT file_id, size_class, cache_key, bytes FROM thumbs
ORDER BY created_at ASC",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?;
rows.collect()
})
.unwrap_or_default();
let mut freed = 0u64;
let mut removed: Vec<(i64, i64)> = Vec::new();
for (file_id, size_class, key, bytes) in victims {
if to_free <= 0 {
break;
}
let path = crate::thumbq::thumb_path(thumb_root, size_class as u8, &key);
let _ = std::fs::remove_file(&path);
removed.push((file_id, size_class));
freed += bytes.max(0) as u64;
to_free -= bytes.max(0);
}
if !removed.is_empty() {
let _ = db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut del = tx.prepare_cached(
"DELETE FROM thumbs WHERE file_id = ?1 AND size_class = ?2",
)?;
let mut reset = tx.prepare_cached(
"UPDATE files SET thumb_state = 0 WHERE id = ?1 AND thumb_state = 1",
)?;
for (file_id, size_class) in &removed {
del.execute(rusqlite::params![file_id, size_class])?;
reset.execute([file_id])?;
}
}
tx.commit()
});
}
freed
}
/// N일보다 오래된 소프트삭제 행을 하드 삭제한다 (FK CASCADE로 관련 데이터 정리).
pub fn purge_deleted(db: &Db, older_than_ms: i64) -> u64 {
db.with_write(move |conn| {
let n = conn.execute(
"DELETE FROM files WHERE deleted_at IS NOT NULL AND deleted_at < ?1",
[older_than_ms],
)?;
Ok(n as u64)
})
.unwrap_or(0)
}
/// DB를 백업 파일로 복제한다 (VACUUM INTO — 조각모음 겸용).
pub fn backup_db(db: &Db, backup_path: &Path) -> Result<(), String> {
let _ = std::fs::remove_file(backup_path);
let path_str = backup_path.to_string_lossy().into_owned();
db.with_write(move |conn| {
conn.execute("VACUUM INTO ?1", [&path_str])?;
Ok(())
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn prune_removes_oldest_over_limit() {
let dbdir = tempfile::tempdir().unwrap();
let thumbs = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
// 썸네일 파일 5개 생성 (파일 I/O는 클로저 밖에서)
for i in 1..=5 {
let key = format!("{i:032x}");
let p = crate::thumbq::thumb_path(thumbs.path(), 0, &key);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, vec![0u8; 1000]).unwrap();
}
db.with_write(|conn| {
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
for i in 1..=5 {
conn.execute(
"INSERT INTO files(id,source_id,folder_id,name,ext,kind,size,mtime_ms,thumb_state)
VALUES(?1,1,1,?2,'jpg',0,100,0,1)",
rusqlite::params![i, format!("f{i}.jpg")],
)?;
let key = format!("{i:032x}");
conn.execute(
"INSERT INTO thumbs(file_id,size_class,cache_key,bytes,created_at) VALUES(?1,0,?2,1000,?3)",
rusqlite::params![i, key, i], // created_at = i (1이 가장 오래됨)
)?;
}
Ok(())
})
.unwrap();
// 5000바이트 중 2500 상한 → 오래된 것부터 최소 2500 삭제
let freed = prune_thumb_cache(&db, thumbs.path(), 2500);
assert!(freed >= 2500, "freed={freed}");
let remaining: i64 = db
.with_read(|c| c.query_row("SELECT count(*) FROM thumbs", [], |r| r.get(0)))
.unwrap();
assert!(remaining < 5);
// 가장 오래된 f1 썸네일 파일이 삭제됐는지
assert!(!crate::thumbq::thumb_path(thumbs.path(), 0, &format!("{:032x}", 1)).exists());
}
#[test]
fn purge_old_deleted_rows() {
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
db.with_write(|conn| {
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
conn.execute("INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms,deleted_at) VALUES(1,1,'old.jpg','jpg',0,1,0,1000)", [])?;
conn.execute("INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms,deleted_at) VALUES(1,1,'recent.jpg','jpg',0,1,0,999999999999)", [])?;
Ok(())
}).unwrap();
let purged = purge_deleted(&db, 100000);
assert_eq!(purged, 1);
let count: i64 = db.with_read(|c| c.query_row("SELECT count(*) FROM files", [], |r| r.get(0))).unwrap();
assert_eq!(count, 1); // recent만 남음
}
#[test]
fn backup_creates_valid_db() {
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
db.with_write(|conn| {
conn.execute("INSERT INTO tags(name) VALUES('백업테스트')", [])?;
Ok(())
})
.unwrap();
let backup = dbdir.path().join("backup.db");
backup_db(&db, &backup).unwrap();
assert!(backup.exists());
// 백업 파일을 열어 데이터 확인
let restored = Db::open(&backup).unwrap();
let name: String = restored
.with_read(|c| c.query_row("SELECT name FROM tags", [], |r| r.get(0)))
.unwrap();
assert_eq!(name, "백업테스트");
}
}
@@ -0,0 +1,253 @@
//! 스캔 2단계: 이미지 메타데이터 추출 (치수는 헤더만 읽는 imagesize, EXIF 촬영일시/회전).
//! 영상 메타데이터(ffprobe)는 M2에서 추가.
use archive_db::Db;
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
const BATCH: usize = 512;
struct MetaResult {
id: i64,
width: Option<u32>,
height: Option<u32>,
orientation: u32,
taken_at: Option<i64>,
ok: bool,
}
fn exif_of(path: &Path) -> (u32, Option<i64>) {
let Ok(file) = std::fs::File::open(path) else {
return (1, None);
};
let mut reader = std::io::BufReader::new(&file);
let Ok(ex) = exif::Reader::new().read_from_container(&mut reader) else {
return (1, None);
};
let orientation = ex
.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
.unwrap_or(1);
let taken_at = ex
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
.or_else(|| ex.get_field(exif::Tag::DateTime, exif::In::PRIMARY))
.and_then(|f| match &f.value {
exif::Value::Ascii(v) => v.first().map(|b| String::from_utf8_lossy(b).into_owned()),
_ => None,
})
.and_then(|s| parse_exif_datetime(&s));
(orientation, taken_at)
}
/// "YYYY:MM:DD HH:MM:SS" → epoch ms (로컬 시간대 무시, UTC로 근사 — 정렬 용도)
fn parse_exif_datetime(s: &str) -> Option<i64> {
let s = s.trim();
if s.len() < 19 {
return None;
}
let y: i64 = s.get(0..4)?.parse().ok()?;
let mo: i64 = s.get(5..7)?.parse().ok()?;
let d: i64 = s.get(8..10)?.parse().ok()?;
let h: i64 = s.get(11..13)?.parse().ok()?;
let mi: i64 = s.get(14..16)?.parse().ok()?;
let sec: i64 = s.get(17..19)?.parse().ok()?;
if !(1..=9999).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
return None;
}
// days since epoch (civil) — Howard Hinnant 알고리즘
let y_adj = if mo <= 2 { y - 1 } else { y };
let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400;
let yoe = y_adj - era * 400;
let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Some(((days * 86400) + h * 3600 + mi * 60 + sec) * 1000)
}
/// meta_state=0인 이미지들의 메타데이터를 추출한다. 처리한 개수를 반환.
pub fn extract_image_meta(
db: &Arc<Db>,
source_id: i64,
cancel: &AtomicBool,
) -> Result<u64, archive_db::DbError> {
let mut total = 0u64;
loop {
if cancel.load(Ordering::Relaxed) {
break;
}
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.source_id = ?1 AND f.meta_state = 0 AND f.kind = 0 AND f.deleted_at IS NULL
LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![source_id, BATCH as i64], |r| {
let dir: String = r.get(1)?;
let name: String = r.get(2)?;
Ok((r.get::<_, i64>(0)?, Path::new(&dir).join(name)))
})?;
rows.collect()
})?;
if batch.is_empty() {
break;
}
let results: Vec<MetaResult> = batch
.par_iter()
.map(|(id, path)| {
let dims = imagesize::size(path).ok();
let (orientation, taken_at) = exif_of(path);
MetaResult {
id: *id,
width: dims.map(|d| d.width as u32),
height: dims.map(|d| d.height as u32),
orientation,
taken_at,
ok: dims.is_some(),
}
})
.collect();
total += results.len() as u64;
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare_cached(
"UPDATE files SET width=?2, height=?3, orientation=?4, taken_at=?5, meta_state=?6
WHERE id = ?1",
)?;
for r in &results {
stmt.execute(rusqlite::params![
r.id,
r.width,
r.height,
r.orientation,
r.taken_at,
if r.ok { 1 } else { 2 },
])?;
}
}
tx.commit()
})?;
}
Ok(total)
}
/// meta_state=0인 영상들의 메타데이터를 ffprobe로 추출한다. 처리한 개수를 반환.
pub fn extract_video_meta(
db: &Arc<Db>,
source_id: i64,
cancel: &AtomicBool,
tools: &crate::ffmpeg::FfTools,
) -> Result<u64, archive_db::DbError> {
let mut total = 0u64;
loop {
if cancel.load(Ordering::Relaxed) {
break;
}
let batch: Vec<(i64, PathBuf)> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT f.id, fo.path, f.name FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.source_id = ?1 AND f.meta_state = 0 AND f.kind = 1 AND f.deleted_at IS NULL
LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![source_id, BATCH as i64], |r| {
let dir: String = r.get(1)?;
let name: String = r.get(2)?;
Ok((r.get::<_, i64>(0)?, Path::new(&dir).join(name)))
})?;
rows.collect()
})?;
if batch.is_empty() {
break;
}
let results: Vec<(i64, Option<crate::ffmpeg::MediaInfo>)> = batch
.par_iter()
.map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok()))
.collect();
total += results.len() as u64;
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare_cached(
"UPDATE files SET width=?2, height=?3, duration_ms=?4, vcodec=?5, acodec=?6,
fps=?7, meta_state=?8 WHERE id = ?1",
)?;
for (id, info) in &results {
match info {
Some(i) => stmt.execute(rusqlite::params![
id, i.width, i.height, i.duration_ms, i.vcodec, i.acodec, i.fps, 1
])?,
None => stmt.execute(rusqlite::params![
id,
Option::<u32>::None,
Option::<u32>::None,
Option::<i64>::None,
Option::<String>::None,
Option::<String>::None,
Option::<f64>::None,
2
])?,
};
}
}
tx.commit()
})?;
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exif_datetime_parsing() {
// 2020-01-01 00:00:00 UTC = 1577836800000
assert_eq!(parse_exif_datetime("2020:01:01 00:00:00"), Some(1_577_836_800_000));
assert_eq!(parse_exif_datetime("잘못된값"), None);
assert_eq!(parse_exif_datetime("2020:13:01 00:00:00"), None);
}
#[test]
fn extracts_dimensions_for_pending_images() {
let dbdir = tempfile::tempdir().unwrap();
let media = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
let img = image::RgbImage::from_pixel(320, 200, image::Rgb([1, 2, 3]));
img.save(media.path().join("x.png")).unwrap();
let root = media.path().to_string_lossy().into_owned();
let sid = db
.with_write({
let root = root.clone();
move |conn| {
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
Ok(conn.last_insert_rowid())
}
})
.unwrap();
let cancel = AtomicBool::new(false);
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
let n = extract_image_meta(&db, sid, &cancel).unwrap();
assert_eq!(n, 1);
let (w, h, state): (u32, u32, i64) = db
.with_read(|c| {
c.query_row(
"SELECT width, height, meta_state FROM files WHERE name='x.png'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
})
.unwrap();
assert_eq!((w, h, state), (320, 200, 1));
}
}
@@ -0,0 +1,62 @@
//! 파일 분류 모델.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum FileKind {
Image = 0,
Video = 1,
Other = 2,
}
const IMAGE_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "heic", "heif", "tif", "tiff", "jfif",
// RAW — v1은 내장 JPEG 프리뷰만 표시
"cr2", "cr3", "nef", "arw", "raf", "dng", "orf", "rw2",
];
const VIDEO_EXTS: &[&str] = &[
"mp4", "m4v", "mov", "mkv", "avi", "wmv", "webm", "flv", "ts", "m2ts", "mts", "mpg", "mpeg",
"3gp", "ogv", "vob",
];
/// 소문자 확장자(점 제외)로 파일 종류를 분류한다.
pub fn classify_ext(ext: &str) -> FileKind {
if IMAGE_EXTS.contains(&ext) {
FileKind::Image
} else if VIDEO_EXTS.contains(&ext) {
FileKind::Video
} else {
FileKind::Other
}
}
/// 경로에서 소문자 확장자를 뽑는다.
pub fn ext_of(path: &std::path::Path) -> Option<String> {
path.extension()
.map(|e| e.to_string_lossy().to_lowercase())
}
/// 인덱싱 대상인지 (이미지/영상만 인덱싱)
pub fn is_media_ext(ext: &str) -> bool {
classify_ext(ext) != FileKind::Other
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify() {
assert_eq!(classify_ext("jpg"), FileKind::Image);
assert_eq!(classify_ext("heic"), FileKind::Image);
assert_eq!(classify_ext("mkv"), FileKind::Video);
assert_eq!(classify_ext("txt"), FileKind::Other);
}
#[test]
fn ext_extraction() {
use std::path::Path;
assert_eq!(ext_of(Path::new("C:\\a\\사진.JPG")).as_deref(), Some("jpg"));
assert_eq!(ext_of(Path::new("C:\\a\\noext")), None);
}
}
@@ -0,0 +1,147 @@
//! 지각 해시 (perceptual hash) — 64비트 dHash + 해밍 거리 클러스터링.
use image_hasher::{HashAlg, HasherConfig};
use std::path::Path;
/// 64비트 dHash(gradient) 계산 — 리사이즈/재인코딩에 강건하다.
/// 썸네일(256px)에서 계산해도 원본과 거의 동일한 해시가 나온다.
pub fn dhash(img: &image::DynamicImage) -> u64 {
let hasher = HasherConfig::new()
.hash_size(8, 8)
.hash_alg(HashAlg::Gradient)
.to_hasher();
let h = hasher.hash_image(img);
let bytes = h.as_bytes();
let mut v = 0u64;
for (i, b) in bytes.iter().take(8).enumerate() {
v |= (*b as u64) << (i * 8);
}
v
}
/// 파일에서 직접 dHash (썸네일 우선, 없으면 원본).
pub fn dhash_file(path: &Path) -> Option<u64> {
let img = image::ImageReader::open(path).ok()?.with_guessed_format().ok()?.decode().ok()?;
Some(dhash(&img))
}
/// 64비트 해밍 거리 (XOR + popcount — SIMD 자동 벡터화)
#[inline]
pub fn hamming(a: u64, b: u64) -> u32 {
(a ^ b).count_ones()
}
/// (id, phash) 목록에서 임계값 이하 근접쌍을 찾는다.
/// 병렬 브루트포스 상삼각 스캔 — 10만개 ≈ 5×10⁹ 비교, 8코어 1–2초.
/// 반환: (id_a, id_b, distance) — 항상 id_a < id_b.
pub fn find_near_pairs(items: &[(i64, u64)], threshold: u32) -> Vec<(i64, i64, u32)> {
use rayon::prelude::*;
let n = items.len();
if n < 2 {
return Vec::new();
}
(0..n)
.into_par_iter()
.flat_map_iter(|i| {
let (id_a, ha) = items[i];
let mut local = Vec::new();
for j in (i + 1)..n {
let (id_b, hb) = items[j];
let d = hamming(ha, hb);
if d <= threshold {
let (lo, hi) = if id_a < id_b { (id_a, id_b) } else { (id_b, id_a) };
local.push((lo, hi, d));
}
}
local
})
.collect()
}
/// Union-Find로 근접쌍을 그룹으로 묶는다.
pub fn cluster(ids: &[i64], pairs: &[(i64, i64, u32)]) -> Vec<Vec<i64>> {
use std::collections::HashMap;
let mut index: HashMap<i64, usize> = HashMap::with_capacity(ids.len());
for (i, id) in ids.iter().enumerate() {
index.insert(*id, i);
}
let mut parent: Vec<usize> = (0..ids.len()).collect();
fn find(parent: &mut [usize], mut x: usize) -> usize {
while parent[x] != x {
parent[x] = parent[parent[x]];
x = parent[x];
}
x
}
for (a, b, _) in pairs {
let (Some(&ia), Some(&ib)) = (index.get(a), index.get(b)) else {
continue;
};
let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib));
if ra != rb {
parent[ra] = rb;
}
}
let mut groups: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
for (i, id) in ids.iter().enumerate() {
let root = find(&mut parent, i);
groups.entry(root).or_default().push(*id);
}
groups.into_values().filter(|g| g.len() >= 2).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hamming_distance() {
assert_eq!(hamming(0b1010, 0b1010), 0);
assert_eq!(hamming(0b1010, 0b1011), 1);
assert_eq!(hamming(0, u64::MAX), 64);
}
#[test]
fn near_pairs_and_clustering() {
// 0,1은 가깝고(거리1), 2는 멀다
let items = vec![
(10i64, 0b0000u64),
(20i64, 0b0001u64),
(30i64, 0b1111_1111_1111u64),
];
let pairs = find_near_pairs(&items, 5);
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0], (10, 20, 1));
let ids: Vec<i64> = items.iter().map(|(id, _)| *id).collect();
let groups = cluster(&ids, &pairs);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 2);
}
#[test]
fn transitive_clustering() {
// A-B 가깝고 B-C 가까우면 A,B,C 한 그룹
let pairs = vec![(1i64, 2i64, 2u32), (2, 3, 3)];
let ids = vec![1i64, 2, 3, 4];
let mut groups = cluster(&ids, &pairs);
groups.sort_by_key(|g| g.len());
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 3);
}
#[test]
fn resized_image_has_close_hash() {
// 같은 이미지의 원본과 축소본은 dHash가 거의 같아야 한다
let img = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(400, 300, |x, y| {
image::Rgb([((x * 255) / 400) as u8, ((y * 255) / 300) as u8, 128])
}));
let small = img.resize_exact(120, 90, image::imageops::FilterType::Lanczos3);
let h1 = dhash(&img);
let h2 = dhash(&small);
assert!(hamming(h1, h2) <= 5, "리사이즈 후 해밍 거리 {}", hamming(h1, h2));
}
}
@@ -0,0 +1,577 @@
//! 스캔 파이프라인 — 열거(jwalk) → 스냅샷 비교 → 배치 upsert → 이동 감지 → 소프트 삭제.
//!
//! 핵심 성능 설계: 스캔 시작 시 소스의 활성 행 전체를 (folder_id, name) 키의
//! 메모리 스냅샷으로 로드하고, size+mtime이 같은 파일은 **DB 쓰기를 완전히
//! 생략**한다. 무변경 재스캔의 비용은 디렉터리 열거 + 스냅샷 로드뿐이다.
use crate::model::{classify_ext, ext_of};
use crate::scan::{enumerate_items, RawEntry, WalkItem};
use archive_db::Db;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
#[derive(Debug, thiserror::Error)]
pub enum ScanError {
#[error("DB 오류: {0}")]
Db(#[from] archive_db::DbError),
#[error("취소됨")]
Cancelled,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct ScanStats {
pub seen: u64,
pub added: u64,
pub changed: u64,
pub removed: u64,
pub moved: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanProgress {
pub scan_id: i64,
pub phase: String, // enumerate | metadata | done
pub seen: u64,
pub current_dir: String,
pub done: bool,
}
/// 스냅샷/이동감지용 기존 행 정보
#[derive(Clone)]
struct SnapRow {
id: i64,
size: i64,
mtime_ms: i64,
name: String,
quick_hash: Option<i64>,
}
const BATCH: usize = 1000;
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// 폴더 행을 보장하고 id를 돌려준다 (조상 폴더 포함, 캐시 사용).
fn ensure_folder(
db: &Db,
cache: &mut HashMap<PathBuf, i64>,
source_id: i64,
root: &Path,
dir: &Path,
) -> Result<i64, ScanError> {
if let Some(id) = cache.get(dir) {
return Ok(*id);
}
let parent_id = if dir == root {
None
} else {
let parent = dir.parent().unwrap_or(root);
Some(ensure_folder(db, cache, source_id, root, parent)?)
};
let path_str = dir.to_string_lossy().into_owned();
let name = dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| dir.to_string_lossy().into_owned());
let id = db.with_write(move |conn| {
conn.execute(
"INSERT INTO folders(source_id, parent_id, path, name) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(source_id, path) DO UPDATE SET name = excluded.name",
rusqlite::params![source_id, parent_id, path_str, name],
)?;
conn.query_row(
"SELECT id FROM folders WHERE source_id = ?1 AND path = ?2",
rusqlite::params![source_id, path_str],
|r| r.get(0),
)
})?;
cache.insert(dir.to_path_buf(), id);
Ok(id)
}
/// 소스의 활성 파일 행을 (folder_id, name) 키로 로드한다.
fn load_snapshot(
db: &Db,
source_id: i64,
) -> Result<HashMap<(i64, String), SnapRow>, ScanError> {
let rows: Vec<(i64, SnapRow)> = db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT folder_id, id, size, mtime_ms, name, quick_hash
FROM files WHERE source_id = ?1 AND deleted_at IS NULL",
)?;
let rows = stmt.query_map([source_id], |r| {
Ok((
r.get::<_, i64>(0)?,
SnapRow {
id: r.get(1)?,
size: r.get(2)?,
mtime_ms: r.get(3)?,
name: r.get(4)?,
quick_hash: r.get(5)?,
},
))
})?;
rows.collect()
})?;
Ok(rows
.into_iter()
.map(|(folder_id, row)| ((folder_id, row.name.clone()), row))
.collect())
}
fn flush_batch(db: &Db, scan_id: i64, batch: Vec<(i64, RawEntry)>) -> Result<(), ScanError> {
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare_cached(
"INSERT INTO files(source_id, folder_id, name, ext, kind, size, mtime_ms, scan_id)
SELECT fo.source_id, ?1, ?2, ?3, ?4, ?5, ?6, ?7 FROM folders fo WHERE fo.id = ?1
ON CONFLICT(folder_id, name) DO UPDATE SET
scan_id = excluded.scan_id,
deleted_at = NULL,
meta_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.meta_state ELSE 0 END,
thumb_state = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.thumb_state ELSE 0 END,
quick_hash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.quick_hash ELSE NULL END,
full_hash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.full_hash ELSE NULL END,
phash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.phash ELSE NULL END,
vhash = CASE WHEN files.size = excluded.size AND files.mtime_ms = excluded.mtime_ms
THEN files.vhash ELSE NULL END,
size = excluded.size,
mtime_ms = excluded.mtime_ms",
)?;
for (folder_id, e) in &batch {
let name = e
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = ext_of(&e.path).unwrap_or_default();
let kind = classify_ext(&ext) as u8;
stmt.execute(rusqlite::params![
folder_id,
name,
ext,
kind,
e.size as i64,
e.mtime_ms,
scan_id
])?;
}
}
tx.commit()
})?;
Ok(())
}
/// 이동 감지: 사라진 행(missing)과 이번 스캔에서 새로 추가된 행을
/// ① (size, name) ② (size, quick_hash 저장분) 기준으로 매칭해
/// 행 id를 보존한 채 경로만 갱신한다 (태그·썸네일 보존).
/// 반환: (moved, 소프트삭제 대상 id 목록)
fn detect_moves(
db: &Db,
source_id: i64,
scan_id: i64,
scan_start: i64,
missing: Vec<SnapRow>,
) -> Result<(u64, Vec<i64>), ScanError> {
if missing.is_empty() {
return Ok((0, Vec::new()));
}
struct NewRow {
id: i64,
size: i64,
name: String,
folder_id: i64,
path: PathBuf,
}
let added: Vec<NewRow> = db.with_read(|conn| {
let mut stmt = conn.prepare(
"SELECT f.id, f.size, f.name, f.folder_id, fo.path FROM files f
JOIN folders fo ON fo.id = f.folder_id
WHERE f.source_id = ?1 AND f.scan_id = ?2 AND f.added_at >= ?3",
)?;
let rows = stmt.query_map(rusqlite::params![source_id, scan_id, scan_start], |r| {
let dir: String = r.get(4)?;
let name: String = r.get(2)?;
Ok(NewRow {
id: r.get(0)?,
size: r.get(1)?,
name: name.clone(),
folder_id: r.get(3)?,
path: Path::new(&dir).join(&name),
})
})?;
rows.collect()
})?;
if added.is_empty() {
return Ok((0, missing.into_iter().map(|m| m.id).collect()));
}
let mut by_size_name: HashMap<(i64, &str), &SnapRow> = HashMap::new();
let mut by_size_hash: HashMap<(i64, i64), &SnapRow> = HashMap::new();
for m in &missing {
by_size_name.entry((m.size, m.name.as_str())).or_insert(m);
if let Some(qh) = m.quick_hash {
by_size_hash.entry((m.size, qh)).or_insert(m);
}
}
let mut moves: Vec<(i64, i64, String)> = Vec::new(); // (old_id, new_folder_id, new_name)
let mut new_ids_to_delete: Vec<i64> = Vec::new();
let mut matched_old: std::collections::HashSet<i64> = Default::default();
for n in &added {
let matched = by_size_name
.get(&(n.size, n.name.as_str()))
.copied()
.filter(|m| !matched_old.contains(&m.id))
.or_else(|| {
if by_size_hash.is_empty() {
return None;
}
let qh = crate::hash::quick_hash(&n.path).ok()? as i64;
by_size_hash
.get(&(n.size, qh))
.copied()
.filter(|m| !matched_old.contains(&m.id))
});
if let Some(m) = matched {
matched_old.insert(m.id);
new_ids_to_delete.push(n.id);
moves.push((m.id, n.folder_id, n.name.clone()));
}
}
let moved = moves.len() as u64;
if moved > 0 {
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut del = tx.prepare_cached("DELETE FROM files WHERE id = ?1")?;
for id in &new_ids_to_delete {
del.execute([id])?;
}
let mut upd = tx.prepare_cached(
"UPDATE files SET folder_id = ?2, name = ?3, scan_id = ?4 WHERE id = ?1",
)?;
for (old_id, folder_id, name) in &moves {
upd.execute(rusqlite::params![old_id, folder_id, name, scan_id])?;
}
}
tx.commit()
})?;
}
let to_delete = missing
.into_iter()
.filter(|m| !matched_old.contains(&m.id))
.map(|m| m.id)
.collect();
Ok((moved, to_delete))
}
fn prune_stale_folders(
db: &Db,
source_id: i64,
seen: &HashMap<PathBuf, i64>,
) -> Result<(), ScanError> {
let seen_ids: std::collections::HashSet<i64> = seen.values().copied().collect();
let all: Vec<(i64, String)> = db.with_read(|conn| {
let mut stmt =
conn.prepare_cached("SELECT id, path FROM folders WHERE source_id = ?1")?;
let rows = stmt.query_map([source_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect()
})?;
let mut stale: Vec<(i64, String)> = all
.into_iter()
.filter(|(id, _)| !seen_ids.contains(id))
.collect();
if stale.is_empty() {
return Ok(());
}
// 자식(더 긴 경로) 먼저 삭제 — parent_id FK 위반 방지
stale.sort_by_key(|(_, p)| std::cmp::Reverse(p.len()));
db.with_write(move |conn| {
let mut stmt = conn.prepare_cached(
"DELETE FROM folders WHERE id = ?1
AND NOT EXISTS (SELECT 1 FROM files WHERE folder_id = ?1)
AND NOT EXISTS (SELECT 1 FROM folders c WHERE c.parent_id = ?1)",
)?;
for (id, _) in &stale {
let _ = stmt.execute([id]);
}
Ok(())
})?;
Ok(())
}
fn soft_delete(db: &Db, ids: Vec<i64>) -> Result<u64, ScanError> {
if ids.is_empty() {
return Ok(0);
}
let count = ids.len() as u64;
let ts = now_ms();
db.with_write(move |conn| {
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare_cached("UPDATE files SET deleted_at = ?2 WHERE id = ?1")?;
for id in &ids {
stmt.execute(rusqlite::params![id, ts])?;
}
}
tx.commit()
})?;
Ok(count)
}
/// 소스 하나를 스캔한다. 블로킹 — 전용 스레드에서 호출할 것.
pub fn scan_source(
db: &Arc<Db>,
source_id: i64,
root: &Path,
cancel: &AtomicBool,
mut on_progress: impl FnMut(ScanProgress),
) -> Result<ScanStats, ScanError> {
let scan_start = now_ms();
let scan_id: i64 = db.with_write(move |conn| {
conn.execute(
"INSERT INTO scans(source_id, started_at, status) VALUES (?1, ?2, 0)",
rusqlite::params![source_id, scan_start],
)?;
Ok(conn.last_insert_rowid())
})?;
let mut stats = ScanStats::default();
let mut snapshot = load_snapshot(db, source_id)?;
let mut folder_cache: HashMap<PathBuf, i64> = HashMap::new();
let mut batch: Vec<(i64, RawEntry)> = Vec::with_capacity(BATCH);
let mut last_emit = Instant::now();
for item in enumerate_items(root) {
if cancel.load(Ordering::Relaxed) {
db.with_write(move |conn| {
conn.execute(
"UPDATE scans SET status = 2, finished_at = ?2 WHERE id = ?1",
rusqlite::params![scan_id, now_ms()],
)
})?;
return Err(ScanError::Cancelled);
}
let entry = match item {
WalkItem::Dir(dir) => {
// 빈 폴더도 트리에 포함 (이동 대상으로 쓰인다)
ensure_folder(db, &mut folder_cache, source_id, root, &dir)?;
continue;
}
WalkItem::File(e) => e,
};
let dir = entry.path.parent().unwrap_or(root).to_path_buf();
let folder_id = ensure_folder(db, &mut folder_cache, source_id, root, &dir)?;
stats.seen += 1;
let name = entry
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
// 스냅샷 비교 — 무변경이면 DB 쓰기 생략 (remove로 '발견됨' 표시)
match snapshot.remove(&(folder_id, name)) {
Some(prev) if prev.size == entry.size as i64 && prev.mtime_ms == entry.mtime_ms => {
// unchanged
}
Some(_) => {
stats.changed += 1;
batch.push((folder_id, entry));
}
None => {
stats.added += 1;
batch.push((folder_id, entry));
}
}
if batch.len() >= BATCH {
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
}
if last_emit.elapsed().as_millis() >= 100 {
last_emit = Instant::now();
on_progress(ScanProgress {
scan_id,
phase: "enumerate".into(),
seen: stats.seen,
current_dir: dir.to_string_lossy().into_owned(),
done: false,
});
}
}
if !batch.is_empty() {
flush_batch(db, scan_id, std::mem::take(&mut batch))?;
}
// 스냅샷에 남은 행 = 디스크에서 사라진 파일 → 이동 감지 후 소프트 삭제
let missing: Vec<SnapRow> = snapshot.into_values().collect();
let (moved, to_delete) = detect_moves(db, source_id, scan_id, scan_start, missing)?;
stats.moved = moved;
stats.removed = soft_delete(db, to_delete)?;
// 디스크에서 사라진 폴더 행 정리 (파일 참조가 없는 것만, 자식 먼저)
prune_stale_folders(db, source_id, &folder_cache)?;
let stats_json = serde_json::to_string(&stats).unwrap_or_default();
db.with_write(move |conn| {
conn.execute(
"UPDATE scans SET status = 1, finished_at = ?2, stats = ?3 WHERE id = ?1",
rusqlite::params![scan_id, now_ms(), stats_json],
)
})?;
on_progress(ScanProgress {
scan_id,
phase: "done".into(),
seen: stats.seen,
current_dir: String::new(),
done: true,
});
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (tempfile::TempDir, tempfile::TempDir, Arc<Db>, i64) {
let dbdir = tempfile::tempdir().unwrap();
let media = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
let root = media.path().to_string_lossy().into_owned();
let source_id = db
.with_write(move |conn| {
conn.execute(
"INSERT INTO sources(kind, name, root) VALUES ('local', '테스트', ?1)",
[&root],
)?;
Ok(conn.last_insert_rowid())
})
.unwrap();
(dbdir, media, db, source_id)
}
fn count_active(db: &Db) -> i64 {
db.with_read(|c| {
c.query_row("SELECT count(*) FROM files WHERE deleted_at IS NULL", [], |r| r.get(0))
})
.unwrap()
}
#[test]
fn full_scan_then_incremental() {
let (_d, media, db, sid) = setup();
let root = media.path().to_path_buf();
std::fs::create_dir_all(root.join("여행/제주")).unwrap();
std::fs::write(root.join("a.jpg"), b"aaa").unwrap();
std::fs::write(root.join("여행/b.png"), b"bbbb").unwrap();
std::fs::write(root.join("여행/제주/c.mp4"), b"ccccc").unwrap();
let cancel = AtomicBool::new(false);
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(stats.seen, 3);
assert_eq!(stats.added, 3);
assert_eq!(count_active(&db), 3);
// 변경 없는 재스캔: added/changed/removed 전부 0
let stats2 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(stats2.seen, 3);
assert_eq!(stats2.added, 0);
assert_eq!(stats2.changed, 0);
assert_eq!(stats2.removed, 0);
assert_eq!(count_active(&db), 3);
// 내용 변경(크기 변화) → changed
std::fs::write(root.join("a.jpg"), b"aaaa-longer").unwrap();
let stats3 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(stats3.changed, 1);
assert_eq!(stats3.added, 0);
// 파일 삭제 후 재스캔 → 소프트 삭제
std::fs::remove_file(root.join("a.jpg")).unwrap();
let stats4 = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(stats4.removed, 1);
assert_eq!(count_active(&db), 2);
}
#[test]
fn move_detection_preserves_row_id() {
let (_d, media, db, sid) = setup();
let root = media.path().to_path_buf();
std::fs::create_dir_all(root.join("old")).unwrap();
std::fs::create_dir_all(root.join("new")).unwrap();
std::fs::write(root.join("old/사진.jpg"), b"unique-content-123").unwrap();
let cancel = AtomicBool::new(false);
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
let old_id: i64 = db
.with_read(|c| c.query_row("SELECT id FROM files WHERE name='사진.jpg'", [], |r| r.get(0)))
.unwrap();
std::fs::rename(root.join("old/사진.jpg"), root.join("new/사진.jpg")).unwrap();
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(stats.moved, 1);
assert_eq!(stats.removed, 0);
let (new_id, folder_path): (i64, String) = db
.with_read(|c| {
c.query_row(
"SELECT f.id, fo.path FROM files f JOIN folders fo ON fo.id=f.folder_id
WHERE f.name='사진.jpg' AND f.deleted_at IS NULL",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
})
.unwrap();
assert_eq!(new_id, old_id, "행 id가 보존되어야 태그/썸네일이 유지된다");
assert!(folder_path.ends_with("new"));
assert_eq!(count_active(&db), 1);
}
#[test]
fn soft_deleted_file_resurrects_on_return() {
let (_d, media, db, sid) = setup();
let root = media.path().to_path_buf();
std::fs::write(root.join("x.jpg"), b"data").unwrap();
let cancel = AtomicBool::new(false);
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
std::fs::remove_file(root.join("x.jpg")).unwrap();
scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(count_active(&db), 0);
// 같은 이름 파일이 돌아옴 → deleted_at 해제
std::fs::write(root.join("x.jpg"), b"data").unwrap();
let stats = scan_source(&db, sid, &root, &cancel, |_| {}).unwrap();
assert_eq!(stats.added, 1);
assert_eq!(count_active(&db), 1);
}
}
@@ -0,0 +1,84 @@
//! 스캔 파이프라인 1단계: 병렬 디렉터리 열거.
//! (증분 조정·이동감지 로직은 M1에서 이 위에 얹는다)
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct RawEntry {
pub path: PathBuf,
pub size: u64,
pub mtime_ms: i64,
}
fn mtime_ms(meta: &std::fs::Metadata) -> i64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// 워크 항목: 디렉터리(빈 폴더 포함 — 이동 대상용) 또는 미디어 파일.
pub enum WalkItem {
Dir(PathBuf),
File(RawEntry),
}
/// jwalk 기반 병렬 열거 — 디렉터리와 미디어 파일을 모두 반환한다.
pub fn enumerate_items(root: &Path) -> impl Iterator<Item = WalkItem> {
jwalk::WalkDir::new(root)
.skip_hidden(true)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| {
if e.file_type().is_dir() {
return Some(WalkItem::Dir(e.path()));
}
if !e.file_type().is_file() {
return None;
}
let path = e.path();
let ext = crate::model::ext_of(&path)?;
if !crate::model::is_media_ext(&ext) {
return None;
}
let meta = e.metadata().ok()?;
Some(WalkItem::File(RawEntry {
path,
size: meta.len(),
mtime_ms: mtime_ms(&meta),
}))
})
}
/// 미디어 파일만 열거 (테스트/유틸용).
pub fn enumerate(root: &Path) -> impl Iterator<Item = RawEntry> {
enumerate_items(root).filter_map(|i| match i {
WalkItem::File(e) => Some(e),
WalkItem::Dir(_) => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enumerates_media_files_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("sub/깊은폴더")).unwrap();
std::fs::write(dir.path().join("a.jpg"), b"1").unwrap();
std::fs::write(dir.path().join("sub/b.mkv"), b"22").unwrap();
std::fs::write(dir.path().join("sub/깊은폴더/한글 이름.mp4"), b"333").unwrap();
std::fs::write(dir.path().join("ignore.txt"), b"x").unwrap();
let mut entries: Vec<_> = enumerate(dir.path()).collect();
entries.sort_by_key(|e| e.size);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].size, 1);
assert!(entries[2].path.ends_with("한글 이름.mp4"));
assert!(entries.iter().all(|e| e.mtime_ms > 0));
}
}
@@ -0,0 +1,237 @@
//! 태그 CRUD + 파일-태그 할당. 모든 변경은 ops_journal에 기록되어 undo 가능하다.
use archive_db::Db;
pub type Result<T> = std::result::Result<T, archive_db::DbError>;
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Tag {
pub id: i64,
pub name: String,
pub color: Option<String>,
pub file_count: i64,
}
pub fn list_tags(db: &Db) -> Result<Vec<Tag>> {
db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT t.id, t.name, t.color,
(SELECT count(*) FROM file_tags ft
JOIN files f ON f.id = ft.file_id
WHERE ft.tag_id = t.id AND f.deleted_at IS NULL)
FROM tags t ORDER BY t.sort_order, t.name",
)?;
let rows = stmt.query_map([], |r| {
Ok(Tag {
id: r.get(0)?,
name: r.get(1)?,
color: r.get(2)?,
file_count: r.get(3)?,
})
})?;
rows.collect()
})
}
pub fn create_tag(db: &Db, name: String, color: Option<String>) -> Result<i64> {
db.with_write(move |conn| {
conn.execute(
"INSERT INTO tags(name, color) VALUES (?1, ?2)
ON CONFLICT(name) DO UPDATE SET color = COALESCE(excluded.color, tags.color)",
rusqlite::params![name, color],
)?;
conn.query_row("SELECT id FROM tags WHERE name = ?1", [&name], |r| r.get(0))
})
}
pub fn rename_tag(db: &Db, tag_id: i64, new_name: String) -> Result<()> {
db.with_write(move |conn| {
conn.execute(
"UPDATE tags SET name = ?2 WHERE id = ?1",
rusqlite::params![tag_id, new_name],
)?;
Ok(())
})
}
pub fn delete_tag(db: &Db, tag_id: i64) -> Result<()> {
db.with_write(move |conn| {
// file_tags는 FK CASCADE로 정리된다
conn.execute("DELETE FROM tags WHERE id = ?1", [tag_id])?;
Ok(())
})
}
/// 파일들에 태그 할당. 반환: 새로 할당된 개수 (이미 있던 것 제외).
pub fn assign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Result<u64> {
let batch_id = batch_id.to_string();
db.with_write(move |conn| {
let tx = conn.transaction()?;
let mut added = 0u64;
{
let mut ins = tx.prepare_cached(
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
)?;
let mut journal = tx.prepare_cached(
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'tag', ?2, ?3)",
)?;
for fid in &file_ids {
if ins.execute(rusqlite::params![fid, tag_id])? > 0 {
journal.execute(rusqlite::params![batch_id, fid, tag_id])?;
added += 1;
}
}
}
tx.commit()?;
Ok(added)
})
}
/// 파일들에서 태그 제거.
pub fn unassign(db: &Db, batch_id: &str, file_ids: Vec<i64>, tag_id: i64) -> Result<u64> {
let batch_id = batch_id.to_string();
db.with_write(move |conn| {
let tx = conn.transaction()?;
let mut removed = 0u64;
{
let mut del = tx.prepare_cached(
"DELETE FROM file_tags WHERE file_id = ?1 AND tag_id = ?2",
)?;
let mut journal = tx.prepare_cached(
"INSERT INTO ops_journal(batch_id, op, file_id, tag_id) VALUES (?1, 'untag', ?2, ?3)",
)?;
for fid in &file_ids {
if del.execute(rusqlite::params![fid, tag_id])? > 0 {
journal.execute(rusqlite::params![batch_id, fid, tag_id])?;
removed += 1;
}
}
}
tx.commit()?;
Ok(removed)
})
}
pub fn file_tags(db: &Db, file_id: i64) -> Result<Vec<Tag>> {
db.with_read(|conn| {
let mut stmt = conn.prepare_cached(
"SELECT t.id, t.name, t.color, 0 FROM tags t
JOIN file_tags ft ON ft.tag_id = t.id WHERE ft.file_id = ?1
ORDER BY t.name",
)?;
let rows = stmt.query_map([file_id], |r| {
Ok(Tag {
id: r.get(0)?,
name: r.get(1)?,
color: r.get(2)?,
file_count: r.get(3)?,
})
})?;
rows.collect()
})
}
/// 태그 전체를 JSON으로 내보낸다 (재해 복구 보험 — 계획서 §7).
/// 형식: [{ tag, color, files: ["<절대경로>", ...] }, ...]
pub fn export_json(db: &Db) -> Result<String> {
#[derive(serde::Serialize)]
struct TagExport {
tag: String,
color: Option<String>,
files: Vec<String>,
}
let exports: Vec<TagExport> = db.with_read(|conn| {
let mut tag_stmt = conn.prepare("SELECT id, name, color FROM tags ORDER BY name")?;
let tags: Vec<(i64, String, Option<String>)> = tag_stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?;
let mut file_stmt = conn.prepare(
"SELECT fo.path, f.name FROM file_tags ft
JOIN files f ON f.id = ft.file_id
JOIN folders fo ON fo.id = f.folder_id
WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL",
)?;
let mut out = Vec::with_capacity(tags.len());
for (id, name, color) in tags {
let files: Vec<String> = file_stmt
.query_map([id], |r| {
let dir: String = r.get(0)?;
let name: String = r.get(1)?;
Ok(format!("{dir}{}{name}", std::path::MAIN_SEPARATOR))
})?
.collect::<rusqlite::Result<_>>()?;
out.push(TagExport { tag: name, color, files });
}
Ok(out)
})?;
Ok(serde_json::to_string_pretty(&exports).unwrap_or_else(|_| "[]".into()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn setup() -> (tempfile::TempDir, Arc<archive_db::Db>, Vec<i64>) {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(archive_db::Db::open(&dir.path().join("t.db")).unwrap());
let ids = db
.with_write(|conn| {
conn.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','t','C:\\m')", [])?;
conn.execute("INSERT INTO folders(id,source_id,path,name) VALUES(1,1,'C:\\m','m')", [])?;
let mut ids = Vec::new();
for i in 0..3 {
conn.execute(
"INSERT INTO files(source_id,folder_id,name,ext,kind,size,mtime_ms)
VALUES(1,1,?1,'jpg',0,100,0)",
[format!("f{i}.jpg")],
)?;
ids.push(conn.last_insert_rowid());
}
Ok(ids)
})
.unwrap();
(dir, db, ids)
}
#[test]
fn tag_crud_and_assignment() {
let (_d, db, ids) = setup();
let tag = create_tag(&db, "가족".into(), Some("#ff0000".into())).unwrap();
let added = assign(&db, "b1", ids.clone(), tag).unwrap();
assert_eq!(added, 3);
// 중복 할당은 0
assert_eq!(assign(&db, "b2", ids.clone(), tag).unwrap(), 0);
let tags = list_tags(&db).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].file_count, 3);
let ft = file_tags(&db, ids[0]).unwrap();
assert_eq!(ft[0].name, "가족");
let removed = unassign(&db, "b3", vec![ids[0]], tag).unwrap();
assert_eq!(removed, 1);
assert_eq!(list_tags(&db).unwrap()[0].file_count, 2);
// 저널 기록 확인
let journal_count: i64 = db
.with_read(|c| c.query_row("SELECT count(*) FROM ops_journal", [], |r| r.get(0)))
.unwrap();
assert_eq!(journal_count, 4); // tag 3 + untag 1
}
#[test]
fn export_contains_paths() {
let (_d, db, ids) = setup();
let tag = create_tag(&db, "여행".into(), None).unwrap();
assign(&db, "b1", vec![ids[0]], tag).unwrap();
let json = export_json(&db).unwrap();
assert!(json.contains("여행"));
assert!(json.contains("f0.jpg"));
}
}
@@ -0,0 +1,335 @@
//! 썸네일 생성 큐 — 2밴드 우선순위(뷰포트 hi / 백그라운드 lo) 워커 풀.
//! 완료 id는 150ms 배치로 콜백에 전달된다 (프론트 셀 갱신 이벤트용).
use crate::thumbs;
use archive_db::Db;
use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub struct ThumbQueue {
hi_tx: Sender<i64>,
lo_tx: Sender<i64>,
}
struct Ctx {
db: Arc<Db>,
thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>,
in_flight: Mutex<HashSet<i64>>,
done_tx: Sender<i64>,
}
impl ThumbQueue {
/// 워커 풀을 시작한다. `on_done`은 완료된 file_id 배치를 받는다.
/// `tools`가 None이면 영상 썸네일은 건너뛴다 (이미지는 정상 처리).
pub fn start(
db: Arc<Db>,
thumb_root: PathBuf,
tools: Option<crate::ffmpeg::FfTools>,
workers: usize,
on_done: impl Fn(Vec<i64>) + Send + 'static,
) -> ThumbQueue {
let (hi_tx, hi_rx) = unbounded::<i64>();
let (lo_tx, lo_rx) = unbounded::<i64>();
let (done_tx, done_rx) = unbounded::<i64>();
let ctx = Arc::new(Ctx {
db,
thumb_root,
tools,
in_flight: Mutex::new(HashSet::new()),
done_tx,
});
for i in 0..workers.max(1) {
let ctx = ctx.clone();
let hi_rx = hi_rx.clone();
let lo_rx = lo_rx.clone();
std::thread::Builder::new()
.name(format!("thumb-{i}"))
.spawn(move || worker_loop(ctx, hi_rx, lo_rx))
.expect("썸네일 워커 생성 실패");
}
// 완료 배치 이미터
std::thread::Builder::new()
.name("thumb-emit".into())
.spawn(move || {
let mut batch: Vec<i64> = Vec::new();
loop {
match done_rx.recv_timeout(Duration::from_millis(150)) {
Ok(id) => {
batch.push(id);
if batch.len() >= 256 {
on_done(std::mem::take(&mut batch));
}
}
Err(RecvTimeoutError::Timeout) => {
if !batch.is_empty() {
on_done(std::mem::take(&mut batch));
}
}
Err(RecvTimeoutError::Disconnected) => {
if !batch.is_empty() {
on_done(batch);
}
break;
}
}
}
})
.expect("썸네일 이미터 생성 실패");
ThumbQueue { hi_tx, lo_tx }
}
/// 뷰포트 우선 생성 요청 (프론트가 스크롤 시 호출).
pub fn prioritize(&self, ids: &[i64]) {
for id in ids {
let _ = self.hi_tx.send(*id);
}
}
/// 대기 중인 이미지 전체를 백그라운드 밴드에 넣는다.
pub fn enqueue_pending(&self, db: &Db, source_id: Option<i64>) {
let ids: Vec<i64> = db
.with_read(|conn| match source_id {
Some(sid) => {
let mut stmt = conn.prepare_cached(
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL AND source_id=?1",
)?;
let rows = stmt.query_map([sid], |r| r.get(0))?;
rows.collect()
}
None => {
let mut stmt = conn.prepare_cached(
"SELECT id FROM files WHERE thumb_state=0 AND kind IN (0,1) AND deleted_at IS NULL",
)?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect()
}
})
.unwrap_or_default();
for id in ids {
let _ = self.lo_tx.send(id);
}
}
}
pub fn cache_key(source_id: i64, path: &str, size: i64, mtime_ms: i64) -> String {
let key = xxhash_rust::xxh3::xxh3_128(format!("{source_id}|{path}|{size}|{mtime_ms}").as_bytes());
format!("{key:032x}")
}
/// cache_key → 썸네일 파일 경로 (256샤딩)
pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> PathBuf {
thumb_root
.join(size_class.to_string())
.join(&key[0..2])
.join(format!("{key}.jpg"))
}
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
loop {
// hi 우선, 없으면 lo를 짧게 대기
let id = match hi_rx.try_recv() {
Ok(id) => id,
Err(_) => match lo_rx.recv_timeout(Duration::from_millis(200)) {
Ok(id) => id,
Err(RecvTimeoutError::Timeout) => {
// hi 채널이 닫혔으면 종료
if hi_rx.is_empty() && lo_rx.is_empty() && hi_rx.try_recv().is_err() {
match hi_rx.recv_timeout(Duration::from_millis(1)) {
Err(RecvTimeoutError::Disconnected) => break,
Ok(id) => id,
Err(RecvTimeoutError::Timeout) => continue,
}
} else {
continue;
}
}
Err(RecvTimeoutError::Disconnected) => break,
},
};
if !ctx.in_flight.lock().unwrap().insert(id) {
continue; // 다른 워커가 처리 중
}
let result = process_one(&ctx, id);
ctx.in_flight.lock().unwrap().remove(&id);
if let Ok(true) = result {
let _ = ctx.done_tx.send(id);
}
}
}
/// true = 새로 생성/등록됨, false = 스킵
fn process_one(ctx: &Ctx, id: i64) -> Result<bool, archive_db::DbError> {
struct Row {
dir: String,
name: String,
ext: String,
size: i64,
mtime_ms: i64,
source_id: i64,
thumb_state: i64,
kind: i64,
duration_ms: Option<i64>,
}
let row = ctx.db.with_read(|conn| {
conn.query_row(
"SELECT fo.path, f.name, f.ext, f.size, f.mtime_ms, f.source_id, f.thumb_state, f.kind, f.duration_ms
FROM files f JOIN folders fo ON fo.id = f.folder_id
WHERE f.id = ?1 AND f.deleted_at IS NULL",
[id],
|r| {
Ok(Row {
dir: r.get(0)?,
name: r.get(1)?,
ext: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
size: r.get(3)?,
mtime_ms: r.get(4)?,
source_id: r.get(5)?,
thumb_state: r.get(6)?,
kind: r.get(7)?,
duration_ms: r.get(8)?,
})
},
)
});
let Ok(row) = row else { return Ok(false) };
if row.thumb_state != 0 || row.kind > 1 {
return Ok(false);
}
// HEIC/HEIF는 image 크레이트가 못 읽음 — ffmpeg(HEVC 네이티브 디코더) 경로 사용
let needs_ffmpeg = row.kind == 1 || matches!(row.ext.as_str(), "heic" | "heif");
// ffmpeg가 없으면 보류 (thumb_state 0 유지 — 사이드카 생기면 재시도)
if needs_ffmpeg && ctx.tools.is_none() {
return Ok(false);
}
let src = std::path::Path::new(&row.dir).join(&row.name);
let src_str = src.to_string_lossy().into_owned();
let key = cache_key(row.source_id, &src_str, row.size, row.mtime_ms);
let dst = thumb_path(&ctx.thumb_root, 0, &key);
let generated = if dst.exists() {
// 캐시 히트 — 크기만 읽어 등록
image::image_dimensions(&dst).ok()
} else if needs_ffmpeg {
// 영상: 길이의 10% 지점 프레임 (인트로/블랙프레임 회피), 미상이면 1초. HEIC: 0초.
let at = if row.kind == 1 {
row.duration_ms
.map(|d| (d as f64 / 1000.0) * 0.1)
.filter(|t| *t > 0.1)
.unwrap_or(1.0)
} else {
0.0
};
let tools = ctx.tools.as_ref().unwrap();
match crate::ffmpeg::extract_frame_jpeg(tools, &src, &dst, at, thumbs::GRID_LONG_EDGE) {
Ok(dims) => Some(dims),
Err(e) => {
tracing::debug!(file = %src_str, "ffmpeg 썸네일 실패: {e}");
None
}
}
} else {
match thumbs::make_image_thumb(&src, &dst, thumbs::GRID_LONG_EDGE, thumbs::JPEG_QUALITY) {
Ok((_, _, dw, dh)) => Some((dw, dh)),
Err(e) => {
tracing::debug!(file = %src_str, "썸네일 생성 실패: {e}");
None
}
}
};
match generated {
Some((dw, dh)) => {
let bytes = std::fs::metadata(&dst).map(|m| m.len() as i64).unwrap_or(0);
ctx.db.with_write(move |conn| {
let tx = conn.transaction()?;
tx.execute(
"INSERT OR REPLACE INTO thumbs(file_id, size_class, cache_key, width, height, bytes)
VALUES (?1, 0, ?2, ?3, ?4, ?5)",
rusqlite::params![id, key, dw, dh, bytes],
)?;
tx.execute("UPDATE files SET thumb_state = 1 WHERE id = ?1", [id])?;
tx.commit()
})?;
Ok(true)
}
None => {
ctx.db
.with_write(move |conn| conn.execute("UPDATE files SET thumb_state = 2 WHERE id = ?1", [id]))?;
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn generates_thumbs_for_scanned_images() {
let dbdir = tempfile::tempdir().unwrap();
let media = tempfile::tempdir().unwrap();
let thumb_root = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("t.db")).unwrap());
for i in 0..5 {
let img = image::RgbImage::from_pixel(600, 400, image::Rgb([i as u8 * 40, 10, 200]));
img.save(media.path().join(format!("img{i}.png"))).unwrap();
}
let root = media.path().to_string_lossy().into_owned();
let sid = db
.with_write({
let root = root.clone();
move |conn| {
conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','t',?1)", [&root])?;
Ok(conn.last_insert_rowid())
}
})
.unwrap();
let cancel = std::sync::atomic::AtomicBool::new(false);
crate::pipeline::scan_source(&db, sid, media.path(), &cancel, |_| {}).unwrap();
let done_count = Arc::new(AtomicUsize::new(0));
let dc = done_count.clone();
let q = ThumbQueue::start(db.clone(), thumb_root.path().to_path_buf(), None, 2, move |ids| {
dc.fetch_add(ids.len(), Ordering::SeqCst);
});
q.enqueue_pending(&db, Some(sid));
// 완료 대기 (최대 10초)
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while done_count.load(Ordering::SeqCst) < 5 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
assert_eq!(done_count.load(Ordering::SeqCst), 5);
let ready: i64 = db
.with_read(|c| c.query_row("SELECT count(*) FROM files WHERE thumb_state=1", [], |r| r.get(0)))
.unwrap();
assert_eq!(ready, 5);
// thumbs 테이블의 cache_key로 실제 파일 존재 확인
let keys: Vec<String> = db
.with_read(|c| {
let mut stmt = c.prepare("SELECT cache_key FROM thumbs")?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect()
})
.unwrap();
assert_eq!(keys.len(), 5);
for k in keys {
assert!(thumb_path(thumb_root.path(), 0, &k).exists());
}
}
}
@@ -0,0 +1,188 @@
//! 썸네일 생성 — 이미지: image 디코드 → EXIF 회전 → SIMD 리사이즈 → JPEG.
//! (영상 썸네일은 M2에서 ffmpeg 사이드카로 추가)
use fast_image_resize::images::Image as FirImage;
use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer};
use image::DynamicImage;
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum ThumbError {
#[error("I/O 오류: {0}")]
Io(#[from] std::io::Error),
#[error("이미지 디코드 오류: {0}")]
Image(#[from] image::ImageError),
#[error("리사이즈 오류: {0}")]
Resize(String),
}
pub type Result<T> = std::result::Result<T, ThumbError>;
/// 썸네일 크기 클래스 (계획서 §5)
pub const GRID_LONG_EDGE: u32 = 256;
pub const PREVIEW_LONG_EDGE: u32 = 1280;
pub const JPEG_QUALITY: u8 = 80;
/// EXIF Orientation 값(18)을 읽는다. 없으면 1(정상).
pub fn read_orientation(path: &Path) -> u32 {
let Ok(file) = std::fs::File::open(path) else {
return 1;
};
let mut reader = std::io::BufReader::new(&file);
exif::Reader::new()
.read_from_container(&mut reader)
.ok()
.and_then(|ex| {
ex.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
})
.unwrap_or(1)
}
fn apply_orientation(img: DynamicImage, orientation: u32) -> DynamicImage {
match orientation {
2 => img.fliph(),
3 => img.rotate180(),
4 => img.flipv(),
5 => img.rotate90().fliph(),
6 => img.rotate90(),
7 => img.rotate270().fliph(),
8 => img.rotate270(),
_ => img,
}
}
fn target_dims(w: u32, h: u32, long_edge: u32) -> (u32, u32) {
if w.max(h) <= long_edge {
return (w, h);
}
if w >= h {
let nh = ((h as u64 * long_edge as u64) / w as u64).max(1) as u32;
(long_edge, nh)
} else {
let nw = ((w as u64 * long_edge as u64) / h as u64).max(1) as u32;
(nw, long_edge)
}
}
/// 이미지 파일에서 JPEG 썸네일을 생성한다. 반환값은 (원본 w, 원본 h, 썸네일 w, 썸네일 h).
/// (HEIC/HEIF는 image 크레이트가 못 읽으므로 썸네일 큐가 ffmpeg 경로로 라우팅한다)
pub fn make_image_thumb(
src: &Path,
dst: &Path,
long_edge: u32,
quality: u8,
) -> Result<(u32, u32, u32, u32)> {
let decoded = image::ImageReader::open(src)?
.with_guessed_format()?
.decode()?;
let oriented = apply_orientation(decoded, read_orientation(src));
let (w, h) = (oriented.width(), oriented.height());
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
Ok((w, h, dw, dh))
}
/// 이미 디코드된 이미지에서 썸네일 생성(원격 EXIF 내장 썸네일 등). 반환: (썸네일 w, h).
pub fn make_image_thumb_from(
img: &DynamicImage,
dst: &Path,
long_edge: u32,
quality: u8,
) -> Result<(u32, u32)> {
resize_encode(img, dst, long_edge, quality)
}
/// JPEG/PNG 등 인메모리 바이트에서 썸네일 생성. `min_long_edge`보다 작으면 거부(None).
/// 원격 이미지 전체 다운로드 후, 또는 EXIF 내장 썸네일 바이트에서 사용.
pub fn make_thumb_from_bytes(
bytes: &[u8],
dst: &Path,
long_edge: u32,
quality: u8,
min_long_edge: u32,
) -> Option<(u32, u32)> {
let img = image::load_from_memory(bytes).ok()?;
if img.width().max(img.height()) < min_long_edge {
return None;
}
resize_encode(&img, dst, long_edge, quality).ok()
}
/// EXIF APP1 내 내장 썸네일(두 번째 JPEG SOI…EOI)을 스캔한다.
pub fn scan_embedded_jpeg(data: &[u8]) -> Option<&[u8]> {
let mut starts = Vec::new();
let mut i = 0;
while i + 1 < data.len() {
if data[i] == 0xFF && data[i + 1] == 0xD8 {
starts.push(i);
}
i += 1;
}
let start = *starts.get(1)?; // 첫째=본 이미지, 둘째=내장 썸네일
let mut j = start + 2;
while j + 1 < data.len() {
if data[j] == 0xFF && data[j + 1] == 0xD9 {
return Some(&data[start..j + 2]);
}
j += 1;
}
None
}
fn resize_encode(img: &DynamicImage, dst: &Path, long_edge: u32, quality: u8) -> Result<(u32, u32)> {
let (w, h) = (img.width(), img.height());
let (dw, dh) = target_dims(w, h, long_edge);
let rgb = img.to_rgb8();
let src_img = FirImage::from_vec_u8(w, h, rgb.into_raw(), PixelType::U8x3)
.map_err(|e| ThumbError::Resize(e.to_string()))?;
let mut dst_img = FirImage::new(dw, dh, PixelType::U8x3);
let mut resizer = Resizer::new();
resizer
.resize(
&src_img,
&mut dst_img,
&ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3)),
)
.map_err(|e| ThumbError::Resize(e.to_string()))?;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::io::BufWriter::new(std::fs::File::create(dst)?);
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
encoder.encode(dst_img.buffer(), dw, dh, image::ExtendedColorType::Rgb8)?;
Ok((dw, dh))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_dims_math() {
assert_eq!(target_dims(4000, 3000, 256), (256, 192));
assert_eq!(target_dims(3000, 4000, 256), (192, 256));
assert_eq!(target_dims(100, 50, 256), (100, 50)); // 업스케일 안 함
}
#[test]
fn make_thumb_from_generated_png() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.png");
let dst = dir.path().join("thumbs/aa/key.jpg");
// 1000x600 그라데이션 이미지 생성
let img = image::RgbImage::from_fn(1000, 600, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
img.save(&src).unwrap();
let (w, h, dw, dh) = make_image_thumb(&src, &dst, 256, 80).unwrap();
assert_eq!((w, h), (1000, 600));
assert_eq!((dw, dh), (256, 153));
let reopened = image::ImageReader::open(&dst).unwrap().decode().unwrap();
assert_eq!(reopened.width(), 256);
}
}
@@ -0,0 +1,68 @@
//! ffmpeg 사이드카 실기 테스트 — src-tauri/binaries에 사이드카가 있을 때만 실행된다.
//! (없으면 조용히 통과 — CI에서 fetch-ffmpeg 후 실행하면 전체 커버)
use archive_indexer::ffmpeg::{self, FfTools};
use std::path::{Path, PathBuf};
fn tools() -> Option<FfTools> {
// crates/archive-indexer → ../../binaries
let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../binaries");
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
"x86_64-pc-windows-msvc"
} else if cfg!(all(windows, target_arch = "aarch64")) {
"aarch64-pc-windows-msvc"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else {
"x86_64-apple-darwin"
};
let ext = if cfg!(windows) { ".exe" } else { "" };
let ffmpeg = bin.join(format!("ffmpeg-{triple}{ext}"));
let ffprobe = bin.join(format!("ffprobe-{triple}{ext}"));
if ffmpeg.is_file() && ffprobe.is_file() {
Some(FfTools { ffmpeg, ffprobe })
} else {
None
}
}
fn make_test_video(t: &FfTools, dir: &Path) -> PathBuf {
let out = dir.join("테스트영상.mp4");
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
let status = ffmpeg::command(&t.ffmpeg)
.args([
"-y", "-v", "error",
"-f", "lavfi", "-i", "testsrc2=duration=2:size=320x240:rate=25",
"-c:v", vcodec, "-b:v", "300k",
])
.arg(&out)
.status()
.expect("ffmpeg 실행 실패");
assert!(status.success(), "테스트 영상 생성 실패");
out
}
#[test]
fn probe_and_thumbnail_roundtrip() {
let Some(t) = tools() else {
eprintln!("사이드카 없음 — 건너뜀 (scripts/fetch-ffmpeg.ps1 실행 후 재시도)");
return;
};
let dir = tempfile::tempdir().unwrap();
let video = make_test_video(&t, dir.path());
// ffprobe 메타
let info = ffmpeg::probe(&t, &video).unwrap();
assert_eq!(info.vcodec.as_deref(), Some("h264"));
assert_eq!(info.width, Some(320));
assert_eq!(info.height, Some(240));
let dur = info.duration_ms.expect("duration 없음");
assert!((1500..=2500).contains(&dur), "duration {dur}ms");
assert!(info.fps.unwrap() > 20.0);
// 프레임 썸네일
let thumb = dir.path().join("thumbs/aa/key.jpg");
let (w, h) = ffmpeg::extract_frame_jpeg(&t, &video, &thumb, 0.5, 256).unwrap();
assert!(thumb.is_file());
assert!(w == 256 || h == 256, "긴 변이 256이어야 함: {w}x{h}");
}
@@ -0,0 +1,51 @@
//! 성능 측정 (기본 무시). 실행:
//! ARCHIVE_PERF_DIR=<50k폴더> cargo test -p archive-indexer --release --test perf_scan -- --ignored --nocapture
use archive_db::Db;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Instant;
#[test]
#[ignore]
fn scan_perf() {
let Ok(dir) = std::env::var("ARCHIVE_PERF_DIR") else {
eprintln!("ARCHIVE_PERF_DIR 미설정 — 건너뜀");
return;
};
let dbdir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open(&dbdir.path().join("perf.db")).unwrap());
let root = std::path::Path::new(&dir);
let cancel = AtomicBool::new(false);
let dir_owned = dir.clone();
db.with_write(move |c| {
c.execute("INSERT INTO sources(id,kind,name,root) VALUES(1,'local','perf',?1)", [&dir_owned])?;
Ok(())
})
.unwrap();
// 1차 스캔 (전체)
let t0 = Instant::now();
let stats = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
let scan1 = t0.elapsed();
// 메타데이터
let t1 = Instant::now();
let meta = archive_indexer::meta::extract_image_meta(&db, 1, &cancel).unwrap();
let meta_dur = t1.elapsed();
// 2차 스캔 (무변경 — 스냅샷 비교)
let t2 = Instant::now();
let stats2 = archive_indexer::pipeline::scan_source(&db, 1, root, &cancel, |_| {}).unwrap();
let scan2 = t2.elapsed();
eprintln!("=== 성능 (릴리스) ===");
eprintln!("파일 수: {}", stats.seen);
eprintln!("1차 스캔: {:.2}s ({} added)", scan1.as_secs_f64(), stats.added);
eprintln!("메타 추출: {:.2}s ({} 이미지)", meta_dur.as_secs_f64(), meta);
eprintln!("무변경 재스캔: {:.2}s (added={}, changed={})", scan2.as_secs_f64(), stats2.added, stats2.changed);
assert_eq!(stats2.added, 0);
assert_eq!(stats2.changed, 0);
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "archive-media"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
async-trait = { workspace = true }
tokio = { workspace = true }
tokio-util = { version = "0.7", features = ["io"] }
bytes = { workspace = true }
serde = { workspace = true }
mime_guess = "2"
rand = "0.9"
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tempfile = "3"
+385
View File
@@ -0,0 +1,385 @@
//! localhost 미디어 스트리밍 서버.
//!
//! Tauri asset/커스텀 프로토콜은 대용량 영상 Range/스트리밍에 결함이 있어
//! (tauri#6375, #7355, #11371) 모든 미디어 바이트는 이 서버를 통해 전달한다.
//! 소비자: 웹뷰 `<video>/<img>`, (M2+) ffmpeg 썸네일러/트랜스코더.
pub mod range;
pub mod stream;
use axum::body::Body;
use axum::extract::{Path as AxumPath, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use range::{parse_range, RangeSpec};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::sync::oneshot;
use tokio_util::io::ReaderStream;
/// 원격 파일 스트리밍 핸들 (전체 길이 + AsyncRead).
pub struct RemoteStream {
pub total_len: u64,
pub reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
pub mime: String,
}
/// file_id → 실제 경로 해석. 앱 쪽에서 DB 조회로 구현한다.
#[async_trait::async_trait]
pub trait PathResolver: Send + Sync + 'static {
fn resolve(&self, file_id: i64) -> Option<PathBuf>;
/// 썸네일 캐시 파일 경로 해석 (size_class: 0=grid, 1=preview).
fn resolve_thumb(&self, file_id: i64, size_class: u8) -> Option<PathBuf> {
let _ = (file_id, size_class);
None
}
/// 원격 파일 여부 (true면 resolve 대신 open_remote 사용).
fn is_remote(&self, file_id: i64) -> bool {
let _ = file_id;
false
}
/// 원격 파일의 지정 범위를 스트리밍한다.
async fn open_remote(&self, file_id: i64, range: Option<std::ops::Range<u64>>) -> Option<RemoteStream> {
let _ = (file_id, range);
None
}
}
pub struct MediaServer {
pub port: u16,
pub token: String,
pub streams: Arc<stream::StreamManager>,
shutdown_tx: Option<oneshot::Sender<()>>,
}
impl MediaServer {
pub fn media_url(&self, file_id: i64) -> String {
format!("http://127.0.0.1:{}/media/{}/{}", self.port, self.token, file_id)
}
pub fn stream_url(&self, job_id: u64) -> String {
format!("http://127.0.0.1:{}/stream/{}/{}", self.port, self.token, job_id)
}
}
impl Drop for MediaServer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}
struct ServerState {
token: String,
resolver: Arc<dyn PathResolver>,
streams: Arc<stream::StreamManager>,
}
/// 서버를 127.0.0.1의 임의 포트에 기동한다. `ffmpeg`가 None이면 스트림 라우트는 501.
pub async fn start(
resolver: Arc<dyn PathResolver>,
ffmpeg: Option<PathBuf>,
) -> std::io::Result<MediaServer> {
let token = generate_token();
let streams = Arc::new(stream::StreamManager::new(ffmpeg));
let state = Arc::new(ServerState {
token: token.clone(),
resolver,
streams: streams.clone(),
});
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/media/{token}/{id}", get(serve_media))
.route("/thumb/{token}/{id}", get(serve_thumb))
.route("/stream/{token}/{id}", get(serve_stream))
.with_state(state);
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?;
let port = listener.local_addr()?.port();
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let result = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await;
if let Err(e) = result {
tracing::error!("미디어 서버 종료 오류: {e}");
}
});
tracing::info!(port, "미디어 서버 시작");
Ok(MediaServer {
port,
token,
streams,
shutdown_tx: Some(shutdown_tx),
})
}
async fn serve_stream(
State(state): State<Arc<ServerState>>,
AxumPath((token, job_id)): AxumPath<(String, u64)>,
) -> Response {
if token != state.token {
return StatusCode::NOT_FOUND.into_response();
}
if !state.streams.available() {
return StatusCode::NOT_IMPLEMENTED.into_response();
}
let Some(stdout) = state.streams.spawn(job_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let body = Body::from_stream(ReaderStream::with_capacity(stdout, 256 * 1024));
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "video/mp4")
.header(header::CACHE_CONTROL, "no-store")
.body(body)
.unwrap()
}
fn generate_token() -> String {
let v: u128 = rand::random();
format!("{v:032x}")
}
async fn serve_media(
State(state): State<Arc<ServerState>>,
AxumPath((token, file_id)): AxumPath<(String, i64)>,
headers: HeaderMap,
) -> Response {
// 토큰 불일치는 404 — 존재 여부를 노출하지 않는다
if token != state.token {
return StatusCode::NOT_FOUND.into_response();
}
// 원격 파일은 VFS 스트리밍 경로로
if state.resolver.is_remote(file_id) {
return serve_remote(&state.resolver, file_id, &headers).await;
}
let resolver = state.resolver.clone();
let path = tokio::task::spawn_blocking(move || resolver.resolve(file_id))
.await
.ok()
.flatten();
let Some(path) = path else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(mut file) = tokio::fs::File::open(&path).await else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(meta) = file.metadata().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let len = meta.len();
let mime = mime_guess::from_path(&path).first_or_octet_stream();
let range_header = headers
.get(header::RANGE)
.and_then(|v| v.to_str().ok());
match parse_range(range_header, len) {
RangeSpec::Unsatisfiable => Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
.body(Body::empty())
.unwrap(),
RangeSpec::Full => {
let stream = ReaderStream::new(file);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::CONTENT_LENGTH, len)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(stream))
.unwrap()
}
RangeSpec::Partial(start, end) => {
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
let window = end - start + 1;
let stream = ReaderStream::new(file.take(window));
Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::CONTENT_LENGTH, window)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(stream))
.unwrap()
}
}
}
/// 원격 파일 스트리밍 — VFS의 open_remote로 범위를 받아 서빙한다.
async fn serve_remote(
resolver: &Arc<dyn PathResolver>,
file_id: i64,
headers: &HeaderMap,
) -> Response {
// 먼저 전체 길이를 알아야 Range 계산이 가능 — HEAD 격으로 0바이트 요청
let head = resolver.open_remote(file_id, Some(0..0)).await;
let Some(head) = head else {
return StatusCode::NOT_FOUND.into_response();
};
let len = head.total_len;
let mime = head.mime.clone();
drop(head);
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
match parse_range(range_header, len) {
RangeSpec::Unsatisfiable => Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
.body(Body::empty())
.unwrap(),
RangeSpec::Full => {
let Some(stream) = resolver.open_remote(file_id, None).await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, len)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(ReaderStream::new(stream.reader)))
.unwrap()
}
RangeSpec::Partial(start, end) => {
let Some(stream) = resolver.open_remote(file_id, Some(start..end + 1)).await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let window = end - start + 1;
Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, mime)
.header(header::CONTENT_LENGTH, window)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(ReaderStream::new(stream.reader)))
.unwrap()
}
}
}
#[derive(serde::Deserialize)]
struct ThumbQuery {
#[serde(default)]
s: u8,
}
async fn serve_thumb(
State(state): State<Arc<ServerState>>,
AxumPath((token, file_id)): AxumPath<(String, i64)>,
axum::extract::Query(q): axum::extract::Query<ThumbQuery>,
) -> Response {
if token != state.token {
return StatusCode::NOT_FOUND.into_response();
}
let resolver = state.resolver.clone();
let path = tokio::task::spawn_blocking(move || resolver.resolve_thumb(file_id, q.s))
.await
.ok()
.flatten();
let Some(path) = path else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(bytes) = tokio::fs::read(&path).await else {
return StatusCode::NOT_FOUND.into_response();
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CONTENT_LENGTH, bytes.len())
.header(header::CACHE_CONTROL, "max-age=3600")
.body(Body::from(bytes))
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
struct StaticResolver(HashMap<i64, PathBuf>);
#[async_trait::async_trait]
impl PathResolver for StaticResolver {
fn resolve(&self, file_id: i64) -> Option<PathBuf> {
self.0.get(&file_id).cloned()
}
}
#[tokio::test]
async fn serves_full_and_range_requests() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("video.mp4");
let data: Vec<u8> = (0..=255u8).cycle().take(100_000).collect();
std::fs::write(&p, &data).unwrap();
let mut map = HashMap::new();
map.insert(42i64, p);
let server = start(Arc::new(StaticResolver(map)), None).await.unwrap();
let base = server.media_url(42);
let client = reqwest::Client::new();
// 전체 응답
let res = client.get(&base).send().await.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(res.headers()["accept-ranges"], "bytes");
assert_eq!(res.headers()["content-type"], "video/mp4");
assert_eq!(res.bytes().await.unwrap().len(), 100_000);
// Range 응답 (영상 시킹 시나리오)
let res = client
.get(&base)
.header("Range", "bytes=1000-1999")
.send()
.await
.unwrap();
assert_eq!(res.status(), 206);
assert_eq!(res.headers()["content-range"], "bytes 1000-1999/100000");
let body = res.bytes().await.unwrap();
assert_eq!(body.len(), 1000);
assert_eq!(&body[..], &data[1000..2000]);
// suffix range (mp4 moov-at-end 케이스)
let res = client
.get(&base)
.header("Range", "bytes=-500")
.send()
.await
.unwrap();
assert_eq!(res.status(), 206);
assert_eq!(&res.bytes().await.unwrap()[..], &data[99_500..]);
// 416
let res = client
.get(&base)
.header("Range", "bytes=200000-")
.send()
.await
.unwrap();
assert_eq!(res.status(), 416);
assert_eq!(res.headers()["content-range"], "bytes */100000");
// 잘못된 토큰 → 404
let bad = format!("http://127.0.0.1:{}/media/wrongtoken/42", server.port);
assert_eq!(client.get(&bad).send().await.unwrap().status(), 404);
}
}
+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)
);
}
}
@@ -0,0 +1,160 @@
//! ffmpeg 리먹스/트랜스코딩 스트림 잡 관리.
//!
//! 웹뷰가 재생 못 하는 컨테이너/코덱은 ffmpeg로 fMP4를 만들어 chunked로
//! 흘려보낸다. 시킹은 잡 재생성(-ss 입력 시킹) + 프론트 seekBase 오프셋 방식.
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoMode {
/// 스트림 카피 (컨테이너만 교체)
Copy,
/// 카피 + hvc1 태그 (macOS HEVC)
CopyHvc1,
/// H.264 하드웨어 인코더로 변환
Transcode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AudioMode {
Copy,
/// AAC 변환 (AC-3/DTS/Vorbis 등)
Aac,
}
#[derive(Debug, Clone)]
pub struct StreamSpec {
pub input: PathBuf,
pub start_seconds: f64,
pub video: VideoMode,
pub audio: AudioMode,
}
pub struct StreamManager {
ffmpeg: Option<PathBuf>,
specs: Mutex<HashMap<u64, StreamSpec>>,
procs: Mutex<HashMap<u64, tokio::process::Child>>,
next_id: AtomicU64,
}
impl StreamManager {
pub fn new(ffmpeg: Option<PathBuf>) -> Self {
StreamManager {
ffmpeg,
specs: Mutex::new(HashMap::new()),
procs: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
}
}
pub fn available(&self) -> bool {
self.ffmpeg.is_some()
}
/// 잡을 등록하고 id를 돌려준다 (프로세스는 GET 시점에 기동).
pub fn create(&self, spec: StreamSpec) -> u64 {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
self.specs.lock().unwrap().insert(id, spec);
id
}
pub fn stop(&self, id: u64) {
self.specs.lock().unwrap().remove(&id);
if let Some(mut child) = self.procs.lock().unwrap().remove(&id) {
let _ = child.start_kill();
}
}
pub fn stop_all(&self) {
self.specs.lock().unwrap().clear();
let mut procs = self.procs.lock().unwrap();
for (_, mut child) in procs.drain() {
let _ = child.start_kill();
}
}
/// GET 핸들러에서 호출 — ffmpeg를 기동하고 stdout을 돌려준다.
pub(crate) fn spawn(&self, id: u64) -> Option<tokio::process::ChildStdout> {
let spec = self.specs.lock().unwrap().get(&id).cloned()?;
let ffmpeg = self.ffmpeg.clone()?;
// 같은 잡의 이전 프로세스는 종료 (시킹 재요청 등)
if let Some(mut old) = self.procs.lock().unwrap().remove(&id) {
let _ = old.start_kill();
}
let mut cmd = tokio::process::Command::new(&ffmpeg);
cmd.args(["-v", "error"]);
if spec.start_seconds > 0.01 {
cmd.args(["-ss", &format!("{:.3}", spec.start_seconds)]);
}
cmd.arg("-i").arg(&spec.input);
cmd.args(["-map", "0:v:0", "-map", "0:a:0?"]);
match spec.video {
VideoMode::Copy => {
cmd.args(["-c:v", "copy"]);
}
VideoMode::CopyHvc1 => {
cmd.args(["-c:v", "copy", "-tag:v", "hvc1"]);
}
VideoMode::Transcode => {
#[cfg(windows)]
cmd.args(["-c:v", "h264_mf", "-b:v", "6M"]);
#[cfg(target_os = "macos")]
cmd.args(["-c:v", "h264_videotoolbox", "-b:v", "6M"]);
#[cfg(not(any(windows, target_os = "macos")))]
cmd.args(["-c:v", "libx264", "-preset", "veryfast", "-crf", "23"]);
// H.264는 짝수 해상도 필수
cmd.args(["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"]);
}
}
match spec.audio {
AudioMode::Copy => {
cmd.args(["-c:a", "copy"]);
}
AudioMode::Aac => {
cmd.args(["-c:a", "aac", "-b:a", "192k", "-ac", "2"]);
}
}
cmd.args([
"-movflags",
"frag_keyframe+empty_moov+default_base_moof",
"-f",
"mp4",
"pipe:1",
]);
cmd.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());
cmd.kill_on_drop(true);
#[cfg(windows)]
{
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
tracing::error!("ffmpeg 스트림 기동 실패: {e}");
return None;
}
};
let stdout = child.stdout.take()?;
self.procs.lock().unwrap().insert(id, child);
Some(stdout)
}
}
impl Drop for StreamManager {
fn drop(&mut self) {
let mut procs = self.procs.lock().unwrap();
for (_, mut child) in procs.drain() {
let _ = child.start_kill();
}
}
}
@@ -0,0 +1,98 @@
//! ffmpeg 스트림(리먹스) 실기 테스트 — 사이드카가 있을 때만 실행.
use archive_media::stream::{AudioMode, StreamSpec, VideoMode};
use archive_media::PathResolver;
use std::path::{Path, PathBuf};
use std::sync::Arc;
fn ffmpeg_path() -> Option<PathBuf> {
let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../binaries");
let triple = if cfg!(all(windows, target_arch = "x86_64")) {
"x86_64-pc-windows-msvc"
} else if cfg!(all(windows, target_arch = "aarch64")) {
"aarch64-pc-windows-msvc"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else {
"x86_64-apple-darwin"
};
let ext = if cfg!(windows) { ".exe" } else { "" };
let p = bin.join(format!("ffmpeg-{triple}{ext}"));
p.is_file().then_some(p)
}
struct NoResolver;
#[async_trait::async_trait]
impl PathResolver for NoResolver {
fn resolve(&self, _: i64) -> Option<PathBuf> {
None
}
}
fn make_mkv(ffmpeg: &Path, dir: &Path) -> PathBuf {
let out = dir.join("입력.mkv");
let vcodec = if cfg!(windows) { "h264_mf" } else { "h264_videotoolbox" };
let status = std::process::Command::new(ffmpeg)
.args([
"-y", "-v", "error",
"-f", "lavfi", "-i", "testsrc2=duration=3:size=320x240:rate=25",
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
"-c:v", vcodec, "-b:v", "300k", "-c:a", "ac3", "-shortest",
])
.arg(&out)
.status()
.unwrap();
assert!(status.success());
out
}
#[tokio::test]
async fn remux_mkv_to_fmp4_stream() {
let Some(ffmpeg) = ffmpeg_path() else {
eprintln!("사이드카 없음 — 건너뜀");
return;
};
let dir = tempfile::tempdir().unwrap();
let mkv = make_mkv(&ffmpeg, dir.path());
let server = archive_media::start(Arc::new(NoResolver), Some(ffmpeg)).await.unwrap();
// MKV(h264 + ac3) → 영상 카피 + 오디오 AAC 변환 (모드 3)
let job = server.streams.create(StreamSpec {
input: mkv,
start_seconds: 0.0,
video: VideoMode::Copy,
audio: AudioMode::Aac,
});
let url = server.stream_url(job);
let res = reqwest::get(&url).await.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(res.headers()["content-type"], "video/mp4");
let body = res.bytes().await.unwrap();
assert!(body.len() > 10_000, "스트림이 너무 짧음: {} bytes", body.len());
// fMP4 시그니처: 4번째 바이트부터 'ftyp'
assert_eq!(&body[4..8], b"ftyp", "fMP4 헤더가 아님");
// fragmented mp4는 moof 박스를 포함
assert!(
body.windows(4).any(|w| w == b"moof"),
"fragmented MP4가 아님 (moof 없음)"
);
server.streams.stop(job);
// 시킹 시나리오: 1.5초 지점에서 재기동
let job2 = server.streams.create(StreamSpec {
input: dir.path().join("입력.mkv"),
start_seconds: 1.5,
video: VideoMode::Copy,
audio: AudioMode::Aac,
});
let res2 = reqwest::get(server.stream_url(job2)).await.unwrap();
assert_eq!(res2.status(), 200);
let body2 = res2.bytes().await.unwrap();
assert!(body2.len() > 5_000);
assert!(body2.len() < body.len(), "1.5초 이후 스트림이 전체보다 짧아야 함");
server.streams.stop(job2);
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "archive-vfs"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
bytes = { workspace = true }
bitflags = "2"
tokio-util = { version = "0.7", features = ["io"] }
# 네트워크 백엔드 — 전부 순수 Rust + rustls/ring 조합으로 C 툴체인 리스크 제거.
russh = { version = "0.62", default-features = false, features = ["ring", "flate2", "rsa"] }
russh-sftp = "2"
suppaftp = { version = "10", features = ["tokio-rustls-ring"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
reqwest_dav = "0.3"
futures-util = "0.3"
serde = { workspace = true }
serde_json = { workspace = true }
# 자격증명: OS 키체인(우선) + 포터블 볼트(ChaCha20-Poly1305 + Argon2id)
keyring = { version = "3.6", default-features = false }
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.9"
[target.'cfg(windows)'.dependencies]
keyring = { version = "3.6", default-features = false, features = ["windows-native"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6", default-features = false, features = ["apple-native"] }
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true }
@@ -0,0 +1,202 @@
//! 개발/테스트용 최소 WebDAV 서버 — 실제 디렉터리를 서빙한다.
//! 사용: cargo run -p archive-vfs --example webdav_server -- <포트> <디렉터리>
//! PROPFIND(Depth 0/1) + GET(Range) + MOVE + DELETE 지원. 인증 없음(로컬 전용).
use std::path::{Path, PathBuf};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
let port: u16 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(8080);
let root = PathBuf::from(args.get(2).cloned().unwrap_or_else(|| ".".into()));
let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap();
println!("WebDAV 서버: http://127.0.0.1:{port}/ root={}", root.display());
loop {
let (sock, _) = listener.accept().await.unwrap();
let root = root.clone();
tokio::spawn(async move {
let _ = handle(sock, root).await;
});
}
}
async fn handle(mut sock: TcpStream, root: PathBuf) -> std::io::Result<()> {
let mut buf = vec![0u8; 16384];
let n = sock.read(&mut buf).await?;
if n == 0 {
return Ok(());
}
let req = String::from_utf8_lossy(&buf[..n]);
let mut lines = req.lines();
let first = lines.next().unwrap_or("");
let mut parts = first.split_whitespace();
let method = parts.next().unwrap_or("");
let raw_path = parts.next().unwrap_or("/");
let path = urldecode(raw_path);
let depth = req
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("depth:"))
.and_then(|l| l.split(':').nth(1))
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "1".into());
let fs_path = root.join(path.trim_start_matches('/'));
match method {
"OPTIONS" => {
write_head(&mut sock, "200 OK", "text/plain", 0, None, "DAV: 1\r\nAllow: OPTIONS, GET, PROPFIND, MOVE, DELETE, MKCOL, PUT\r\n").await?;
}
"PROPFIND" => {
let xml = build_propfind(&root, &fs_path, &path, &depth);
write_head(&mut sock, "207 Multi-Status", "application/xml; charset=utf-8", xml.len(), None, "").await?;
sock.write_all(xml.as_bytes()).await?;
}
"GET" => {
if let Ok(data) = tokio::fs::read(&fs_path).await {
let range = req
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("range:"))
.and_then(|l| l.split("bytes=").nth(1))
.map(|s| s.trim().to_string());
match range {
Some(spec) => {
let (s, e) = spec.split_once('-').unwrap_or(("0", ""));
let start: usize = s.trim().parse().unwrap_or(0);
let end: usize = e.trim().parse().unwrap_or(data.len().saturating_sub(1));
let end = end.min(data.len().saturating_sub(1));
let slice = &data[start..=end];
let cr = format!("bytes {}-{}/{}", start, end, data.len());
write_head(&mut sock, "206 Partial Content", "application/octet-stream", slice.len(), Some(&cr), "").await?;
sock.write_all(slice).await?;
}
None => {
write_head(&mut sock, "200 OK", "application/octet-stream", data.len(), None, "").await?;
sock.write_all(&data).await?;
}
}
} else {
write_head(&mut sock, "404 Not Found", "text/plain", 0, None, "").await?;
}
}
"MOVE" => {
let dest = req
.lines()
.find(|l| l.to_ascii_lowercase().starts_with("destination:"))
.and_then(|l| l.splitn(2, ':').nth(1))
.map(|s| urldecode(s.trim()))
.unwrap_or_default();
// Destination은 전체 URL — 경로 부분만 추출
let dest_path = dest.split("//").nth(1).and_then(|s| s.split_once('/')).map(|(_, p)| format!("/{p}")).unwrap_or(dest);
let dfs = root.join(dest_path.trim_start_matches('/'));
let _ = tokio::fs::rename(&fs_path, &dfs).await;
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
}
"DELETE" => {
let _ = tokio::fs::remove_file(&fs_path).await;
write_head(&mut sock, "204 No Content", "text/plain", 0, None, "").await?;
}
"MKCOL" => {
let _ = tokio::fs::create_dir_all(&fs_path).await;
write_head(&mut sock, "201 Created", "text/plain", 0, None, "").await?;
}
_ => {
write_head(&mut sock, "405 Method Not Allowed", "text/plain", 0, None, "").await?;
}
}
sock.flush().await?;
Ok(())
}
async fn write_head(
sock: &mut TcpStream,
status: &str,
ctype: &str,
len: usize,
content_range: Option<&str>,
extra: &str,
) -> std::io::Result<()> {
let mut head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {len}\r\nAccept-Ranges: bytes\r\n"
);
if let Some(cr) = content_range {
head.push_str(&format!("Content-Range: {cr}\r\n"));
}
head.push_str(extra);
head.push_str("Connection: close\r\n\r\n");
sock.write_all(head.as_bytes()).await
}
fn build_propfind(root: &Path, fs_path: &Path, url_path: &str, depth: &str) -> String {
let mut responses = String::new();
let base = url_path.trim_end_matches('/');
// 요청 대상 자신
if fs_path.is_dir() {
responses.push_str(&dav_folder(&format!("{base}/")));
if depth != "0" {
if let Ok(rd) = std::fs::read_dir(fs_path) {
for entry in rd.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let child_url = format!("{base}/{}", urlencode(&name));
if entry.path().is_dir() {
responses.push_str(&dav_folder(&format!("{child_url}/")));
} else {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
responses.push_str(&dav_file(&child_url, size));
}
}
}
}
} else {
let size = fs_path.metadata().map(|m| m.len()).unwrap_or(0);
responses.push_str(&dav_file(base, size));
}
let _ = root;
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">{responses}</D:multistatus>"#
)
}
fn dav_folder(href: &str) -> String {
format!(
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
)
}
fn dav_file(href: &str, size: u64) -> String {
format!(
r#"<D:response><D:href>{href}</D:href><D:propstat><D:prop><D:resourcetype/><D:getcontentlength>{size}</D:getcontentlength><D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>"#
)
}
fn urldecode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn urlencode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
+259
View File
@@ -0,0 +1,259 @@
//! 자격증명 저장 — 기본은 OS 키체인, 포터블 모드는 암호화 볼트(opt-in).
//! SQLite에는 비밀번호를 절대 저장하지 않는다 (계획서 §9).
use std::path::PathBuf;
const SERVICE: &str = "kr.co.elio.archive";
#[derive(Debug, thiserror::Error)]
pub enum CredError {
#[error("키체인 오류: {0}")]
Keyring(String),
#[error("볼트 오류: {0}")]
Vault(String),
#[error("잠긴 볼트 — 패스프레이즈 필요")]
Locked,
}
pub type Result<T> = std::result::Result<T, CredError>;
/// 자격증명 백엔드.
pub enum CredStore {
/// OS 키체인 (Windows Credential Manager / macOS Keychain)
Keychain,
/// 포터블 암호화 볼트 (exe 옆 vault.bin)
Vault(Vault),
}
impl CredStore {
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
match self {
CredStore::Keychain => {
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
entry.set_password(secret).map_err(|e| CredError::Keyring(e.to_string()))
}
CredStore::Vault(v) => v.set(key, secret),
}
}
pub fn get(&self, key: &str) -> Result<Option<String>> {
match self {
CredStore::Keychain => {
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
match entry.get_password() {
Ok(p) => Ok(Some(p)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(CredError::Keyring(e.to_string())),
}
}
CredStore::Vault(v) => v.get(key),
}
}
pub fn delete(&self, key: &str) -> Result<()> {
match self {
CredStore::Keychain => {
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| CredError::Keyring(e.to_string()))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(CredError::Keyring(e.to_string())),
}
}
CredStore::Vault(v) => v.delete(key),
}
}
}
/// ChaCha20-Poly1305 + Argon2id 암호화 볼트 (포터블 모드).
pub struct Vault {
path: PathBuf,
key: [u8; 32],
data: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
impl Vault {
/// 패스프레이즈로 볼트를 열거나(기존) 새로 만든다.
pub fn open(path: PathBuf, passphrase: &str) -> Result<Vault> {
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
// 볼트 파일의 salt를 재사용하거나 새로 생성
let (salt_b64, ciphertext) = if path.exists() {
let bytes = std::fs::read(&path).map_err(|e| CredError::Vault(e.to_string()))?;
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
let (salt, ct) = content.split_once('\n').ok_or_else(|| CredError::Vault("형식 오류".into()))?;
(salt.to_string(), Some(ct.to_string()))
} else {
let salt = SaltString::generate(&mut OsRng);
(salt.as_str().to_string(), None)
};
let salt = SaltString::from_b64(&salt_b64).map_err(|e| CredError::Vault(e.to_string()))?;
let hash = Argon2::default()
.hash_password(passphrase.as_bytes(), &salt)
.map_err(|e| CredError::Vault(e.to_string()))?;
let hash_bytes = hash.hash.ok_or_else(|| CredError::Vault("해시 없음".into()))?;
let mut key = [0u8; 32];
key.copy_from_slice(&hash_bytes.as_bytes()[..32]);
let data = match ciphertext {
Some(ct) if !ct.is_empty() => decrypt_map(&key, &ct)?,
_ => std::collections::HashMap::new(),
};
let vault = Vault {
path,
key,
data: std::sync::Mutex::new(data),
};
// salt를 즉시 기록 (새 볼트)
vault.persist(&salt_b64)?;
Ok(vault)
}
fn persist(&self, salt_b64: &str) -> Result<()> {
let data = self.data.lock().unwrap();
let ct = encrypt_map(&self.key, &data)?;
std::fs::write(&self.path, format!("{salt_b64}\n{ct}")).map_err(|e| CredError::Vault(e.to_string()))
}
fn salt_from_file(&self) -> Result<String> {
let bytes = std::fs::read(&self.path).map_err(|e| CredError::Vault(e.to_string()))?;
let content = String::from_utf8(bytes).map_err(|e| CredError::Vault(e.to_string()))?;
Ok(content.split_once('\n').map(|(s, _)| s.to_string()).unwrap_or_default())
}
pub fn set(&self, key: &str, secret: &str) -> Result<()> {
self.data.lock().unwrap().insert(key.to_string(), secret.to_string());
let salt = self.salt_from_file()?;
self.persist(&salt)
}
pub fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.data.lock().unwrap().get(key).cloned())
}
pub fn delete(&self, key: &str) -> Result<()> {
self.data.lock().unwrap().remove(key);
let salt = self.salt_from_file()?;
self.persist(&salt)
}
}
fn encrypt_map(key: &[u8; 32], map: &std::collections::HashMap<String, String>) -> Result<String> {
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
let plaintext = serde_json::to_vec(map).map_err(|e| CredError::Vault(e.to_string()))?;
let cipher = ChaCha20Poly1305::new(key.into());
let nonce_bytes: [u8; 12] = rand::random();
let nonce = Nonce::from_slice(&nonce_bytes);
let ct = cipher.encrypt(nonce, plaintext.as_ref()).map_err(|e| CredError::Vault(e.to_string()))?;
let mut combined = nonce_bytes.to_vec();
combined.extend_from_slice(&ct);
Ok(base64_encode(&combined))
}
fn decrypt_map(key: &[u8; 32], b64: &str) -> Result<std::collections::HashMap<String, String>> {
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
let combined = base64_decode(b64).map_err(|e| CredError::Vault(e))?;
if combined.len() < 12 {
return Err(CredError::Vault("암호문 길이 오류".into()));
}
let (nonce_bytes, ct) = combined.split_at(12);
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ct)
.map_err(|_| CredError::Vault("복호화 실패 (패스프레이즈 불일치?)".into()))?;
serde_json::from_slice(&plaintext).map_err(|e| CredError::Vault(e.to_string()))
}
// 표준 base64 (URL-safe 아님) — 외부 크레이트 없이 구현
fn base64_encode(data: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
out.push(T[((n >> 18) & 63) as usize] as char);
out.push(T[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
fn val(c: u8) -> std::result::Result<u32, String> {
match c {
b'A'..=b'Z' => Ok((c - b'A') as u32),
b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
b'+' => Ok(62),
b'/' => Ok(63),
_ => Err("잘못된 base64 문자".into()),
}
}
let clean: Vec<u8> = s.bytes().filter(|c| *c != b'=' && !c.is_ascii_whitespace()).collect();
let mut out = Vec::with_capacity(clean.len() / 4 * 3);
for chunk in clean.chunks(4) {
let mut n = 0u32;
let mut bits = 0;
for &c in chunk {
n = (n << 6) | val(c)?;
bits += 6;
}
n <<= 24 - bits;
out.push((n >> 16) as u8);
if chunk.len() > 2 {
out.push((n >> 8) as u8);
}
if chunk.len() > 3 {
out.push(n as u8);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_roundtrip() {
for input in [&b""[..], b"a", b"ab", b"abc", b"abcd", b"hello world!"] {
let enc = base64_encode(input);
assert_eq!(base64_decode(&enc).unwrap(), input, "input={input:?}");
}
}
#[test]
fn vault_encrypt_persist_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.bin");
{
let v = Vault::open(path.clone(), "correct-horse").unwrap();
v.set("sftp:1:password", "s3cr3t비밀").unwrap();
v.set("ftp:2:password", "another").unwrap();
}
// 재오픈 (같은 패스프레이즈)
{
let v = Vault::open(path.clone(), "correct-horse").unwrap();
assert_eq!(v.get("sftp:1:password").unwrap().as_deref(), Some("s3cr3t비밀"));
assert_eq!(v.get("ftp:2:password").unwrap().as_deref(), Some("another"));
v.delete("ftp:2:password").unwrap();
assert_eq!(v.get("ftp:2:password").unwrap(), None);
}
// 잘못된 패스프레이즈 → 복호화 실패
{
let result = Vault::open(path.clone(), "wrong-passphrase");
assert!(result.is_err(), "잘못된 패스프레이즈는 실패해야 함");
}
}
}
+181
View File
@@ -0,0 +1,181 @@
//! FTP/FTPS 백엔드 (suppaftp, tokio + rustls).
//! FTP는 데이터 커넥션당 전송 하나라 Range는 REST 오프셋으로 구현한다.
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
use std::ops::Range;
use std::sync::Arc;
use suppaftp::tokio::AsyncFtpStream;
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct FtpConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub base_path: String,
}
pub struct FtpFs {
stream: Arc<Mutex<AsyncFtpStream>>,
base: String,
}
impl FtpFs {
pub async fn connect(cfg: &FtpConfig) -> Result<FtpFs> {
let mut stream = AsyncFtpStream::connect((cfg.host.as_str(), cfg.port))
.await
.map_err(|e| VfsError::Protocol(format!("FTP 연결: {e}")))?;
stream
.login(&cfg.username, &cfg.password)
.await
.map_err(|e| VfsError::Protocol(format!("FTP 로그인: {e}")))?;
// 바이너리 모드
let _ = stream.transfer_type(suppaftp::types::FileType::Binary).await;
Ok(FtpFs {
stream: Arc::new(Mutex::new(stream)),
base: cfg.base_path.trim_end_matches('/').to_string(),
})
}
fn full(&self, path: &str) -> String {
let p = path.trim_start_matches('/');
if self.base.is_empty() {
format!("/{p}")
} else if p.is_empty() {
self.base.clone()
} else {
format!("{}/{}", self.base, p)
}
}
}
#[async_trait::async_trait]
impl VfsProvider for FtpFs {
fn capabilities(&self) -> Caps {
// FTP는 병렬 읽기·서버측 rename 지원, 하지만 Range는 REST 재연결 기반
Caps::SERVER_MOVE
}
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
let full = self.full(path);
let mut stream = self.stream.lock().await;
let list = stream
.list(Some(&full))
.await
.map_err(|e| VfsError::Protocol(format!("LIST: {e}")))?;
let mut out = Vec::new();
for line in list {
if let Some(entry) = parse_list_line(&line) {
out.push(entry);
}
}
Ok(out)
}
async fn stat(&self, path: &str) -> Result<VEntry> {
let full = self.full(path);
let mut stream = self.stream.lock().await;
let size = stream.size(&full).await.unwrap_or(0) as u64;
let name = full.rsplit('/').next().unwrap_or("").to_string();
Ok(VEntry {
name,
is_dir: size == 0,
size,
mtime_ms: 0,
})
}
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
use tokio::io::AsyncReadExt;
let full = self.full(path);
let mut stream = self.stream.lock().await;
if let Some(r) = &range {
if r.start > 0 {
stream
.resume_transfer(r.start as usize)
.await
.map_err(|e| VfsError::Protocol(format!("REST: {e}")))?;
}
}
let mut data_stream = stream
.retr_as_stream(&full)
.await
.map_err(|e| VfsError::Protocol(format!("RETR: {e}")))?;
// Range 길이만큼(또는 전체) 메모리로 읽는다
let mut buf = Vec::new();
match &range {
Some(r) => {
let len = r.end.saturating_sub(r.start);
let mut limited = (&mut data_stream).take(len);
limited.read_to_end(&mut buf).await?;
}
None => {
data_stream.read_to_end(&mut buf).await?;
}
}
// 데이터 커넥션 정리
let _ = stream.finalize_retr_stream(data_stream).await;
Ok(Box::new(std::io::Cursor::new(buf)))
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let (f, t) = (self.full(from), self.full(to));
let mut stream = self.stream.lock().await;
stream.rename(&f, &t).await.map_err(|e| VfsError::Protocol(format!("RNFR/RNTO: {e}")))
}
async fn delete(&self, path: &str) -> Result<()> {
let full = self.full(path);
let mut stream = self.stream.lock().await;
stream.rm(&full).await.map_err(|e| VfsError::Protocol(format!("DELE: {e}")))
}
async fn mkdir(&self, path: &str) -> Result<()> {
let full = self.full(path);
let mut stream = self.stream.lock().await;
let _ = stream.mkdir(&full).await;
Ok(())
}
}
/// UNIX 스타일 LIST 라인 파싱 (권한 파싱 최소화 — 디렉터리 여부 + 이름 + 크기)
fn parse_list_line(line: &str) -> Option<VEntry> {
// 예: "drwxr-xr-x 2 user group 4096 Jan 01 12:00 폴더명"
let parts: Vec<&str> = line.splitn(9, char::is_whitespace).filter(|s| !s.is_empty()).collect();
if parts.len() < 9 {
return None;
}
let perms = parts[0];
let is_dir = perms.starts_with('d');
let size: u64 = parts[4].parse().unwrap_or(0);
let name = parts[8].to_string();
if name == "." || name == ".." {
return None;
}
Some(VEntry {
name,
is_dir,
size,
mtime_ms: 0,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_unix_list() {
let dir = parse_list_line("drwxr-xr-x 2 user group 4096 Jan 01 12:00 여행폴더").unwrap();
assert!(dir.is_dir);
assert_eq!(dir.name, "여행폴더");
let file = parse_list_line("-rw-r--r-- 1 user group 102400 Feb 15 09:30 사진.jpg").unwrap();
assert!(!file.is_dir);
assert_eq!(file.size, 102400);
assert_eq!(file.name, "사진.jpg");
assert!(parse_list_line("drwxr-xr-x 2 u g 4096 Jan 01 12:00 .").is_none());
}
}
+80
View File
@@ -0,0 +1,80 @@
//! VFS 추상화 — 로컬/원격 소스가 스캔·인덱싱·썸네일·재생 파이프라인을 공유하기
//! 위한 단일 트레이트. 모든 소비자는 `VfsProvider`만 알고, 프로토콜별 구현은
//! 백엔드 모듈에 격리된다.
pub mod creds;
pub mod ftp;
pub mod local;
pub mod sftp;
pub mod webdav;
use std::ops::Range;
use tokio::io::AsyncRead;
bitflags::bitflags! {
/// 소스별 지원 기능 — UI와 파이프라인 동작을 게이트한다.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Caps: u32 {
/// 임의 오프셋 읽기 (재생 시킹, EXIF 썸네일 부분 다운로드)
const RANGE_READ = 1 << 0;
/// 서버측 이동/이름변경
const SERVER_MOVE = 1 << 1;
/// OS 휴지통 (없으면 앱 레벨 휴지통 사용)
const NATIVE_TRASH = 1 << 2;
/// 수정시각 보존 쓰기
const MTIME_WRITE = 1 << 3;
/// 병렬 읽기에 안전
const PARALLEL_READS = 1 << 4;
/// 파일 변경 감시 신뢰 가능
const WATCHABLE = 1 << 5;
}
}
#[derive(Debug, Clone)]
pub struct VEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime_ms: i64,
}
#[derive(Debug, thiserror::Error)]
pub enum VfsError {
#[error("I/O 오류: {0}")]
Io(#[from] std::io::Error),
#[error("경로를 찾을 수 없음: {0}")]
NotFound(String),
#[error("아직 구현되지 않음: {0}")]
NotImplemented(&'static str),
#[error("프로토콜 오류: {0}")]
Protocol(String),
}
pub type Result<T> = std::result::Result<T, VfsError>;
/// 스트리밍 읽기 핸들.
pub type VfsRead = Box<dyn AsyncRead + Send + Unpin>;
#[async_trait::async_trait]
pub trait VfsProvider: Send + Sync {
fn capabilities(&self) -> Caps;
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>>;
async fn stat(&self, path: &str) -> Result<VEntry>;
/// 핵심 읽기 프리미티브. `range`가 None이면 파일 전체를 스트리밍한다.
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead>;
async fn rename(&self, from: &str, to: &str) -> Result<()>;
async fn delete(&self, path: &str) -> Result<()>;
async fn mkdir(&self, path: &str) -> Result<()>;
}
pub(crate) fn system_time_to_ms(t: std::time::SystemTime) -> i64 {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
+133
View File
@@ -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);
}
}
+179
View File
@@ -0,0 +1,179 @@
//! SFTP 백엔드 (russh + russh-sftp, 순수 Rust, ring 백엔드).
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
use russh::client;
use russh_sftp::client::SftpSession;
use std::ops::Range;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct SftpConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub base_path: String,
}
struct ClientHandler;
impl client::Handler for ClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_key: &russh::keys::ssh_key::PublicKey,
) -> std::result::Result<bool, Self::Error> {
// v1: TOFU 미구현 — 서버 키를 신뢰 (LAN/개인 NAS 가정).
// 프로덕션 강화 시 known_hosts 저장/검증 추가.
Ok(true)
}
}
pub struct SftpFs {
sftp: Arc<Mutex<SftpSession>>,
base: String,
// 세션 유지용 핸들 (drop되면 연결 종료)
_handle: Arc<client::Handle<ClientHandler>>,
}
impl SftpFs {
pub async fn connect(cfg: &SftpConfig) -> Result<SftpFs> {
let config = Arc::new(client::Config::default());
let mut handle = client::connect(config, (cfg.host.as_str(), cfg.port), ClientHandler)
.await
.map_err(|e| VfsError::Protocol(format!("SSH 연결 실패: {e}")))?;
let authed = handle
.authenticate_password(&cfg.username, &cfg.password)
.await
.map_err(|e| VfsError::Protocol(format!("SSH 인증 실패: {e}")))?;
if !authed.success() {
return Err(VfsError::Protocol("SSH 인증 거부됨".into()));
}
let channel = handle
.channel_open_session()
.await
.map_err(|e| VfsError::Protocol(format!("채널 오류: {e}")))?;
channel
.request_subsystem(true, "sftp")
.await
.map_err(|e| VfsError::Protocol(format!("SFTP 서브시스템 오류: {e}")))?;
let sftp = SftpSession::new(channel.into_stream())
.await
.map_err(|e| VfsError::Protocol(format!("SFTP 세션 오류: {e}")))?;
Ok(SftpFs {
sftp: Arc::new(Mutex::new(sftp)),
base: cfg.base_path.trim_end_matches('/').to_string(),
_handle: Arc::new(handle),
})
}
fn full(&self, path: &str) -> String {
let p = path.trim_start_matches('/');
if self.base.is_empty() {
format!("/{p}")
} else if p.is_empty() {
self.base.clone()
} else {
format!("{}/{}", self.base, p)
}
}
}
#[async_trait::async_trait]
impl VfsProvider for SftpFs {
fn capabilities(&self) -> Caps {
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
}
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
let full = self.full(path);
let sftp = self.sftp.lock().await;
let entries = sftp
.read_dir(&full)
.await
.map_err(|e| VfsError::Protocol(format!("read_dir: {e}")))?;
let mut out = Vec::new();
for e in entries {
let name = e.file_name();
if name == "." || name == ".." {
continue;
}
let meta = e.metadata();
out.push(VEntry {
name,
is_dir: meta.is_dir(),
size: meta.size.unwrap_or(0),
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
});
}
Ok(out)
}
async fn stat(&self, path: &str) -> Result<VEntry> {
let full = self.full(path);
let sftp = self.sftp.lock().await;
let meta = sftp
.metadata(&full)
.await
.map_err(|_| VfsError::NotFound(path.to_string()))?;
let name = full.rsplit('/').next().unwrap_or("").to_string();
Ok(VEntry {
name,
is_dir: meta.is_dir(),
size: meta.size.unwrap_or(0),
mtime_ms: meta.mtime.map(|t| t as i64 * 1000).unwrap_or(0),
})
}
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let full = self.full(path);
let sftp = self.sftp.lock().await;
let mut file = sftp
.open(&full)
.await
.map_err(|e| VfsError::Protocol(format!("open: {e}")))?;
match range {
None => {
// 전체를 메모리로 (SftpSession 핸들 수명 문제 회피)
let mut buf = Vec::new();
file.read_to_end(&mut buf).await?;
Ok(Box::new(std::io::Cursor::new(buf)))
}
Some(r) => {
file.seek(std::io::SeekFrom::Start(r.start)).await?;
let len = r.end.saturating_sub(r.start);
let mut buf = vec![0u8; len as usize];
let n = file.read(&mut buf).await?;
buf.truncate(n);
Ok(Box::new(std::io::Cursor::new(buf)))
}
}
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let (f, t) = (self.full(from), self.full(to));
let sftp = self.sftp.lock().await;
sftp.rename(&f, &t)
.await
.map_err(|e| VfsError::Protocol(format!("rename: {e}")))
}
async fn delete(&self, path: &str) -> Result<()> {
let full = self.full(path);
let sftp = self.sftp.lock().await;
sftp.remove_file(&full)
.await
.map_err(|e| VfsError::Protocol(format!("delete: {e}")))
}
async fn mkdir(&self, path: &str) -> Result<()> {
let full = self.full(path);
let sftp = self.sftp.lock().await;
// 존재하면 무시
let _ = sftp.create_dir(&full).await;
Ok(())
}
}
+205
View File
@@ -0,0 +1,205 @@
//! WebDAV 백엔드 (reqwest + rustls). 시놀로지 WebDAV Server 대상.
//! Range를 그대로 지원해 원격 재생 시킹에 가장 유리하다.
use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead};
use reqwest_dav::{Auth, Client, ClientBuilder, Depth};
use std::ops::Range;
#[derive(Debug, Clone)]
pub struct WebDavConfig {
pub url: String, // 예: https://nas.example.com:5006/dav
pub username: String,
pub password: String,
pub base_path: String,
}
pub struct WebDavFs {
client: Client,
base: String,
// 원격 재생용 raw HTTP (Range 헤더 직접 전달)
http: reqwest::Client,
root_url: String,
auth: (String, String),
}
impl WebDavFs {
pub async fn connect(cfg: &WebDavConfig) -> Result<WebDavFs> {
let client = ClientBuilder::new()
.set_host(cfg.url.clone())
.set_auth(Auth::Basic(cfg.username.clone(), cfg.password.clone()))
.build()
.map_err(|e| VfsError::Protocol(format!("WebDAV 클라이언트: {e}")))?;
let http = reqwest::Client::builder()
.build()
.map_err(|e| VfsError::Protocol(e.to_string()))?;
Ok(WebDavFs {
client,
base: cfg.base_path.trim_matches('/').to_string(),
http,
root_url: cfg.url.trim_end_matches('/').to_string(),
auth: (cfg.username.clone(), cfg.password.clone()),
})
}
fn rel(&self, path: &str) -> String {
let p = path.trim_start_matches('/');
if self.base.is_empty() {
format!("/{p}")
} else {
format!("/{}/{}", self.base, p)
}
}
fn abs_url(&self, path: &str) -> String {
format!("{}{}", self.root_url, self.rel(path))
}
}
#[async_trait::async_trait]
impl VfsProvider for WebDavFs {
fn capabilities(&self) -> Caps {
Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::PARALLEL_READS
}
async fn list_dir(&self, path: &str) -> Result<Vec<VEntry>> {
let rel = self.rel(path);
let items = self
.client
.list(&rel, Depth::Number(1))
.await
.map_err(|e| VfsError::Protocol(format!("PROPFIND: {e}")))?;
// 요청 디렉터리 자신의 정규화 경로 (마지막 세그먼트)
let req_seg = urldecode(rel.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
let mut out = Vec::new();
for item in items {
match item {
reqwest_dav::list_cmd::ListEntity::File(f) => {
let name = urldecode(f.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
if name.is_empty() {
continue;
}
out.push(VEntry {
name,
is_dir: false,
size: f.content_length.max(0) as u64,
mtime_ms: f.last_modified.timestamp_millis(),
});
}
reqwest_dav::list_cmd::ListEntity::Folder(d) => {
let name = urldecode(d.href.trim_end_matches('/').rsplit('/').next().unwrap_or(""));
// 자기 자신(요청 디렉터리)은 제외 — 이름이 같으면 스킵
if name.is_empty() || name == req_seg {
continue;
}
out.push(VEntry {
name,
is_dir: true,
size: 0,
mtime_ms: d.last_modified.timestamp_millis(),
});
}
}
}
Ok(out)
}
async fn stat(&self, path: &str) -> Result<VEntry> {
let rel = self.rel(path);
let items = self
.client
.list(&rel, Depth::Number(0))
.await
.map_err(|_| VfsError::NotFound(path.to_string()))?;
let name = rel.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string();
match items.into_iter().next() {
Some(reqwest_dav::list_cmd::ListEntity::File(f)) => Ok(VEntry {
name: urldecode(&name),
is_dir: false,
size: f.content_length.max(0) as u64,
mtime_ms: f.last_modified.timestamp_millis(),
}),
Some(reqwest_dav::list_cmd::ListEntity::Folder(d)) => Ok(VEntry {
name: urldecode(&name),
is_dir: true,
size: 0,
mtime_ms: d.last_modified.timestamp_millis(),
}),
None => Err(VfsError::NotFound(path.to_string())),
}
}
async fn open_range(&self, path: &str, range: Option<Range<u64>>) -> Result<VfsRead> {
use futures_util::TryStreamExt;
let url = self.abs_url(path);
let mut req = self.http.get(&url).basic_auth(&self.auth.0, Some(&self.auth.1));
if let Some(r) = &range {
req = req.header("Range", format!("bytes={}-{}", r.start, r.end.saturating_sub(1)));
}
let resp = req.send().await.map_err(|e| VfsError::Protocol(format!("GET: {e}")))?;
if !resp.status().is_success() && resp.status().as_u16() != 206 {
return Err(VfsError::Protocol(format!("HTTP {}", resp.status())));
}
let stream = resp
.bytes_stream()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
Ok(Box::new(tokio_util_compat(stream)))
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
self.client
.mv(&self.rel(from), &self.rel(to))
.await
.map_err(|e| VfsError::Protocol(format!("MOVE: {e}")))
}
async fn delete(&self, path: &str) -> Result<()> {
self.client
.delete(&self.rel(path))
.await
.map_err(|e| VfsError::Protocol(format!("DELETE: {e}")))
}
async fn mkdir(&self, path: &str) -> Result<()> {
let _ = self.client.mkcol(&self.rel(path)).await;
Ok(())
}
}
/// bytes 스트림을 AsyncRead로 변환
fn tokio_util_compat<S>(stream: S) -> impl tokio::io::AsyncRead + Send + Unpin
where
S: futures_util::Stream<Item = std::io::Result<bytes::Bytes>> + Send + Unpin + 'static,
{
tokio_util::io::StreamReader::new(stream)
}
/// 최소 URL 디코드 (%XX + 경로 세그먼트)
fn urldecode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_decode() {
assert_eq!(urldecode("%ED%95%9C%EA%B8%80"), "한글");
assert_eq!(urldecode("plain.jpg"), "plain.jpg");
assert_eq!(urldecode("a%20b.png"), "a b.png");
}
}
@@ -0,0 +1,131 @@
//! WebDAV 백엔드 통합 테스트 — 자체 완결형(in-process) 최소 WebDAV 서버 대상.
//! PROPFIND(Depth 1) + Range GET을 검증한다 (브라우즈 + 원격 재생 경로).
use archive_vfs::webdav::{WebDavConfig, WebDavFs};
use archive_vfs::VfsProvider;
use std::convert::Infallible;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
/// 아주 작은 WebDAV 서버: 고정 디렉터리 하나(/dav)에 파일 2개.
/// PROPFIND → multistatus XML, GET → Range 지원.
async fn run_server() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut sock, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let _ = handle(&mut sock).await;
});
}
});
addr
}
const FILE_A: &[u8] = b"0123456789ABCDEF"; // 16바이트
const FILE_B: &[u8] = b"hello webdav world"; // 18바이트
async fn handle(sock: &mut tokio::net::TcpStream) -> Result<(), Infallible> {
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
if n == 0 {
return Ok(());
}
let req = String::from_utf8_lossy(&buf[..n]);
let method = req.split_whitespace().next().unwrap_or("");
let path = req.split_whitespace().nth(1).unwrap_or("/");
let response: Vec<u8> = match method {
"PROPFIND" => {
let xml = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response><D:href>/dav/</D:href><D:propstat><D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
<D:response><D:href>/dav/a.jpg</D:href><D:propstat><D:prop>
<D:resourcetype/>
<D:getcontentlength>{}</D:getcontentlength>
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
<D:response><D:href>/dav/b.mp4</D:href><D:propstat><D:prop>
<D:resourcetype/>
<D:getcontentlength>{}</D:getcontentlength>
<D:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</D:getlastmodified>
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
</D:multistatus>"#,
FILE_A.len(),
FILE_B.len()
);
http_response("207 Multi-Status", "application/xml", xml.as_bytes(), None)
}
"GET" => {
let body = if path.contains("a.jpg") { FILE_A } else { FILE_B };
// Range 헤더 파싱
if let Some(range_line) = req.lines().find(|l| l.to_ascii_lowercase().starts_with("range:")) {
let spec = range_line.split("bytes=").nth(1).unwrap_or("").trim();
let (s, e) = spec.split_once('-').unwrap_or(("0", ""));
let start: usize = s.trim().parse().unwrap_or(0);
let end: usize = e.trim().parse().unwrap_or(body.len() - 1);
let slice = &body[start..=end.min(body.len() - 1)];
let cr = format!("bytes {}-{}/{}", start, end.min(body.len() - 1), body.len());
http_response("206 Partial Content", "application/octet-stream", slice, Some(&cr))
} else {
http_response("200 OK", "application/octet-stream", body, None)
}
}
_ => http_response("405 Method Not Allowed", "text/plain", b"", None),
};
let _ = sock.write_all(&response).await;
let _ = sock.flush().await;
Ok(())
}
fn http_response(status: &str, ctype: &str, body: &[u8], content_range: Option<&str>) -> Vec<u8> {
let mut head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n",
body.len()
);
if let Some(cr) = content_range {
head.push_str(&format!("Content-Range: {cr}\r\n"));
}
head.push_str("Connection: close\r\n\r\n");
let mut out = head.into_bytes();
out.extend_from_slice(body);
out
}
#[tokio::test]
async fn webdav_list_and_range_read() {
let addr = run_server().await;
let fs = WebDavFs::connect(&WebDavConfig {
url: format!("http://{addr}/dav"),
username: "u".into(),
password: "p".into(),
base_path: String::new(),
})
.await
.unwrap();
// list_dir → 파일 2개 (디렉터리 자신 제외)
let entries = fs.list_dir("").await.unwrap();
let files: Vec<_> = entries.iter().filter(|e| !e.is_dir).collect();
assert_eq!(files.len(), 2, "파일 2개여야 함: {entries:?}");
let a = files.iter().find(|e| e.name == "a.jpg").expect("a.jpg 없음");
assert_eq!(a.size, FILE_A.len() as u64);
// 전체 읽기
let mut r = fs.open_range("a.jpg", None).await.unwrap();
let mut buf = Vec::new();
r.read_to_end(&mut buf).await.unwrap();
assert_eq!(buf, FILE_A);
// Range 읽기 (재생 시킹 시나리오)
let mut r = fs.open_range("a.jpg", Some(4..10)).await.unwrap();
let mut buf = Vec::new();
r.read_to_end(&mut buf).await.unwrap();
assert_eq!(buf, &FILE_A[4..10]);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Some files were not shown because too many files have changed in this diff Show More