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>
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`).
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 %*
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)"
|
||||||
@@ -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 %*
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Archive 메인 윈도우 기본 권한",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:default",
|
||||||
|
"shell:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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 값(1–8)을 읽는다. 없으면 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);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(), "잘못된 패스프레이즈는 실패해야 함");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 954 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 847 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |