개인용 로컬 네트워크 멀티미디어 서버. 이미지 섬네일 생성, 음악/동영상 스트리밍, 파일 업로드를 제공한다. 인증 없이 사용하며 Docker로 배포한다.
Target users: 개인 (단일 사용자, 로컬 네트워크)
Deployment: Docker + named volume (미디어 파일 영속 저장)
- 파일 업로드 (multipart/form-data, 최대 파일 크기 제한 없음)
- Docker volume에 마운트된 디렉토리(
/data)에 파일 저장 - 디렉토리 트리 탐색 (폴더 구조 그대로 노출)
- 파일 삭제
- 파일/폴더 이름 변경 (파일은 확장자 고정; 이미지/동영상은 썸네일·duration 사이드카 함께 rename)
- UI에서 현재 visible 파일을 여러 개 또는 전체 선택해 사이드바 폴더/breadcrumb 경로로 일괄 이동
- 폴더 생성 (현재 탐색 경로 기준, 이름 입력 모달 — 사이드바에서 진입)
- 폴더 삭제 (재귀 삭제 — 하위 파일/폴더 +
.thumb/디렉토리 포함; 메인 리스트 + 사이드바 트리 모두에서 진입) - 폴더 이동 (사이드바 트리 노드 또는 메인 리스트 폴더 행 → 다른 트리 노드/breadcrumb DnD; 자기 자손으로 이동 거부, 충돌 시 409)
- 대상: 파일 및 폴더 (데이터 루트 디렉토리 자체는 제외)
- 파일 이름 규칙:
- 확장자는 변경 불가 — 원본 확장자 유지 (MIME/타입 일관성 보장)
- 사용자 입력에 확장자가 포함되어 있어도 서버는 base name만 사용하고 원본 확장자를 재부착
- UI 모달은 확장자를 제외한 base name만 input에 표시·편집
- Dotfile carveout: 원본이
.gitignore처럼 선행 점이 있고 다른 점이 없는 이름이면 확장자가 없는 것으로 취급 (rename 시 원하지 않는 suffix 부착 방지). 서버·JS 클라이언트 일관. - Case-only rename: 대소문자만 다른 rename(
a.txt→A.txt)은 대소문자 무시 파일시스템에서도 동작 (기존 파일 존재 검사 skip + OS가 atomic하게 처리)
- 폴더 이름 규칙: 확장자 개념 없음.
validateName과 동일 (빈 문자열/./..///\\불가, 최대 255자; 파일은base + origExt가 255자 초과 시에도 400) - scope: 동일 부모 디렉토리 내에서만 rename. 경로 이동·디렉토리 간 이동은 별도 기능 (out of scope).
- 충돌 처리: 같은 이름이 이미 존재하면
409 Conflict반환. 자동_1suffix 없음 (rename은 사용자의 명시적 의도). - 동일 이름 입력: 새 이름이 기존 이름과 동일(확장자 포함 비교)하면
400 {"error": "name unchanged"}반환. - 사이드카 파일 동기화 (이미지/동영상 파일 rename 시):
.thumb/{oldname}.jpg→.thumb/{newname}.jpg.thumb/{oldname}.jpg.dur→.thumb/{newname}.jpg.dur(동영상만)- 사이드카가 없으면 skip (오류 아님)
- 사이드카 rename 실패는 로그만 남기고 200 반환 — 썸네일은 다음 조회 시 on-demand 재생성됨 (기존 lazy 메커니즘)
- 폴더 rename: 폴더 내부의
.thumb/디렉토리는 부모 폴더 rename과 함께 자동으로 따라감 (OSrename한 번). 추가 처리 불필요. - UI 트리거: 각 entry 카드에 rename 버튼 (연필 아이콘), 기존 delete 버튼과 동일한 레이아웃에 추가
- UI 피드백: 성공 시
loadBrowse()로 현재 경로 재조회. 409/400 에러는 모달 내부 메시지로 표시하고 모달 유지.
기존 파일 이동(PATCH /api/file {"to": "..."}, §5)은 media.MoveFile이 디렉토리를 명시적으로 거부(ErrSrcIsDir)하여 폴더에 대해서는 사용할 수 없다. 본 기능은 폴더에도 동일한 PATCH 의미론을 부여한다 — PATCH /api/folder에 {"to": "..."} body를 받으면 폴더 자체를 destDir 안으로 이동.
- API 형태:
PATCH /api/folder?path=<src>body가{"name":"..."}이면 기존 rename,{"to":"..."}이면 이동.PATCH /api/file이patchFile에서 body를 한 번 읽고 분기하는 것과 동일 패턴(files.go의patchFile참고). - 이동 의미:
srcAbs(폴더)의 base name이 그대로 유지된 채destDir아래로 옮긴다. 결과 경로는destDir/<srcBaseName>. 이름 변경은 동시에 수행하지 않음(이동과 rename은 별도 호출). - 충돌 처리:
destDir/<srcBaseName>이 이미 존재(파일이든 폴더든)하면409 {"error": "already exists"}. 자동_Nsuffix 부여 없음 — 폴더는 파일과 달리 자동 suffix가 사용자 의도와 어긋나기 쉬워 명시적 거부가 안전. rename 정책과 일관. - 자기 자손 이동 방지:
destDir이srcAbs와 동일하거나srcAbs의 자손이면400 {"error": "invalid destination"}. 비교는filepath.Clean후 prefix 검사 + 경계가 path separator로 끝나는지 확인 (예:/a/b는/a/bc의 prefix가 아님). - 동일 부모 거부:
filepath.Dir(srcAbs) == destDir이면400 {"error": "same directory"}— 기존 파일 이동(files.go의moveFile)과 동일. 의미 없는 이동을 노이즈로 만들지 않음. - 루트 이동 방지:
srcAbs == h.dataDir이면400 {"error": "cannot move root"}. rename 가드와 동일. - 원자성: 단일
os.Rename호출(폴더 전체 + 내부.thumb/+ 하위 모든 파일이 함께 이동). 사이드카 별도 처리 불필요(폴더 rename과 동일 원리, §2.1.1). - Cross-volume 처리:
os.Rename이EXDEV반환 시 재귀 copy+remove 폴백 없이400 {"error": "cross_device"}반환 (단일 볼륨 가정 미충족은 운영 precondition이라 5xx가 아닌 4xx). 단일 데이터 볼륨이 전제이며(SPEC §1, Docker volume 단일 마운트), 폴더 재귀 복사는 race·디스크 공간·중간 실패 처리 비용이 크므로 의도적 out-of-scope. 파일 이동은 EXDEV 시 copy+remove 폴백을 유지(media/move.go:93) — 단일 파일 단위라 안전. - 사이드 효과: 이동된 폴더 안의 파일 경로가 모두 바뀌므로, 현재 browse 경로(
currentPath)가 이동된 폴더 자신 또는 그 자손이라면 클라이언트가 새 경로로 navigate 해야 한다 —rewritePathAfterFolderRename(폴더 rename에서 사용 중)을 재사용해srcOldPath→destDir + "/" + baseName으로 다시 계산. - 응답:
200 OK,{"path": "/movies/sub", "name": "sub"}— 새 위치의 절대 상대 경로 + base name(불변). - UI 트리거:
- 사이드바 트리 노드를 다른 사이드바 트리 노드 위로 드래그
- 사이드바 트리 노드를 breadcrumb의 다른 경로 위로 드래그
- 메인 리스트 표(
buildTable)의 폴더 행을 사이드바 트리 노드 또는 breadcrumb 위로 드래그
- DnD payload 일반화: 기존
dataTransfer의DND_MIMEpayload({src, paths})는 파일 전용이었다. 폴더는 항상 단건 이동이므로paths배열에 폴더 경로를 그대로 1개 담아 같은 채널을 재사용 — drop 핸들러는is_dir구분 없이PATCH /api/file또는PATCH /api/folder로 라우팅한다(클라이언트가 카드/노드 메타에서is_dir를 알고 있음). - 다중 선택과의 관계: 폴더는 selected set(
selectedPaths)에서 제외(bindEntrySelection이is_dir이면 체크박스 자체를 표시하지 않음 — 기존 정책 유지). 따라서 폴더 이동은 항상 단건. 멀티 폴더 이동은 out-of-scope.
기존 폴더 작업 UI가 메인 리스트 표에만 노출되어 있고(이미지/동영상 그리드에는 폴더가 분류되지 않음), 사이드바 트리에는 rename만 있어 폴더 단위 운영이 끊겨 있다. 0.0.1 릴리즈에 맞춰 진입점을 정리한다.
- 새 폴더 버튼 위치 이동: 메인 툴바(현재
#new-folder-btn이 업로드 영역 근처)에서 제거하고 사이드바 헤더 영역(트리 root 위)으로 이동. 동작은 그대로 — 클릭 시 모달, currentPath 기준 생성, 성공 시_browse(currentPath, false)+_loadTree().- 사용자 멘탈 모델: "폴더 작업은 사이드바에서" 일관 — rename·delete·move·create가 모두 트리 영역 동선에 모임.
- 모바일(<600px) 드로어에서도 동일 위치(사이드바 헤더). 드로어가 닫혀 있을 때는 자연스럽게 가려짐.
- 사이드바 트리 노드에 🗑 버튼 추가: 기존 ✎ 버튼 옆에. 클릭 시 기존
deleteFolder(path)호출 — 동작 변화 없음(confirm()다이얼로그 +DELETE /api/folder+ 트리·browse 재조회).- 루트는 삭제 불가(서버가
cannot delete root400). UI는 트리 root 자체를 노드로 렌더하지 않으므로 추가 가드 불필요.
- 루트는 삭제 불가(서버가
- 사이드바 트리 노드 DnD 활성화: 노드 row(
.tree-node-row)에draggable=true+dragstart에서DND_MIMEpayload 전송({src: node.path, paths: [node.path], is_dir: true}). 기존attachDropHandlers는 사이드바 트리 노드와 breadcrumb에 이미 부착되어 있으므로(tree.js:112), drop 처리 분기만 추가. - 메인 리스트 폴더 행 DnD 활성화:
buildTable의 폴더 행(!entry.is_dir로 막혀 있던attachDragHandlers호출,browse.js:352)에서is_dir분기를 풀어 폴더에도 dragstart를 부착. - drop 핸들러 라우팅:
moveFiles/fileOps.js의 PATCH 호출을is_dir여부로 분기 —/api/foldervs/api/file. 폴더 이동 실패 응답 코드는 파일과 공통 처리(already exists/invalid destination/same directory/cannot move root/cross_device모두 한 줄 alert).
- 지원 포맷: JPG, PNG, WEBP, GIF
- 업로드 시 섬네일 자동 생성 (200×200px, JPEG)
- 섬네일은 원본과 동일 경로에
.thumb/디렉토리에 저장 - 원본 이미지 서빙
- PNG 업로드 시 자동 JPEG 변환 — settings 토글, 흰 배경 합성, quality 90 (§2.8)
- 지원 포맷: MP4, MKV, AVI (원본 스트리밍), TS (ffmpeg 트랜스코딩)
- HTTP Range 요청 지원 (seek 가능)
- MP4/MKV/AVI: 트랜스코딩 없이 원본 파일 스트리밍
- TS: ffmpeg로 실시간 MP4 트랜스코딩 후 스트리밍 (
Content-Type: video/mp4) - MIME 타입 자동 감지
- TS 파일을 MP4로 영구 변환하여 Range/seek 지원 + 반복 트랜스코딩 비용 제거 (§2.3.3)
- 지원 포맷: MP4, MKV, AVI, TS (전체)
-
GET /api/thumb?path=에서 동영상 파일도 섬네일 반환 (기존 이미지와 동일 엔드포인트) - ffmpeg로 프레임 추출 → 200×200px JPEG (이미지 섬네일과 동일 크기)
- 섬네일은 원본과 동일 경로의
.thumb/디렉토리에 저장 (캐시) - 프레임 추출 전략 (순서대로 시도):
- 영상 길이의 50% 시점 추출
- 추출된 프레임이 모두 검정(모든 픽셀 R+G+B < 10) 또는 모두 흰색(모든 픽셀 R+G+B > 745)이면 25% 시점 재시도
- 25%도 무효이면 75% 시점 재시도
- 모두 실패하면
internal/thumb/placeholder.jpg(빌드 시 embed) 반환
- ffmpeg 실패(파일 손상, 지원 코덱 없음 등) 시 placeholder 반환 (5xx 에러 아님)
- on-demand 생성: 캐시 파일이 없을 때만 ffmpeg 실행, 이후 캐시 서빙
-
browseAPI: 동영상 파일도.thumb/{name}.jpg존재 여부로thumb_available계산
- 동영상 썸네일 카드 우하단에 재생 시간 오버레이 표시 (반투명 검정 배경 + 흰 글씨)
- 포맷 (YouTube 스타일): 1시간 미만
M:SS(예:4:32), 1시간 이상H:MM:SS(예:1:23:45)- 초는 항상 0 패딩, 분은 시간이 있을 때만 0 패딩 (
4:05,1:02:09)
- 초는 항상 0 패딩, 분은 시간이 있을 때만 0 패딩 (
- 저장 위치 (사이드카 파일):
.thumb/{name}.jpg.dur— duration(초, float)을 평문 텍스트로 저장 (예:273.456)- 썸네일 JPEG 생성과 동시에 ffprobe가 이미 구한 값을 기록 (추가 ffprobe 호출 없음)
- 기존 캐시 호환:
.thumb/{name}.jpg은 있지만.dur는 없는 경우 →browse응답 시 on-demand ffprobe 1회 실행하여.dur생성 후 캐시, 실패 시 null 반환 (썸네일은 그대로 서빙) - placeholder 사용 시: duration을 구할 수 없으면 사이드카 파일 생성하지 않음 → API 응답에서
duration_sec: null→ UI는 오버레이 숨김 - browse API 확장: 동영상 entry에
duration_sec: float | null필드 추가 (다른 타입은 항상 null) - UI 렌더링 (
buildVideoGrid):duration_sec이 null 또는 0 이하이면 오버레이 숨김- 포맷팅은 클라이언트(
web/util.js의formatDuration)에서 수행 - 폴더 삭제 시
.thumb/전체 삭제로 사이드카도 함께 정리됨 (기존 동작 그대로)
TS 파일은 현재 /api/stream 요청 시마다 ffmpeg로 실시간 리먹싱(§2.3, internal/handler/stream.go:streamTS)되며, 리먹싱된 MP4는 .cache/streams/에 캐시되지만 Range/seek 미지원이다. 이 기능은 TS 원본을 리먹싱한 foo.mp4를 같은 폴더에 영구 저장해 이후 모든 요청에서 원본 서빙(Range 포함) 경로를 타게 한다.
- 범위(scope):
/data안의 기존.ts파일 → 동일 폴더에 같은 base name의.mp4파일 생성. 다른 포맷(MKV/AVI) 변환이나 코덱 재인코딩은 out of scope. - 방식: ffmpeg 리먹싱(
-c copy)만 — TS는 보통 H.264/AAC이므로 컨테이너만 교체. 수 초 내 완료(파일 복사 수준 속도), CPU 비용 낮음. 재인코딩 폴백 없음. - API:
POST /api/convert(§5에 상세). body로 파일 경로 배열과 원본 삭제 플래그를 받고 SSE로 진행 스트림 반환 — URL import(§2.6)와 동일 이벤트 스키마(start/progress/done/error/summary). - 개별 변환 트리거: 동영상 썸네일 카드가
.ts파일이면 "MP4로 변환" 버튼(🎞 또는 텍스트) 표시. 기존 rename/delete 버튼과 동일 레이아웃. 클릭 시 확인 모달 → 변환 시작. - 일괄 변환 트리거: 현재 browse 경로에
.ts파일이 1개 이상이면 상단 툴바(§2.5.2 툴바와 공존 또는 별도 버튼)에 "모든 TS 변환 (N개)" 버튼 표시. 클릭 시 확인 모달 → 현재 filter/sort 통과한 visible entries 중.ts전부를 순차 변환. - ffmpeg 호출(기존
streamTS패턴 재사용):ffmpeg -y -loglevel error \ -i <src.ts> \ -map 0:v:0 -map 0:a:0? \ -c:v copy -c:a copy \ -bsf:a aac_adtstoasc \ -movflags +faststart \ <tmp.mp4>- 임시 파일 패턴:
.convert-*.mp4(.mp4확장자 필수 — ffmpeg가 muxer를 확장자로 선택;5c5f871커밋 참고) - 출력은 destDir에
os.CreateTemp→ ffmpeg 실행 → atomicos.Rename - stderr 버퍼링하여 실패 시 서버 로그에만 기록 (SSE 본문에는
ffmpeg_error코드만 노출, stderr 그대로 노출 안 함)
- 임시 파일 패턴:
- 파일명 결정:
foo.ts→foo.mp4(base name 유지, 확장자만.mp4교체)- 대소문자: 원본이
.TS/.Ts등이어도 출력은 소문자.mp4고정 - 충돌 처리: 목표 경로(
foo.mp4)가 이미 존재하면409 Conflict계열 에러(error: "already_exists") — 자동_1suffix 없음(rename 로직과 일관, Q3(a)). 사용자가 기존foo.mp4처리 결정해야 함.
- 대소문자: 원본이
- 원본 처리:
- 기본은 원본
.ts유지 - 요청 body의
delete_original: true면 최종 rename 성공 후 원본.ts+.thumb/foo.ts.jpg+.thumb/foo.ts.jpg.dur삭제 - UI 모달에 "변환 후 원본 TS 삭제" 체크박스(기본 unchecked)
- 원본 삭제 실패 시: 변환 자체는 성공 처리(
done이벤트) +warnings: ["delete_original_failed"]추가. 서버 로그에 사유 기록.
- 기본은 원본
- 사이드카: 새
foo.mp4의 썸네일/duration 사이드카는 생성하지 않음 — 기존 lazy 메커니즘(§2.3.1 on-demand, §2.3.2.dur생성)이 다음browse시점에 자동 생성. 단순성 우선. - 동시성: 요청 한 건 내에서 배열은 순차 처리(동시 ffmpeg 프로세스 1개) — URL import와 동일. 동일 소스에 대한 여러 요청이 겹치면
stream.go의lockStreamKey와 동일한 per-path 뮤텍스로 보호. - 취소: 요청 context 취소(클라이언트 연결 끊김 포함) 시 현재 실행 중인 ffmpeg 프로세스 kill + 임시 파일 삭제. 배열의 남은 항목은 처리하지 않음.
- 타임아웃: 파일당 10분 고정(
convertFileTimeout상수, §2.7 URL import 타임아웃과 독립 — 변환은 로컬 I/O라 네트워크 변동성과 무관). 초과 시 ffmpeg kill +error: "convert_timeout". 매우 큰 TS 파일(>2시간)도 remux는 I/O bound이므로 이 제한으로 충분. - 크기 상한: URL import의
url_import_max_bytes(§2.7)는 적용하지 않음 — 로컬 파일 remux는 디스크 공간이 허용하는 한 제한 없음. 디스크 풀 에러는error: "write_error". - Progress 이벤트:
start:total에 원본.ts파일 크기를 채움 (출력 MP4 크기는 사전 예측 불가지만 ≈ 원본 크기, 진행률 대략 계산 가능)progress: 임시.mp4파일의 현재 크기 — HLS import와 동일(500 ms polling + 1 MiB / 250 ms throttling, §5.1.1)done: 최종 MP4 파일 크기 +warnings(해당 시)
- 응답 후 UI 갱신: SSE
summary수신 후 클라이언트가loadBrowse()1회 호출 → 새.mp4+ (delete_original 시) 원본 제거가 반영됨. - Non-goals:
- 재인코딩(CRF, preset 선택 등) — remux 실패는 그대로
ffmpeg_error반환(Q4(a)) - 다른 포맷 변환(MKV→MP4, AVI→MP4 등) — 범위 외
- 원본
foo.ts를foo.mp4로 덮어쓰기 (삭제는 별도 단계) - 변환 결과 저장 위치 변경(항상 원본 폴더)
- 변환 큐 영속화(서버 재시작 시 진행 중 변환은 폐기, 재개 없음)
.cache/streams/의 기존 리먹싱 캐시 재활용(hash 기반 키라 별도 로직이 필요해 복잡도 상승; 단순하게 신규 ffmpeg 1회 실행)
- 재인코딩(CRF, preset 선택 등) — remux 실패는 그대로
- 지원 포맷: MP3, FLAC, AAC, OGG, WAV, M4A
- HTTP Range 요청 지원
- 원본 파일 스트리밍
- 파일/폴더 브라우저 (리스트 뷰)
- 이미지 갤러리 (섬네일 그리드 → 클릭 시 원본 뷰어)
- 동영상 플레이어 (HTML5
<video>태그) - 음악 플레이어 (HTML5
<audio>태그, 재생목록) - 파일 업로드 UI (드래그 앤 드롭 + 버튼)
- 반응형 레이아웃 (모바일 브라우저 지원)
- 폴더 생성 모달 (이름 입력 → 현재 경로에 생성; 진입 버튼은 사이드바 헤더에 위치 §2.1.3)
- 폴더 삭제 확인 모달 (재귀 삭제 경고 문구 포함; 메인 리스트 표 + 사이드바 트리 노드 🗑 두 곳에서 진입 §2.1.3)
- 폴더 이동 DnD (사이드바 트리 ↔ 사이드바 트리 / 메인 리스트 폴더 행 → 사이드바 트리 또는 breadcrumb §2.1.2)
- URL에서 가져오기 모달: 업로드 버튼 옆 버튼 → textarea(줄바꿈 구분 URL) → "가져오기" → 각 URL별 실시간 프로그래스 바 표시 (다운로드 중 % / 완료 / 실패 상태) → 전체 완료 시 성공·실패 카운트 요약. 모달 닫기는 뷰 숨김일 뿐, 다운로드는 현재 탭이 살아있는 동안 계속 진행(§2.6). 닫힌 동안 헤더 우측 미니 배지(
URL ↓ 완료/전체+ 실패 시⚠)로 진행 집계를 노출하고, 클릭하면 모달이 다시 열린다. 진행 중인 배치가 있는 상태에서 재오픈하면 confirm 라벨이 "새 배치 추가" 로 바뀌어 기존 row 아래에 새 배치를 append할 수 있다 — 서버는 §2.6의 배치 직렬화 규칙으로 처리한다. - 파일 용량 표시 (§2.5.1)
- 정렬·필터 툴바 (§2.5.2)
- 움짤 필터 (§2.5.3)
- TS → MP4 변환 트리거 (§2.3.3): 동영상 카드별 "MP4로 변환" 버튼 + 현재 폴더 일괄 변환 버튼 + 진행 모달(URL import 모달과 동일한 SSE 진행 바 스타일)
- 사이드바 sticky-until-bottom + 업로드 존 sticky: 사이드바는 콘텐츠 자연 높이로 자라며
syncSidebarSticky()가 stickytop을 동적으로 계산해, 트리가 길어도 페이지 스크롤만으로 마지막 노드까지 닿게 한다(내부 overflow 스크롤 없음). 업로드 존은 헤더 바로 아래에 sticky 로 고정되어 본문 스크롤 중에도 항상 보인다. 모바일(<600px) 드로어 동작은 그대로. 상세:tasks/spec-tree-full-visible.md. - 다중 파일 선택 이동: 파일 카드/테이블 행에서 체크박스로 파일을 선택하고, 툴바에서 현재 필터/검색을 통과한 visible 파일 전체를 선택/해제할 수 있다. 선택된 파일 중 하나를 사이드바 폴더 또는 breadcrumb 경로로 드래그하면 선택 묶음을 기존
PATCH /api/file {"to": ...}API로 순차 이동한다. 선택이 없거나 선택되지 않은 파일을 드래그하면 기존 단일 파일 이동 동작을 유지한다. 폴더는 선택 대상에서 제외한다. 상세:tasks/spec-multi-file-move-ui.md. - Rubber-band 영역 선택 (§2.5.4)
- 라이트박스 내 삭제 (§2.5.5): 원본 이미지·동영상 뷰어 안에서 🗑 버튼 또는
Delete키로 현재 항목 삭제 - 움짤 카드 자동재생 부담 완화 (§2.5.6): GIF/WebP 카드는 평시 정적 placeholder, hover/viewport 진입 시만 재생 + 움짤 탭 카드 크기 확대
현재 browse 경로에 직접 있는 파일들의 개수·합계를 상단에 요약하고, 개별 파일 크기를 모든 뷰에서 볼 수 있게 한다. 서버 API 변경 없음 — /api/browse 응답에 size 필드가 이미 존재하므로 클라이언트(web/main.js 진입점 + 도메인 모듈, web/style.css)만 수정한다.
- 범위(scope): 현재 browse 경로에 직접 있는 파일만. 하위 폴더 재귀 합산은 하지 않음. 폴더는 메인 리스트에 표시되지 않으므로(
renderFileList가 파일만 분류) 자연스럽게 제외됨. - 합계 표시 위치: breadcrumb 줄 오른쪽 끝에
파일 {N}개 · {formatSize(total)}형태로 렌더. 파일 0개이면 요약 영역 숨김(빈 텍스트).- 좌측: 기존 breadcrumb 경로 링크. 우측: 새
#browse-summary요소.justify-content: space-between또는margin-left: auto로 정렬.
- 좌측: 기존 breadcrumb 경로 링크. 우측: 새
- 합계 계산:
entries.filter(e => !e.is_dir).reduce((s, e) => s + (e.size || 0), 0)—is_dir=true는 제외. 음수/NaN이 들어올 일은 없으나|| 0으로 방어. - 개별 파일 용량:
- 기타/음악 표 (
buildTable): 기존크기열 유지 (변경 없음). - 이미지 그리드 (
buildImageGrid): 섬네일 좌상단 size badge (.size-badge) 표시. 좌하단은 파일명 텍스트(.thumb-name)의 시작 부분과 겹쳐 이름이 가려지므로 상단으로 배치. - 동영상 그리드 (
buildVideoGrid): 섬네일 좌상단 size badge + 기존 우하단 duration badge 병존. size badge는 duration badge와 동일한 반투명 배경·흰 글씨(시각 스타일), 위치만 다름.
- 기타/음악 표 (
- 포맷: 기존
formatSize그대로 사용 (1.5 GB,512 MB,0 B등). 새 포맷 함수 도입 금지. - 갱신 타이밍:
browse()호출 시 한 번 계산 후 렌더. 업로드·삭제·rename 후에는 기존과 동일하게loadBrowse()가 재호출되어 자동 갱신됨 (추가 작업 불필요). - Non-goals:
- 폴더 재귀 크기(디렉토리별 합산) — 범위 외.
- 사이드바 트리(
renderTreeChildren)에 크기 표시 — 범위 외. - 합계의 실시간 스트리밍 업데이트 — 기존 UI 패턴 일치(전체 재조회).
/api/browse 응답은 그대로 두고, 클라이언트에서 현재 폴더의 파일을 정렬·타입 필터·이름 검색할 수 있게 한다. 정렬·필터 상태는 URL 쿼리에 저장해 새로고침·공유·뒤로가기에서 복원된다.
- 범위(scope): 현재 browse 경로에 직접 있는 파일만. 하위 폴더 재귀 검색 없음. 폴더는 사이드바 트리가 담당하며 툴바의 영향을 받지 않음.
- 툴바 위치 및 구성:
#file-list바로 위에<div id="browse-toolbar">신설. 왼쪽→오른쪽 순서:- 타입 세그먼트 —
전체 / 이미지 / 동영상 / 음악 / 기타버튼 5개 (data-type="all|image|video|audio|other"). 단일 선택(라디오 스타일). 기본all. - 검색 입력 —
<input type="search" placeholder="이름으로 검색">. 대소문자 무시.String.prototype.trim()후 빈 문자열이 아니면 파일명에 부분문자열 매칭(name.toLowerCase().includes(q.toLowerCase())). - 정렬 select — 6개 옵션:
이름 ↑(name:asc, 기본)이름 ↓(name:desc)크기 ↑(size:asc)크기 ↓(size:desc)수정일 ↑(date:asc, 오래된 것 먼저)수정일 ↓(date:desc, 최신 먼저)
- 타입 세그먼트 —
- URL 파라미터: 기본값(
name:asc, 빈 검색,all)은 생략하여 URL을 깨끗하게 유지. 값이 있을 때만 포함:?path=/sub&sort=size:desc&q=foo&type=video- 유효하지 않은 값(화이트리스트 밖)은 기본값으로 fallback 후 URL에서 제거.
- 경로 이동:
pushState(뒤로가기 작동). - 툴바 변경:
replaceState(히스토리 스팸 방지). - popstate: URL 재파싱 후 툴바 컨트롤 값 복원 + 재렌더.
- 정렬 규칙:
name:String.prototype.localeCompare(undefined, { numeric: true, sensitivity: 'base' })— 자연스러운 한글/숫자 순. 대소문자 무시.size: 숫자 비교. 동률 시 이름 오름차순 tiebreaker.date:mod_timeISO 문자열을Date파싱 후getTime()비교. 동률 시 이름 오름차순.- 모든 정렬은 타입 섹션 내부에만 적용. 섹션 순서(이미지→동영상→음악→기타)는 유지.
- 필터 적용 순서: (1) 타입 → (2) 이름 검색 → (3) 정렬. 세 단계 모두 통과한 엔트리만 렌더.
- 섹션 구조 유지:
renderFileList의 이미지/동영상/음악/기타 분할은 그대로. 타입 필터로 가려진 섹션은 섹션 타이틀도 함께 숨김(0개 섹션 표시 금지 — 기존 규칙 동일). - 합계(§2.5.1) 연동: 합계 표시는 필터 통과한 visible entries 기준으로 재계산. "전체 X개 중 Y개 표시" 형태는 아님 — 단순히
파일 Y개 · {size}. - 라이트박스/재생목록 연동:
imageEntries,videoEntries,playlist(오디오)는 현재 visible 결과로 재설정. 필터로 가려진 항목은 lightbox prev/next, 오디오 next 대상에서도 제외. - 빈 결과 처리: 필터 결과가 0개이면 기존 "파일이 없습니다" 문구 대신 "검색 결과가 없습니다" 표시. 파일 자체가 0개인 폴더와 구분.
- 성능: 디바운스 없음 — 검색 입력마다 즉시 재렌더. 기준 규모(약 1k 엔트리)에서 재렌더 비용 무시 가능.
- 반응형: 툴바는 좁은 화면에서 2줄로 wrap 허용 (
flex-wrap: wrap). 세그먼트·검색·정렬 각각 최소 폭 유지. - Non-goals:
- 섹션별 개별 정렬·필터.
- 재귀 검색(하위 폴더까지 이름 매칭).
localStoragepersistence — URL이 단일 진실.- 확장자·날짜 범위 등 세부 필터.
- 서버 사이드 정렬/페이지네이션 — 현재 규모에서 불필요.
- 서버 변경: 없음 (
/api/browse응답 그대로).
타입 세그먼트(§2.5.2)에 6번째 항목 "움짤" 을 추가한다. 움짤은 "짧고 작은 움직이는 미디어"를 한 번에 훑기 위한 단축 필터다.
움짤 정의 (필터 통과 조건):
- GIF (
mime === 'image/gif'): 무조건 움짤. GIF는 서버가 duration을 제공하지 않고, 실무에서 대부분 짧고 가볍다는 사용자 판단에 따라 크기·길이 체크를 생략한다. - WebP (
mime === 'image/webp'): 무조건 움짤. §2.9의 변환 결과물이 모두 animated WebP이고 단일 사용자 운용에서 정적 WebP는 사실상 등장하지 않는다는 가정. 정적/애니메이션을 헤더(VP8X + Animation flag)로 분기하는 정확한 detection은 over-engineering이라 도입하지 않음 — 정적 webp가 등장해 분류가 어색해지면 후속 phase에서 보강. - 동영상 (
type === 'video'):size ≤ 50 MiB(50 × 1024² = 52,428,800 B) ANDduration_sec != null && duration_sec <= 30둘 다 만족해야 한다.duration_sec이null(썸네일 placeholder / ffprobe 실패 등)이면 움짤로 간주하지 않음 — 길이를 모르므로 보수적으로 제외. - 그 외 (정적 이미지 — JPG/PNG, 음악, 기타): 움짤 아님.
UI:
- 툴바 타입 세그먼트 맨 끝에 6번째 버튼
움짤(data-type="clip"). 기존 순서 유지:전체 / 이미지 / 동영상 / 음악 / 기타 / 움짤. - 단일 선택(라디오) 동작.
배타적 분류 (3-way): 이미지 / 동영상 / 움짤은 서로 배타적으로 분류된다. 움짤 조건에 해당하는 파일은 이미지나 동영상 탭에 나타나지 않는다:
이미지탭: 정적 이미지만 (GIF 제외)동영상탭: 움짤 아닌 동영상만 (길거나 큰 동영상 / duration 미상 동영상)움짤탭: GIF + WebP + 움짤 동영상전체탭은 이 배타 규칙을 적용하지 않음 — 모든 파일을 자연 타입 섹션에 표시 (움짤도 이미지/동영상 섹션에 포함).음악 / 기타탭은 움짤 조건과 무관 (은 해당 타입 내 움짤이 존재할 수 없음).
URL 파라미터:
-
TYPE_VALUES에clip추가. 허용값:all|image|video|audio|other|clip. 기본all은 여전히 URL에서 생략. - 움짤 선택 시 URL
?...&type=clip. 새로고침·공유에서 동일 상태 복원.
필터 적용 (applyView):
- 움짤 판별은 헬퍼로 분리:
function isClip(e) { if (e.mime === 'image/gif' || e.mime === 'image/webp') return true; if (e.type === 'video') { return e.size <= 50 * 1024 * 1024 && e.duration_sec != null && e.duration_sec <= 30; } return false; }
- 타입 분기:
view.type === 'all':out = filesview.type === 'clip':out = files.filter(isClip)- 그 외(
image|video|audio|other):out = files.filter(e => e.type === view.type && !isClip(e))
- 이후 이름 검색(q) · 정렬(sort)은 기존대로 순차 적용.
섹션 구조:
- 섹션 분할(이미지/동영상/음악/기타)은 유지. 움짤 모드에서 살아남은 GIF는 "이미지" 섹션에, 살아남은 짧은 동영상은 "동영상" 섹션에 표시된다. 음악·기타 섹션 제목은 0개가 되어 자연스럽게 숨김.
합계·라이트박스·재생목록 연동:
- §2.5.1·§2.5.2와 동일. 움짤 모드에서도 합계는 보이는 파일 기준, lightbox는 visible 이미지만 순환.
Non-goals:
- APNG / 정적 WebP 정확 분기 감지 — RIFF VP8X chunk + Animation flag 검사가 필요. 단일 사용자 운용에서 정적 WebP가 등장할 가능성이 낮아 단순화 (모든 webp를 움짤로 분류). 필요해지면 후속 phase에서 보강.
- GIF duration 서버 측 추출 — 본 기능은 서버 무변경 원칙. 필요해지면 별도 Phase.
- 움짤 전용 뷰(섹션 병합, 자동재생 미리보기 등).
- 움짤 조건 커스터마이징 (50MB / 30s 상수, 사용자 설정 없음).
서버 변경: 없음.
빈 영역에서 시작한 마우스 드래그로 사각형을 그려 그 안의 카드/행을 일괄 선택한다. Phase 22의 다중 선택 인프라(selectedPaths, tasks/spec-multi-file-move-ui.md)를 그대로 활용 — 별도 selection 상태 도입 안 함.
활성화 조건:
- mousedown이 카드/행/버튼/링크/체크박스/모달/라이트박스가 아닌 빈 영역에서 시작.
- 데스크톱 (>600px) 한정 — 모바일/터치는 기존 체크박스 UX 유지.
- 좌클릭(
button === 0)만 — 우클릭·중클릭은 무시.
상호작용:
- mousedown → 시작점 + 기존 selection 스냅샷 기록.
- mousemove 5px 초과 이동 → 반투명 overlay 생성, 시작점부터 커서까지의 사각형 그림. (5px 이하 이동은 click으로 간주해 selection 미변경.)
- 드래그 중 → 사각형과 교차(intersect)하는 visible 카드/행을
selectedPaths에 실시간 반영. - mouseup → overlay 제거, selection 확정.
- ESC → 드래그 중단 + mousedown 시점 selection 스냅샷으로 복원.
Modifier 키:
- 기본(modifier 없음) → 드래그 시작 시 selection 대체 (시작 시 클리어 후 사각형 결과 적용).
- Ctrl 또는 Shift+드래그 → 기존 selection 유지 + 사각형이 잡은 항목 추가 (additive only — 사각형이 줄어들어도 한 번 들어온 항목은 빠지지 않음).
대상:
- 이미지 그리드 / 비디오 그리드 / 테이블 행. visible 항목만 — 필터로 가려진 항목은 사각형이 덮어도 미선택.
- 폴더 카드는 selection 정책상 제외(§2.1.3) —
bindEntrySelection이 이미 차단.
시각:
- overlay는
position: absolute, 반투명 accent 색 배경 + 1px solid border. main 영역 안에서만 그려져 사이드바·헤더·툴바 위로 안 넘침. - 드래그 중 텍스트 선택 차단(
user-select: none).
Non-goals:
- 모바일/터치 long-press + drag.
- 사이드바 트리에서의 영역 선택.
- 키보드 화살표 + Shift 범위 선택.
- 드래그 중 viewport 자동 스크롤(사각형이 viewport 끝에 닿을 때 자동 따라감) — 별도 phase.
- 사각형이 줄어들 때 toggle off (additive only 정책 일관).
서버 변경: 없음.
원본 이미지·동영상을 라이트박스로 열어둔 상태에서 현재 항목을 바로 삭제할 수 있게 한다. 지금은 폴더 뷰의 썸네일 카드에서만 삭제가 가능해, 이미지를 한 장씩 넘기며 정리할 때 라이트박스를 닫고 카드로 돌아가야 하는 마찰이 있다.
- 범위(scope): 이미지 라이트박스(
openLightboxImage)와 동영상 라이트박스(openLightboxVideo) 둘 다. 음악 재생목록은 별도 UI라 대상 아님. - 트리거:
- 라이트박스 우상단에 새 🗑 버튼(
#lb-delete). 위치는 close(✕) 버튼 왼쪽(예:right: 72px) — 닫기가 가장 우측이라는 기존 패턴 유지. - 키보드
Delete키 — 라이트박스가 열려 있을 때만(!$.lightbox.classList.contains('hidden')). 기존Esc / ←/ →핸들러와 같은keydown리스너에 추가.Backspace는 바인딩하지 않음(브라우저 뒤로가기와 충돌 가능).
- 라이트박스 우상단에 새 🗑 버튼(
- 확인:
confirm('삭제하시겠습니까?\n${path}')— 기존deleteFile(web/fileOps.js:145)와 동일 문구·동일 모달 도입 안 함. 사용자가 취소하면 라이트박스 상태 그대로 유지. - 요청: 기존
DELETE /api/file?path=그대로 호출. 새 API 추가 없음. - 삭제 후 동작:
- 이미지 라이트박스:
- 성공 시
imageEntries에서 해당 항목을 제거하고, 새 길이 기준으로lbIndex보정 후 다음 이미지 표시(openLightboxImage(newIndex)). - 인덱스 보정 규칙: 삭제 후
imageEntries가 비면 라이트박스 닫기. 비지 않으면newIndex = oldIndex % imageEntries.length(끝에서 삭제했으면 자연스럽게 0으로 wrap, 중간이면 다음 항목이 같은 인덱스로 당겨와 그대로 표시). - prev/next 순환 일관성: §2.5.2 "라이트박스/재생목록 연동"에서
imageEntries는 visible 결과로 재설정한다고 명시 — 삭제 직후_browse()새로고침이 끝나면applyView가 다시imageEntries를 채우므로 라이트박스가 닫힌 뒤에는 자동 정합. 라이트박스가 열린 채로는 로컬 mutation으로 즉시 반응성 유지.
- 성공 시
- 동영상 라이트박스:
- 동영상은 prev/next가 없으므로 성공 시 라이트박스를 닫고
_browse(currentPath, false)로 폴더 새로고침.
- 동영상은 prev/next가 없으므로 성공 시 라이트박스를 닫고
- 이미지 라이트박스:
- 목록·트리 갱신: 모든 성공 케이스에서 마지막에
_browse(currentPath, false)1회 호출(이미지 라이트박스도 닫히든 안 닫히든). 트리 갱신은 폴더 변동 없으므로 호출하지 않음(기존deleteFile도 트리 미호출). - 실패 처리:
res.ok === false이면alert('삭제 실패')(기존 패턴). 라이트박스는 열린 채로 유지하고 imageEntries 손대지 않음. - 사이드카 삭제: 기존
DELETE /api/file핸들러가 이미.thumb/{name}.jpg/.dur사이드카를 함께 정리(§2.3.1·§2.3.2). 클라이언트 추가 작업 없음. - same-origin 보호: 기존
requireSameOrigin래핑된DELETE /api/file그대로 사용 — 새 변경 핸들러 추가 없음. - 상호작용 우선순위: 라이트박스 열린 동안
Delete키는 라이트박스 삭제로만 해석. 파일 카드의 다중 선택 삭제(있다면)와는 라이트박스가 닫혀 있을 때만 동작. 충돌 회피. - Non-goals:
- 다중 선택 일괄 삭제 — 이미 툴바 다른 경로(체크박스 + 삭제 버튼)가 별도로 있다면 그 영역과 분리.
- 라이트박스 안에서 rename/move 등 추가 mutation — 이번 범위 밖.
- 휴지통(trash) / undo — 기존 삭제와 동일하게 즉시 원본 삭제.
- 음악 재생목록의 트랙 삭제 — 별도 phase.
- 모바일 long-press 삭제 트리거 — 데스크톱 키보드/버튼에 한정.
- 서버 변경: 없음.
움짤(GIF / WebP)은 카드 thumb 사이드카가 없으면 <img src="/api/stream?..."> 로 원본 자체를 그려 자동재생된다. 폴더에 움짤이 100+ 있으면 visible 여부와 무관하게 모두 디코드되어 저사양 PC에서 부담이 크다. 이 기능은 클라이언트만 변경해 동시 재생되는 카드 수를 줄인다 — 서버 변경 없음. 평시에는 정적 첫 프레임 jpg(/api/thumb) 를 보여주고, hover/viewport 진입 시에만 원본 stream 으로 src 를 토글한다.
기반 가정 (서버 동작):
thumb.Generate는 GIF 첫 프레임(decodeGIFFirstFrame) / 정적 WebP(imaging.Open) / animated WebP (webpmux -get frame 1→dwebp→ imaging) 모두 처리해 정적 jpg 사이드카(.thumb/{name}.jpg) 를 만든다. animated WebP 폴백은imaging의golang.org/x/image/webp디코더가 정적 frame 만 지원하는 한계 때문에 필요. Dockerfile 에libwebp-tools패키지 추가(webpmux + dwebp).serveImageThumb는 사이드카가 없으면 즉시 lazy 생성. 즉 클라이언트가/api/thumb?path=...를 요청하면 GIF/WebP 라도 항상 정적 jpg 가 돌아온다. 첫 호출은 디코드 비용이 있고 두 번째부터는 OS file cache 에서 즉시.- browse 응답의
thumb_available은 사이드카 디스크 존재 여부 — 첫 browse 시점에는 GIF/WebP 의 경우 false 일 수 있지만, 클라이언트는 그 값을 무시하고 항상/api/thumbURL 을 사용하는 정책으로 가도 안전(lazy 생성이 backstop).
대상:
mime === 'image/gif'또는mime === 'image/webp'카드만. 짧은 동영상 카드는 이미 정적 jpg 썸네일이라 부담 원인 아님.image-grid안의 카드(buildImageGrid로 렌더되는 항목). 라이트박스/오디오 등 다른 경로는 무관.
데스크톱 동작 (hover 가능):
- 평시 정적: GIF/WebP 카드
<img>의src는/api/thumb?path=...(정적 첫 프레임 jpg).data-stream-src에/api/stream?path=...원본 URL 을 둔다. -
mouseenter시 재생:img.src = img.dataset.streamSrc로 교체 → 브라우저가 GIF/animated WebP 디코드 시작 → 자동재생. -
mouseleave시 정지:img.src = img.dataset.thumbSrc(= 정적 jpg URL) → 디코더 해제, 첫 프레임 정지. - hover 가 빠르게 들락날락할 때마다 src 가 토글되는 비용은 작다 — jpg 와 GIF/webp 모두 OS/브라우저 캐시에서 즉시. 별도 디바운스 없음.
모바일 동작 (hover 불가, < 600px 또는 (hover: none)):
- IntersectionObserver 한정 재생: 카드 viewport 진입 시
img.src = streamSrc로 활성화, 이탈 시img.src = thumbSrc로 정적 복귀.rootMargin: 0px,threshold: 0.1(10% 이상 보이면 활성). - 데스크톱은
(hover: hover)media query 가 true 이면 hover 정책 우선, IntersectionObserver 미사용. - 모바일도 카드 tap 으로 라이트박스 진입하는 기존 동작 유지 — 본 기능과 무관.
카드 크기 변경 (움짤 탭 한정):
-
view.type === 'clip'일 때image-grid의 grid template 을 더 넓게 —minmax(240px, 1fr). 다른 탭(전체/이미지/동영상)은 기존minmax(160px, 1fr)유지. - 1920px 데스크톱 기준 한 행 카드 수가 ~10 → ~6 로 줄어 시야에 보이는 동시 재생 후보가 자연스레 감소. 데스크톱은 hover 정책이 주된 부담 감소책이고 이 변경은 보조.
- 모바일(<600px)은 grid 가 이미 1~2 열이라 추가 변경 없음.
구현 위치:
web/browse.jsbuildImageGrid— GIF/WebP 카드 마크업에src=thumbURL(정적 첫 프레임) +data-stream-src=streamURL+data-thumb-src=thumbURL.attachClipHoverPlayback(card)헬퍼 호출.web/browse.js신규 헬퍼attachClipHoverPlayback(card):matchMedia('(hover: hover)').matches분기 — 데스크톱이면 hover handler 만, 모바일이면 IntersectionObserver 만.- 카드 DOM 제거(re-render) 시 cleanup 자동(리스너는 element 와 함께 GC, observer 는 element 가 사라지면 자동 정리되지만 명시적으로
unobserve도 호출).
web/browse.jsrenderFileList— 움짤 탭(view.type === 'clip') 활성 시image-grid에image-grid-clip클래스 추가.web/style.css:.image-grid.image-grid-clip { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }. 모바일 분기는 기존 미디어 쿼리가 처리.- 별도 placeholder 스타일 불필요 —
<img>가 항상 정적 jpg 든 stream 이든 src 가 채워져 있어 빈 카드 없음.
Non-goals:
- 서버측 동기 첫 프레임 추출 (browse 응답에서 즉시 사이드카 생성) — lazy 생성으로 충분. 처음 browse 시 첫 프레임이 없는 카드는
/api/thumb첫 호출에서 생성되고 사용자에게는 약간의 latency 만 보인다. prefers-reduced-motion사용자 옵션 — 본 기능이 이미 hover 시만 재생이라 별도 toggle 불필요. 필요해지면 settings 추가.- 모바일에서도 hover 같은 동작(long-press 시 재생) — IntersectionObserver 단순 정책 유지.
- viewport 안 카드 수 cap (예: "최대 6개만 동시 재생") — IntersectionObserver 정책으로는 보이는 모든 카드가 활성. 더 엄격한 cap 이 필요하면 후속 검토.
- 라이트박스/오디오 플레이어 — 본 기능 무관.
- 이전 phase 결정(움짤 필터 §2.5.3, 변환 §2.9) 변경 — 분류·변환 정책 그대로.
- thumb 사이드카 사전 일괄 생성 (예: 폴더 내 모든 움짤 미리 워밍) — lazy 메커니즘에 위임. 첫 진입 시 latency 가 체감되면 후속 검토.
서버 변경: 최소 — thumb.Generate 에 animated WebP 폴백(webpmux + dwebp) + Dockerfile libwebp-tools 패키지 추가. handler/browse 등 그 외 경로 무변경.
미디어 URL을 입력하면 서버가 다운로드와 동시에 디스크에 스트리밍 저장한다. 이미지·동영상·음악 모두 지원.
- 여러 URL 한 번에 입력 가능 (줄바꿈 구분, 빈 줄/공백 무시)
- 실시간 progress 스트리밍: 서버가
text/event-stream(SSE) 응답으로 URL별 진행 이벤트를 순차 전송 → 클라이언트가 URL별 프로그래스 바 업데이트 - 순차 batch 처리: 서버는 URL을 순서대로 하나씩 다운로드 (동시성 1), 각 단계를 SSE 이벤트로 내보냄
- 부분 실패 허용: 성공한 URL만 저장, 실패 URL은 해당
error이벤트로 사유 전달 (전체 롤백 X) - 저장 위치: 클라이언트가 보낸
path쿼리 파라미터 경로(현재 browse 경로) - 지원 Content-Type (응답 헤더 기준):
- Image:
image/jpeg,image/png,image/webp,image/gif - Video:
video/mp4,video/x-matroska,video/x-msvideo,video/mp2t - Audio:
audio/mpeg,audio/flac,audio/aac,audio/ogg,audio/wav,audio/mp4 - HLS 플레이리스트 (ffmpeg 리먹싱 후
.mp4로 저장):application/vnd.apple.mpegurl,application/x-mpegurl, 레거시audio/mpegurl,audio/x-mpegurl— 상세는 §2.6.1
- Image:
- 파일명 결정:
- URL 마지막 경로 세그먼트 추출 (예:
https://x.com/a/foo.mp4→foo.mp4) - URL에 확장자 없거나 비표준이면 응답
Content-Type헤더에서 결정 (image/jpeg→.jpg,video/mp4→.mp4,audio/mpeg→.mp3,video/x-matroska→.mkv,audio/mp4→.m4a, …) - URL 확장자와
Content-Type이 충돌하면Content-Type우선 (확장자를 응답 기준으로 교체) - 파일명 sanitize:
/,\,.., 컨트롤 문자 제거. 빈 이름 → 타입별 기본값 (image/video/audio) + 확장자 - 충돌 시 자동 리네임:
foo.mp4존재 →foo_1.mp4,foo_2.mp4... (기존createUniqueFile패턴 재사용). 충돌 시warnings에"renamed"추가
- URL 마지막 경로 세그먼트 추출 (예:
- 다운로드 흐름:
- URL 스킴 검증:
http/https만 허용 (file:,data:,javascript:등 거부) - HTTP는 허용하되
warnings에"insecure_http"추가 (다운로드는 진행) - HTTPS는 표준 TLS 인증서 검증 (자체서명 거부)
- 리다이렉트 최대 5회 추적 (스킴은 매 hop마다 재검증)
- DNS 해석 결과가 loopback/private/link-local/multicast/unspecified IP이면 거부 (
error: "private_network"). 최초 요청과 redirect 이후 실제 dial 대상 모두에 적용된다. HLS의 master/variant playlist 본문, segment, key, init segment fetch는 모두 같은 보호 클라이언트를 통과하므로 DNS rebinding 우회가 닫혀 있다 — 자세한 흐름은 §2.6.1. - 요청 시
Authorization등 인증 헤더 자동 첨부 안 함 - 응답 헤더 검증:
Content-Type이 위 허용 목록에 없으면 거부 (error: "unsupported_content_type")- HLS 분기 (§2.6.1): Content-Type이 HLS 플레이리스트이거나, URL 경로가
.m3u8로 끝나고 Content-Type이text/plain/application/octet-stream/ 파싱 불가 → HLS 흐름으로 이탈하고 이하 §2.6 검증은 건너뜀 Content-Length헤더가 있고 설정값url_import_max_bytes(§2.7) 초과면 다운로드 시작 전 거부 (error: "too_large")Content-Length헤더 없어도 다운로드 진행 (아래 누적 카운터로 런타임 보호)
- 임시 파일에 스트리밍 저장
- 다운로드 중 누적 바이트가
url_import_max_bytes초과 시 즉시 중단 + 임시 파일 삭제 (error: "too_large") - 검증 통과 시 임시 파일 → 최종 경로로 atomic rename
- 이미지·동영상 성공 시
.thumb/{name}.jpg섬네일 비동기 생성 (음악은 생략)
- URL 스킴 검증:
- 진행 이벤트 (SSE): URL당 최소
start→done또는start→error. 큰 파일은 중간에progress이벤트를 주기적으로 방출 (§5.1.1). 배치 단위로는 응답 헤더 직후register이벤트 1회(jobId 부여) →queued이벤트 1회 → URL 단위 이벤트들 →summary이벤트로 종료한다 (§5.1). - 타임아웃: 연결 10초 + 전체 다운로드는 설정값
url_import_timeout_seconds(§2.7, 기본 30분, 개별 URL 단위). 초과 시error: "download_timeout" - 배치 직렬화: 서버는
Handler.importSem(size-1 채널 세마포어)로POST /api/import-url을 프로세스 전역에서 한 번에 한 배치씩 순차 처리한다. 동시에 여러 클라이언트(또는 같은 사용자가 모달 재오픈으로 추가한 두 번째 배치)가 POST를 보내면, 응답 헤더는 즉시 가고queued이벤트가 곧바로 송출되지만, 후속start이벤트는 앞선 배치가 끝날 때까지 대기한다. 세마포어 wait는 잡 컨텍스트(Job.Ctx())와 함께 select 되므로, 잡이 명시적 취소되면 미획득 상태로 즉시 종료된다. 클라이언트 disconnect는 잡을 취소하지 않는다 — 아래 백그라운드 진행. - 잡 레지스트리 (인메모리): 모든 import 배치는 서버
internal/importjob.Registry에 등록되어 request lifecycle과 분리되어 산다. POST 응답 첫register프레임으로 받는jobId(imp_[a-z2-7]{8})는 새로고침/탭 재오픈 후GET /api/import-url/jobs/{id}/events(§5.1)로 다시 구독할 수 있는 영구 식별자. - 백그라운드 진행: 클라이언트 fetch가 끊겨도(모달 close, 새로고침, 탭 닫음) 서버 잡은 끝까지 실행된다. 클라이언트(§2.5)는 페이지 로드 시
GET /api/import-url/jobs로 활성/이력 잡 목록을 받아 row와 헤더 배지를 복원하고, 활성 잡에 대해EventSource로 라이브 진행 stream에 재합류한다. 같은 사용자가 탭 두 개를 열면 같은 잡의 진행을 양쪽이 fan-out으로 본다(subscriber당 64-event 버퍼, drop on full — lifecycle 이벤트 누락 시 SetStatus(terminal)이 채널을 close하므로 핸들러가 hang하지 않는다). - 취소:
POST /api/import-url/jobs/{id}/cancel(배치 전체) 또는POST .../cancel?index=N(개별 URL). 진행 중 URL이면 per-URL ctx cancel → urlfetch 종료 →error: "cancelled"이벤트. 대기 중 URL이면 즉시cancelled로 마킹 + 이벤트 emit, 워커는 도달 시점에 skip. 잡 status 결정 규칙: succeeded≥1 →completed, 그 외 cancelled가 있으면cancelled, 아니면failed. - 이력 dismiss: 종료된 잡은
DELETE /api/import-url/jobs/{id}로 history에서 제거. 활성 잡은 409(먼저 cancel 필요). 종료된 잡 일괄 정리는DELETE /api/import-url/jobs?status=finished. UI는 모달 footer "완료 항목 모두 지우기" 버튼. - 활성 잡 cap: 동시에 active(
queued+running) 잡은MaxQueuedJobs=100개로 제한. 초과 시 POST →429 too_many_jobs. 단일 사용자 + 직렬 처리라 사실상 도달 불가능한 안전장치. - 서버 재시작 시 휘발: 잡 레지스트리는 인메모리. SIGINT/SIGTERM은
signal.NotifyContext→Registry.CancelAll()→Registry.WaitAll(5s)(병렬 fan-in) → 진행 중 urlfetch/ffmpeg 정리 →httpServer.Shutdown(10s). 재시작 후 새로고침하면 잡이 0건이고 임시 파일은 정리되어 있다. 디스크 영속 잡 큐는 의도적 비목표 (단일 사용자 LAN, 비용 대비 가치 낮음). - 워커 panic 보호:
runImportJob은defer recover()로 panic 시summary{Failed: len(URLs)}+SetStatus(StatusFailed)로 잡을 종료 상태에 안착시킨다. 그렇지 않으면 슬롯이 영구 점유되고 graceful shutdown이 5초 대기를 모두 소비. - 로그 redact:
urlfetch실패 로그(logFetchError)는 URL의 userinfo(user:pass@host)와 sensitive query 키(token,signature,key,apikey,password,secret+ AWS 시그니처 키)를 자동 마스킹한다.*url.Error도 동일하게 처리. - URL 길이 cap:
normalizeURLs에서 2 KB 초과 URL은 무시. JobSnapshot에 임의 길이 텍스트가 영구 적재되어GET /jobs로 노출되는 것을 차단. - 설정 스냅샷 시점:
url_import_max_bytes/url_import_timeout_seconds(§2.7)는 POST 도착 시점(세마포어 acquire 이전)에 스냅샷을 찍는다. 큐잉 중인 배치는 자기가 받은 시점의 값을 그대로 쓰며, 진행 중에 PATCH /api/settings로 값이 바뀌어도 영향받지 않는다. - SSRF 정책: 약하게 — 사설 IP(127.0.0.1, 10.x, 172.16-31.x, 192.168.x, 169.254.x, ::1, fc00::/7, fe80::/10) 차단 안 함 (LAN 미디어 서버 자기 호출 등 정상 케이스 허용)
- 인증/쿠키: 자동 첨부 절대 안 함 (인증 필요한 URL은 실패 처리)
HLS(.m3u8) 플레이리스트는 여러 개의 .ts/.m4s 세그먼트를 참조하는 색인 파일이라, 개별 세그먼트에는 Content-Length가 있어도 스트림 전체 크기를 미리 알 수 없다. 일반 다운로드 경로(Content-Length 사전 검증) 대신 ffmpeg 리먹싱 경로를 거쳐 단일 MP4 파일로 저장한다.
- 감지 조건 (둘 중 하나 만족 시 HLS 분기):
- 응답
Content-Type(media type만, 파라미터 무시, 대소문자 무시)이application/vnd.apple.mpegurl,application/x-mpegurl,audio/mpegurl, 또는audio/x-mpegurl - URL 경로가
.m3u8(대소문자 무시)로 끝나고Content-Type이text/plain,application/octet-stream, 빈 값, 또는 파싱 실패 (CDN 오인식 폴백)
- 응답
- 마스터 플레이리스트 처리:
- 초기 HTTP 응답 본문을 최대 1 MiB까지 읽어 플레이리스트 파싱 (초과 시
error: "hls_playlist_too_large") #EXT-X-STREAM-INF:가 하나 이상 있으면 master playlist로 간주- 각 variant의
BANDWIDTH속성 비교 → 최고값 variant 선택 (동률은 먼저 선언된 것) BANDWIDTH누락 variant는 0으로 간주 (후순위)- variant URL이 상대 경로면 master URL 기준 resolve
#EXT-X-STREAM-INF가 없으면 이미 media playlist — 원본 URL을 그대로 사용
- 초기 HTTP 응답 본문을 최대 1 MiB까지 읽어 플레이리스트 파싱 (초과 시
- 다운로드 흐름 — ffmpeg는 항상 검증된 local 파일만 입력으로 받는다. 원격 fetch는 모두 Go 보호 클라이언트가 수행하여 DNS rebinding 우회를 차단한다:
- HLS 감지 → 초기 응답 본문을 메모리로 읽고 즉시 연결 close (master 본문 cap 1 MiB)
- master playlist 파싱 →
#EXT-X-STREAM-INF이 있으면 BANDWIDTH 최고 variant 선택. 없으면 본문 자체가 media playlist - variant URL이 master와 다르면 보호 클라이언트(
publicOnlyDialContext)로 variant playlist 본문을 새로 fetch (cap 1 MiB) - media playlist 파싱 → segment(
#EXTINF), key(#EXT-X-KEY:URI=), init(#EXT-X-MAP:URI=) URI 추출, base 기준 resolve, 스킴http/https검증, segment 개수 cap 10,000(hls_too_many_segments) - 임시 디렉터리
<destDir>/.urlimport-hls-<random>/생성 (browse dot-prefix 필터로 자동 숨김) - 보호 클라이언트로 모든 segment / key / init을 임시 디렉터리에 사전 다운로드:
- segment →
seg_NNNN.<ext>(whitelist.ts/.m4s/.mp4/.aac/.m4a/.vtt, 외에는.bin) - key →
key_N.bin(per-resource cap 64 KiB) - init →
init_N.<ext>(per-resource cap 16 MiB) - 누적 바이트가
url_import_max_bytes(§2.7) 초과 시 즉시 중단 →error: "too_large" - 한 리소스라도 실패(http 4xx/5xx, TLS, dial, private IP 등) → 즉시 중단, 임시 디렉터리 cleanup
- segment →
- URI를 local 상대 경로로 재작성한
playlist.m3u8을 임시 디렉터리에 작성 (다른 라인 —#EXTM3U,#EXTINF,#EXT-X-VERSION,#EXT-X-BYTERANGE,#EXT-X-ENDLIST등 — 은 verbatim 유지) - ffmpeg 프로세스 spawn:
ffmpeg -hide_banner -loglevel error -protocol_whitelist "file,crypto" -allowed_extensions ALL -i <localPlaylistPath> -c copy -bsf:a aac_adtstoasc -f mp4 -movflags +faststart -y <outputPath>outputPath는 임시 디렉터리 안output.mp4- stderr는 버퍼링하여 실패 시 로그로 남김 (응답 본문으로는 노출 안 함)
- argv invariant —
-i인자에는 절대 local 경로만 들어가고http:///https:///tcp등 네트워크 protocol 토큰은 절대 등장하지 않는다 (단위 테스트로 잠금)
- 별도 goroutine에서 500 ms 주기로 출력 MP4
os.Stat→ 현재 크기 →progress이벤트 (1 MiB / 250 ms throttling) - 출력 크기가 누적 cap의 잔여를 초과 시 ctx cancel로 ffmpeg 종료 →
error: "too_large" url_import_timeout_seconds(§2.7) 또는 요청 context 취소 시 ctx 전파로 ffmpeg 종료 →error: "download_timeout"(또는 context에 따라network_error)- ffmpeg exit code 0 → 출력 MP4를 destDir로 atomic rename (
renameUnique) - ffmpeg exit code non-zero →
error: "ffmpeg_error" - 모든 경로(성공/실패/취소/패닉)에서 임시 디렉터리는
defer os.RemoveAll로 정리
- 파일명 결정:
- URL 마지막 경로 세그먼트가
foo.m3u8→ base namefoo+ 강제 확장자.mp4 - base name 추출 불가(빈 이름,
.,..) → 기본값video.mp4 - 확장자 교체가 발생하므로 항상
warnings: ["extension_replaced"]추가 - 충돌 시 기존
_1,_2자동 리네임 로직 그대로 적용
- URL 마지막 경로 세그먼트가
- 타입 및 후속 처리:
type: "video"(항상)- 성공 시 일반 동영상과 동일:
.thumb/{name}.jpg썸네일 풀 제출 + duration 사이드카 생성
- progress 이벤트 차이점:
start이벤트:total필드 생략 (미상) — 기존 "알 수 없으면 생략" 규칙 준수progress.received는 두 단계의 단조 증가 카운터:- Phase 1 (segment/key/init 사전 다운로드): 누적 다운로드 바이트
- Phase 2 (ffmpeg 출력): Phase 1 총량 + 출력 MP4 size
- 클라이언트 입장에선 단일 monotonic 값. 의미 변경 없이 값만 단조 증가
done.size: 최종 MP4 파일 크기 (atomic rename 직후Stat)
- 보안:
- ffmpeg
-protocol_whitelist "file,crypto"로 제한 — 네트워크 protocol(http/https/tcp/tls/udp/rtp 등) 모두 차단. 입력 playlist + 모든 segment/key/init은 Go가 미리 받아 임시 디렉터리에 둔 local 파일이므로 ffmpeg가 자체 DNS 해석을 수행할 여지가 없다 — 이것이 DNS rebinding 차단의 핵심 invariant - master/variant/segment/key/init URI의 스킴(
http/https)은 Go 파서가 검증, 모든 fetch는publicOnlyDialContext(IP-pin) 통과 — 사설 IP는 dial 시점에 거부 -allowed_extensions ALL은 local 파일 입력에만 영향, 네트워크 fetch가 없으므로 LFI/포트스캔 위험 없음- ffmpeg 호출 시 인자는 argv로 전달 (쉘 미개입) — shell injection 불가
- ffmpeg
- Live stream 처리: 명시적 거부·감지 없음. 엔드리스 스트림은 다운로드 타임아웃(§2.7) 또는 최대 크기 상한(§2.7)에서 자연 중단되고
download_timeout/too_large로 실패 처리. 부분적으로 기록된 임시 파일은 폐기. - DRM/Fairplay/암호화 세그먼트: 지원 안 함 — ffmpeg가 실패하면 그대로
ffmpeg_error반환
URL Import(§2.6)와 HLS Import(§2.6.1)가 공유하는 두 개의 런타임 설정을 UI에서 조정할 수 있다. 서버 전역 단일 값(single-tenant 배포).
- 설정 항목:
url_import_max_bytes— URL/HLS 다운로드 누적 바이트 상한. 기본 10 GiB (10 * 1024³ = 10737418240). 경계: 1 MiB ~ 1 TiB (1048576~1099511627776).url_import_timeout_seconds— URL/HLS 다운로드 per-URL 총 타임아웃. 기본 1800초(30분). 경계: 60 ~ 14400초 (1분 ~ 240분).auto_convert_png_to_jpg—/api/upload에서 PNG를 받으면 JPEG로 자동 변환할지 여부 (§2.8.1). 기본true. boolean이라 경계 검증 없음.- (검토 중)
folder_upload_workers— 폴더 업로드 워커 풀 크기 (§2.11). 현재는 클라이언트 상수4로 하드코딩. settings 노출은 실측 후 결정.
- 저장 위치:
<dataDir>/.config/settings.json— browse에서 숨김(.-prefix 필터로 기존에 제외됨). atomic write(temp + rename). - 형식:
{ "url_import_max_bytes": 10737418240, "url_import_timeout_seconds": 1800, "auto_convert_png_to_jpg": true } - 초기 로드: 서버 시작 시
settings.json읽기. 파일 부재·JSON 파싱 실패·경계 위반 값이면 경고 로그 후 기본값 사용(쓰기는 하지 않음 — 사용자가 PATCH할 때까지 메모리 default). - 요청별 적용: 각 URL import/HLS 요청 시작 시점에 현재 설정 스냅샷을 찍어 사용. 다운로드 중간에 PATCH가 와도 진행 중인 요청은 원래 값 유지(race-free).
- UI: 헤더 ⚙ 버튼 → 설정 모달
- 필드 1: 최대 다운로드 크기 (MiB 단위 number input,
1~1048576), 옆에 helper text로 GiB 환산 표시 (예:10240 MiB ≈ 10.0 GiB) - 필드 2: 다운로드 타임아웃 (분 단위 number input,
1~240) - 필드 3: "PNG 업로드 시 JPG로 자동 변환" 체크박스 (기본 ON, §2.8.1)
- 저장 버튼 →
PATCH /api/settings→ 성공 시 모달 닫고 값 캐시 갱신, 실패 시 모달 내부에 에러 메시지 표시
- 필드 1: 최대 다운로드 크기 (MiB 단위 number input,
- 서버 검증: PATCH 시 세 필드 모두 검사 —
url_import_max_bytes/url_import_timeout_seconds는 경계,auto_convert_png_to_jpg는 boolean 타입. 범위 밖 값은400 {"error": "out_of_range", "field": "url_import_max_bytes" | "url_import_timeout_seconds"}, 타입 오류는400 {"error": "invalid request"}. 저장 실패 시500 {"error": "write_failed"}. - Content-Length 누락 허용: §2.6에서
missing_content_length거부 정책은 제거. CL 없이도 다운로드 진행하며 런타임 누적 바이트 카운터가url_import_max_bytes초과 시 즉시 중단(HLS와 동일한 size watcher 방식).
PNG 파일을 JPEG로 영구 변환한다. 두 진입점:
- 자동 업로드 변환 (§2.8.1):
/api/upload로 PNG가 들어오면 디스크에 저장하기 전에 JPEG로 변환. settings 토글로 ON/OFF. - 수동 변환 (§2.8.2):
POST /api/convert-image로 기존 PNG 파일을 명시적으로 변환. UI 카드별 버튼 + 폴더 일괄 변환 버튼.
용도: 사진/스크린샷 갤러리 운용에서 PNG는 무손실 압축 특성상 같은 콘텐츠도 JPEG의 수 배 크기다. 단일 사용자 미디어 서버에서 알파 채널이 필요한 케이스는 드물고, 디스크 절감 효과가 더 크다는 사용자 판단. 알파 채널(투명도) 손실은 JPEG의 구조적 한계이므로 흰 배경 합성으로 처리 — 알파가 필수인 PNG는 자동 변환을 끄거나 수동 변환을 회피해야 한다.
구현 공통 규약:
- 라이브러리: 기존
github.com/disintegration/imaging(§3) 재사용. PNG 디코드 + JPEG 인코드 모두 동일 라이브러리. ffmpeg 호출 없음. - JPEG quality: 90 고정. 사용자 설정 없음 (단순성 우선; settings에 quality 필드 추가하지 않음).
- 알파 채널 처리: 알파가 있는 PNG는 흰색(#FFFFFF) 배경에 합성한 뒤 JPEG로 인코드.
image.NewRGBA(bounds)→ 흰색 fill →draw.Draw(dst, bounds, src, image.Point{}, draw.Over)패턴. - 메모리 폭주 방어 (decompression bomb): 디코드 전에
image.DecodeConfig로 헤더만 먼저 읽어width × height > 64M 픽셀(≈ 8K×8K, RGBA로 ~256 MiB)인 입력을 거부한다 (imageconv.MaxPixels, sentinelimageconv.ErrImageTooLarge). 5–60 KB짜리 zlib bomb이 65535×65535 헤더로 16 GiB 할당을 요구하는 시나리오 차단. handler는 wire codeimage_too_large로 매핑(§5), 자동 업로드 변환(§2.8.1)에서는convert_failed폴백과 동일하게 원본 PNG 보존 + warning. - EXIF/메타데이터: 보존하지 않음. PNG에는 거의 없고, JPEG 인코더 기본 동작에 맡긴다.
- 확장자 정책: 출력은 항상 소문자
.jpg(.jpeg아님). 입력 PNG가.PNG/.Png이어도 동일. - 신규 패키지:
internal/imageconv/— 단일 함수ConvertPNGToJPG(srcPath, destPath string, quality int) error. atomic write(os.CreateTemp같은 디렉토리 → 인코드 →os.Rename) 포함. handler / upload 양쪽에서 호출.
/api/upload (§5)가 PNG를 받으면 디스크에 PNG로 저장하는 대신 JPEG로 변환해 저장한다. settings의 auto_convert_png_to_jpg(§2.7)가 true일 때만 작동.
- 트리거 조건: multipart 파일의 base name 확장자가
.png(대소문자 무시) ANDh.settingsSnapshot().AutoConvertPNGToJPG == true. 그 외 PNG는 변환하지 않고 원본 그대로 저장. - 흐름:
- multipart 스트림을 destDir 안 임시 파일에 저장:
os.CreateTemp(destDir, ".pngconvert-*.png")—.-prefix라 browse 필터로 자동 숨김. 사용자에게 보일 최종 파일명을 점유하지 않는다. - 임시 PNG → JPEG 변환(
imageconv.ConvertPNGToJPG): 디코드 → 흰 배경 합성 → quality 90 인코드 → 두 번째 임시 파일(.pngconvert-*.jpg)에 atomic write. - 첫 번째 임시 파일(원본 PNG) 삭제.
- 두 번째 임시 파일을 최종 경로로 atomic rename. 최종 파일명은
<basename>.jpg(확장자만.jpg로 교체, base name 유지). - 최종 경로 충돌 시 기존
createUniqueFile패턴(O_CREATE|O_EXCL 재시도)으로<basename>_1.jpg,_2.jpg자동 suffix → 응답warnings에"renamed"추가. - 썸네일 풀 제출은 변환된 JPEG를 대상으로 (기존 동작과 동일, type=image).
- multipart 스트림을 destDir 안 임시 파일에 저장:
- 변환 실패 시 폴백:
imageconv.ConvertPNGToJPG가 에러 반환(decode 실패, encode 실패, write 실패) → 두 번째 임시 파일 cleanup, 첫 번째 임시 PNG를 원래 흐름대로 최종 경로로 rename(.png 그대로 저장), 응답warnings에"convert_failed"추가. 업로드 자체는 성공(201)으로 처리. 서버 로그(slog.Warn)에 사유 기록. 이유: 사용자가 어찌됐든 파일은 받기를 기대한다 — 변환 실패가 업로드 실패로 변환되면 데이터 손실로 인식된다. - 응답 스키마 변경: 기존
{path, name, size, type}에 두 필드 추가 — 상세는 §5POST /api/upload.converted: bool— true면 PNG → JPG 변환됨, false면 원본 그대로 (자동 변환 OFF 또는 변환 실패 또는 PNG 아님).warnings: string[]—"renamed","convert_failed"누적.
- 설정 OFF 시: PNG도 다른 파일처럼 원본 그대로 저장.
converted: false,warnings: []. 응답 외 모든 동작은 기존 업로드와 동일 (multipart →createUniqueFile). - type 판별: 변환 성공이든 실패든 응답
type은 최종 파일 기준 (media.DetectType). PNG 그대로면image, JPG로 변환돼도image— 변하지 않음. - 설정 스냅샷: 요청 시작 시점에
h.settingsSnapshot()로 값을 고정. 업로드 중간에 PATCH로 토글이 바뀌어도 진행 중 업로드는 원래 값 유지(§2.7 race-free 정책 일관). - 동시성: 업로드는 항상 unique 임시 파일 사용 → per-path lock 불필요. 디코드/인코드는 CPU bound이지만 단일 사용자 가정이라 별도 워커 풀 도입 안 함 (handler goroutine에서 직접 수행).
기존 PNG 파일을 명시적으로 JPEG로 변환. SSE가 아닌 동기 JSON 응답 — PNG 변환은 일반 사진 크기에서 1초 내외이고, 폴더 일괄 변환(최대 500개)도 단일 사용자 운용에서 수 분 내 종료되어 progress 스트림이 가치보다 복잡도가 큼.
- API:
POST /api/convert-image(§5에 상세). - 개별 변환 트리거: 이미지 카드가 PNG 파일이면 "JPG로 변환" 버튼 표시. 기존 rename/delete 버튼과 동일 레이아웃. 클릭 시 확인 모달 → 변환 시작.
- 일괄 변환 트리거: 상단 툴바(§2.5.2와 공존)의 단일 버튼이 selection 상태에 따라 모드 전환 — 클릭 시 확인 모달 → 한 번의 요청으로 변환:
- 선택 0개 + visible PNG ≥ 1개: "모든 PNG 변환 (M개)" — 현재 filter/sort 통과한 visible entries 중 PNG 전부.
- 선택 ≥ 1개이고 그중 PNG ≥ 1개: "선택 PNG 변환 (N개)" —
selectedPaths∩ visible entries 중 PNG만 추려서 변환. 비-PNG는 자동으로 제외(차단/경고 없음). - 선택 ≥ 1개인데 PNG 0개 / 선택 0개 + visible PNG 0개: 버튼 숨김.
- selection 정책: 폴더는
selectedPaths에서 자동 제외(§2.1.3 기존 정책)이라 폴더가 섞일 수 없음. 다른 폴더로 이동 시 selection이 비워지는 동작도 기존 그대로.
- 변환 동작 (요청 1건당 file 단위 순차 처리):
media.SafePath로 입력 경로 검증 → traversal 차단.os.Stat으로 파일/디렉토리 구분, 확장자.png(대소문자 무시) 화이트리스트.- 목표 경로
<basename>.jpg가 같은 디렉토리에 이미 존재하면 항목 결과를error: "already_exists"로 마킹 — 자동 suffix 없음(rename·TS→MP4 정책과 일관). 사용자가 기존.jpg처리 결정해야 함. imageconv.ConvertPNGToJPG로 임시 파일에 변환 → atomic rename으로 최종 경로 안착.delete_original: true이면 변환 성공 후 원본 PNG +.thumb/{name}.png.jpg삭제. 사이드카 삭제 실패 시 결과의warnings에"delete_original_failed"추가 (이미지에는.dur사이드카 없음 — §2.3.2 동영상 전용).- 새 JPEG의 썸네일은 별도 생성하지 않음 — 기존 lazy 메커니즘(§2.3.1)이 다음
browse에서 자동 생성 (TS→MP4와 동일 단순화).
- 파일명 결정:
foo.png→foo.jpg(base name 유지, 확장자만.jpg교체).- 대소문자: 원본이
.PNG/.Png등이어도 출력은 소문자.jpg고정. - 충돌 처리: 위 3번. 자동 suffix 없음 (자동 업로드 변환과 다른 정책 — 수동은 사용자의 명시적 행위라 의도 추정 금지, rename 정책과 일관).
- 대소문자: 원본이
- 응답:
200 OK, 동기 JSON. 항목별 결과 배열. 항목 단위 성공/실패가 섞여도 HTTP 200 — TS→MP4 SSE의 batch summary와 동일한 정신. 상세는 §5POST /api/convert-image. - 타임아웃: 파일당 30초 (
imageConvertFileTimeout상수). 초과 시error: "convert_timeout". 정상 PNG는 1초 내외라 도달 불가능한 안전장치. - 취소: 요청 context 취소(클라이언트 연결 끊김) 시 진행 중 변환은 중단되며 임시 파일은 cleanup. 동기 응답이라 클라이언트 입장에서는 fetch가 abort되는 형태 — 별도 cancel API 없음.
- 응답 후 UI 갱신: 응답 수신 후 클라이언트가
loadBrowse()1회 호출 → 새.jpg+ (delete_original 시) 원본 제거가 반영.
Non-goals (자동·수동 공통):
- JPEG 외 다른 출력 포맷 (WEBP, AVIF) — 범위 외.
- PNG 외 다른 입력 포맷 (BMP, TIFF, WEBP) — 범위 외.
- quality 사용자 조절 — 90 고정.
- 알파 채널 보존을 위한 PNG 우회 (예: 알파가 있는 PNG는 변환 거부) — 정책상 항상 흰 배경 합성.
- EXIF/메타데이터 보존.
- 변환 큐 / 잡 레지스트리 영속화 — 동기 요청이라 불필요.
- progress 이벤트 / SSE — 동기 응답으로 단순화.
- 동시 변환 (배열은 항상 순차 처리) — 단일 사용자 가정.
- URL import(§2.6) 결과의 자동 변환 — 다운로드와 변환 의도를 분리. 다운로드 받은 PNG는 수동 변환으로만 처리.
움짤(§2.5.3)로 분류되는 항목을 animated WebP로 영구 변환한다. 자동 재생·미리보기 친화적인 단일 가벼운 포맷으로 정리하는 것이 목적. 수동 변환만 제공 — PNG→JPG와 달리 ffmpeg 인코딩 비용이 무시 못 할 수준이라 다운로드/업로드 의도와 변환 의도를 분리한다.
입력 자격 (서버가 게이트 재검증):
- GIF (
mime === 'image/gif'): 무조건 자격 있음. 크기·길이 검증 면제 — §2.5.3 움짤 정의와 일관. - 동영상 (
type === 'video'): §2.5.3과 동일 게이트 —size ≤ 50 MiB(CLIP_MAX_BYTES) ANDduration_sec ≤ 30s(CLIP_MAX_DURATION_SEC). duration은thumb.LookupDuration캐시 우선 → 없으면thumb.BackfillDuration(ffprobe 1회)으로 확보. duration을 끝내 알 수 없으면duration_unknown로 거부 (보수적). - 그 외 (WebP 포함):
unsupported_input로 거부. WebP는 §2.5.3에서 움짤로 분류되지만 변환 결과물 자체이므로 입력 자격은 없다 — 클라이언트의 일괄 paths 추출에서 webp를 제외하고(isClipConvertable), 직접 API 호출은 서버가unsupported_input로 차단한다. PNG/JPG 정적 이미지·오디오·기타도 동일 코드.
구현 공통 규약:
- 인코더: ffmpeg
libwebp(multi-frame 입력을 자동으로 animated webp로 promote, ffmpeg 6+). 등록된libwebp_anim별칭은 alpine apk ffmpeg 6.1 빌드에서 single-frame 출력만 만드는 회귀가 있어 사용하지 않는다 —libwebp+ multi-frame 입력 경로가 결과 webp의 RIFF 컨테이너에 VP8X chunk + animation flag를 정상 기록한다. Dockerfile 영향 없음 — 기본 alpine apkffmpeg패키지에 포함. - 인코딩 파라미터 (고정값, 사용자 설정 없음):
-c:v libwebp/-loop 0(무한 반복) /-lossless 0/-q:v 80(화질 우선) /-compression_level 4- fps·해상도 원본 유지 — 자연스러움 우선. 입력이 극단 해상도여도 별도 다운스케일 안 함 (50 MiB 게이트가 이미 상한).
- 음성 제거:
-an. 입력에 audio stream이 있었다면 결과warnings에"audio_dropped"추가. audio 존재 여부는 ffprobe 1회 호출로 검출(duration 조회와 동일 호출에서 stream 정보 함께 추출 — GIF는 audio 검사 생략).
- 출력 파일명:
<basename>.webp— 확장자만 교체, base name 유지. 대소문자 정규화: 출력은 항상 소문자.webp.foo.MP4/foo.gif/foo.GIF모두 →foo.webp. - 충돌 처리: 목표
<basename>.webp가 같은 디렉토리에 사전 존재하면already_exists로 거부 — 자동 suffix 없음 (rename·TS→MP4·PNG→JPG 수동 변환과 일관). - 원자성:
os.CreateTemp(dstDir, ".webpconvert-*.webp")→ ffmpeg 출력 → atomicos.Rename. 실패 시 임시 파일 cleanup. (TS→MP4 패턴과 동일.) - per-path 직렬화:
Handler.webpLocks sync.Map로 같은 소스에 대한 동시 변환을 직렬화 — TS→MP4의convertLocks패턴 그대로. - 타임아웃: 파일당 5분 (
webpConvertFileTimeout). 30초 입력 + 화질 우선이라도 충분히 안전한 상한. - 요청 응답 형태:
POST /api/convert-webp, SSE 진행 스트림 — PNG→JPG의 동기 응답이 아니다. ffmpeg 인코딩이 1초 내외로 끝나지 않으므로 progress UX 가치가 충분. 이벤트 스키마는 TS→MP4(/api/convert)와 동일 (start/progress/done/error/summary). 자세한 스키마는 §5.1. delete_original: 기본false.true면 변환 성공 후 원본 +.thumb/{name}.jpg(+ 동영상은.jpg.dur) 삭제. 사이드카 삭제 실패는delete_original_failedwarning 추가, 변환 자체는 성공.- 새 WebP의 썸네일: 별도 생성하지 않음 — 기존 lazy 메커니즘이 다음 browse에서 자동 생성 (TS→MP4·PNG→JPG와 동일 단순화).
- 동시성: 배열은 항상 순차 처리 (single-user 가정).
- 취소: request context cancel 시 ffmpeg kill + 임시 파일 정리.
- 요청 보호:
requireSameOrigin으로 wrap (변경 작업). - 설정 의존: 인코딩 파라미터가 모두 고정값이므로 settings(§2.7) 의존 없음.
- 신규 패키지/파일:
internal/convert/webp.go(별도 패키지 신설하지 않음 — TS→MP4와 함께 ffmpeg runner 묶음에 합친다),internal/handler/convert_webp.go.
UI:
- 카드별 "WebP로 변환" 버튼: 움짤 자격 카드(GIF 또는 짧은 동영상)에만 노출. WebP 카드는 결과물이므로 버튼 미노출 (재변환 의도 없음). 기존 rename/delete 버튼과 동일 레이아웃.
- 일괄 변환 트리거 (툴바, PNG→JPG 일괄 버튼과 공존하는 별도 버튼). 변환 입력 자격 (
isClipConvertable)은isClip에서 webp를 제외한 부분집합 — GIF + 짧은 동영상만:- 선택 0개 + visible 변환가능 움짤 ≥ 1개: "모든 움짤 WebP로 변환 (M개)" — 현재 visible entries 중
isClipConvertable통과 항목 전부 (webp 제외). - 선택 ≥ 1개이고 그중 변환가능 움짤 ≥ 1개: "선택 움짤 WebP로 변환 (N개)" —
selectedPaths∩ visible entries 중isClipConvertable통과만 추려서 변환. 비-움짤·webp는 자동 제외(차단·경고 없음). - 그 외 (선택은 있는데 변환가능 0개 / 선택 0개 + visible 변환가능 0개): 버튼 숨김.
- 선택 0개 + visible 변환가능 움짤 ≥ 1개: "모든 움짤 WebP로 변환 (M개)" — 현재 visible entries 중
- 활성 조건: 일괄 버튼은 움짤 탭(
view.type === 'clip') 활성 시에만 노출. 다른 탭(전체/이미지/동영상)에서는 visible에 움짤이 섞여 있어도 표시하지 않음 — 의도 모호함 방지(움짤 탭으로 명시 진입한 경우에만 일괄 변환을 허용). - 모달: TS→MP4와 동일한 SSE 진행 바 모달 디자인. 항목별 진행률 바 + 성공/실패 요약. 신규 모듈
web/convertWebp.js(TS→MP4의convert.js를 참고하되 별도 모듈로 분리해 모달 DOM 충돌 회피). - 응답 후 갱신: done 이벤트 수신 시 해당 폴더
loadBrowse()1회 — 새.webp카드 + (delete_original 시) 원본 제거 반영.
Non-goals:
- WebP 외 다른 애니메이션 출력 포맷 (AVIF·HEIF, GIF로의 역변환) — 범위 외.
- 인코딩 파라미터 사용자 조절 (quality / fps / scale / loop) — 모두 고정값.
- 무손실 webp 모드.
- 자동 업로드 변환 — 다운로드/업로드 의도와 변환 의도 분리 (PNG→JPG 자동 변환과 다른 정책 결정).
- 음성 보존 — WebP는 무음 포맷.
- 움짤 게이트 미충족 동영상의 강제 변환 — 30s/50MiB 상한은 정책. 더 긴 동영상이 필요하면 별도 Phase.
- 변환 큐 / 잡 레지스트리 영속화 — 서버 재시작 시 진행 중 변환 폐기 (TS→MP4와 동일).
- 동시 ffmpeg 프로세스 (배열은 순차 처리).
폴더 한 번에 가져오기 또는 그리드에서 선택한 다수 파일을 한 번에 가져오기. 선택이 1개면 ZIP을 거치지 않고 기존 /api/stream으로 직접 다운로드한다.
API 형태:
GET /api/download-folder?path=<dir>—<dir>폴더 전체 재귀 ZIP. 브라우저<a download>트리거로 자연스러운 스트리밍 다운로드.POST /api/download-folder?path=<dir>body{"items":["/dir/a.mp4","/dir/sub/b.jpg",...]}—<dir>기준 부분 선택 ZIP (재귀: items 안에 폴더가 섞여도 walk).
공통 동작:
- 응답:
Content-Type: application/zip,Content-Disposition: attachment; filename*=UTF-8''<percent-encoded>(한글 폴더명 RFC 5987 인코딩). - ZIP Store 모드 (
zip.Store) — 미디어는 이미 압축되어 deflate가 비용만 들고 거의 줄어들지 않는다. archive/zip.Writer가http.ResponseWriter로 직접 write — 메모리에 ZIP 전체를 적재하지 않는다.media.SafePath로path와 모든items검증 (path traversal 차단).- 모든
items는path기준 자손이어야 한다 — 자손이 아니면400 {"error": "invalid items"}(path를 prefix scope로 강제). - ZIP 내부 경로는
<rootName>/<상대경로>— 클라이언트가 압축을 풀면 ZIP 파일명과 동일한 폴더로 묶인다.path가 root("/")일 땐<rootName>=files. 그 외엔<rootName>= 폴더 base name. - 제외 규칙: 워크 중 점(
.)으로 시작하는 모든 디렉터리·파일 skip —.thumb/,.cache/,.config/, dotfile 일체. 사용자가 의도해서 만든 dotfile도 함께 제외(단순성 우선; 데이터 디렉터리에 dotfile을 의도적으로 두는 케이스는 1차 미지원). - Symlink:
os.Lstat로 검사해 skip (보안상 root 외부로 새는 것 차단). - 빈 폴더 / 빈 selection: ZIP 헤더만 있는
200 OK빈 ZIP 반환 — 클라이언트가 분기 없이 받아 풀 수 있다. - 요청 보호:
requireSameOrigin로 wrap — GET은 패스, POST는 cross-origin 거부 (기존 mutating 정책과 일관). GET은 mutating은 아니지만 같은 미들웨어로 등록해 라우트 표를 단순화. - 순차/스트리밍: ZIP은 큰 파일도 buffer 없이 흘려 보낸다. 중간 io 오류는 부분 ZIP을 그대로 끊는다 — 이미 헤더를 보냈으므로 5xx로 응답을 바꿀 수 없다 (HTTP 기본 한계). 로그만 남긴다.
Content-Length미설정: ZIP 크기를 사전에 알 수 없으므로Transfer-Encoding: chunked로 흘린다 (Go가 자동 처리).- 신규 파일:
internal/handler/download_folder.go.
오류 매핑:
path/itemstraversal 또는items가path자손이 아님:400 {"error": "invalid path"}또는"invalid items".path미존재:404 {"error": "not found"}.path가 디렉터리가 아님:400 {"error": "not a directory"}.- POST body 파싱 실패:
400 {"error": "invalid body"}. body 64 KiB 초과:413 {"error": "too_large"}(기존maxJSONBodyBytes). - POST cross-origin:
403 {"error": "cross_origin"}.
UI:
- 툴바 "폴더 다운로드" 버튼: 현재 폴더 전체 GET 트리거.
<a href="/api/download-folder?path=..." download>클릭 시뮬레이션. 빈 폴더에서도 항상 표시 (빈 ZIP). - 선택 다운로드 버튼:
selectionSummary영역(선택 ≥1일 때 노출되는 "선택 해제" 옆) — 라벨 "선택 다운로드 (N개)".- N=1:
/api/stream?path=<file>직접 다운로드 (기존 GET 스트림). ZIP 우회. - N≥2:
POST /api/download-folder?path=<currentPath>body{items: selectedPaths}→fetch→response.blob()→URL.createObjectURL→<a download>트리거 →revokeObjectURL. 단일 사용자 LAN 환경에서 메모리 적재 트레이드오프 수용.
- N=1:
- 다운로드 파일명: 폴더 전체는
<basename>.zip(root는files.zip). 선택은<currentBasename>-selected-<N>.zip. 서버가Content-Disposition으로 알려주므로<a download>속성은 fallback으로만 채운다.
Non-goals:
- 사이드바 트리 노드의 다운로드 진입점 (필요 시 후속 Phase).
- 다중 폴더 동시 선택 다운로드 (현재 폴더 단위 + 그 안의 파일 선택만 지원).
- ZIP 내부 deflate 압축, 암호화 ZIP, ZIP64 강제(
archive/zip이 필요 시 자동 적용). - 진행률 / 취소 / 재개 — ZIP은 한 응답으로 끝, 진행률은 산정 비용·UX 가치 모두 작다 (1차 보류).
- 사이드카(
.thumb/) 동봉 — 다운받은 ZIP을 다른 환경에서 풀 때는 새로 생성하면 충분.
PC의 폴더를 통째로 선택/드래그하면 폴더 안의 모든 파일을 재귀로 업로드한다. 하위 폴더 구조도 그대로 보존. 새 백엔드 엔드포인트 없이 기존 POST /api/folder(폴더 생성)와 POST /api/upload(파일 업로드)만 조합 — 클라이언트가 트리를 walk해서 sub-folder 사전 생성 후 파일 하나씩 업로드.
진입점:
-
#upload-zone에 "클릭하여 업로드" 옆 "폴더 선택" 라벨 추가. 별도<input type="file" id="folder-input" webkitdirectory hidden>사용 — 단일 input에webkitdirectory를 토글하는 방식은 브라우저별 picker 동작 차이가 있어 element를 분리한다. - 드래그앤드롭 확장:
event.dataTransfer.items[i].webkitGetAsEntry()로 재귀 탐색.entry.isFile이면entry.file(cb),entry.isDirectory면entry.createReader().readEntries(cb)를 빈 배치까지 반복 호출 (W3C 표준 패턴 — Chrome이 100개씩 끊어 반환). 폴더와 파일이 섞여 드롭되면 둘 다 처리.
클라이언트 트리 walk:
- 메모리 큐에
{ relativePath, file }적재.relativePath는webkitRelativePath(input) 또는entry.fullPath(drag)에서 추출, 슬래시 정규화 + 선두/말미 슬래시 제거. - 숨김파일 필터:
relativePath의 어떤 세그먼트라도.로 시작하면 skip + WindowsThumbs.db도 별도 추가. 필터된 개수는 진행 UI 말미에 "숨김파일 N개 제외" 메모로 표시. - 상한 가드 (사전 ack): 파일 ≥ 1000개 또는 총합 ≥ 50 GiB면
confirm("1234개 / 18.2 GiB 업로드 — 계속?")게이트. 단일 사용자 환경이라 hard cap 대신 acknowledge.
루트 폴더 충돌 모달:
- 업로드 직전
GET /api/browse?path=<currentPath>로 동일 root name 폴더 존재 확인. 존재 시 모달:- (1) 합치기 — 기존 폴더에 추가 업로드. 파일 단위 충돌은 서버의
createUniqueFile이_1,_2suffix 자동 부착. - (2) 새 이름으로 — 입력 필드(기본값
<root>_1), 검증 후 재시도. 새 이름이 또 충돌하면 입력 필드에 에러 노출 후 재입력 유도. - (3) 취소 — 업로드 자체 중단.
- (1) 합치기 — 기존 폴더에 추가 업로드. 파일 단위 충돌은 서버의
- sub-folder 충돌은 묻지 않음 —
POST /api/folder로 만들고 409면 머지 (서버 측 자동 머지). 파일 단위 충돌은 위와 같이 서버 자동 suffix. - 파일만 드롭한 경우(루트 폴더 없음): 충돌 모달 건너뜀, 기존 단일 파일 업로드 흐름 그대로.
업로드 2-phase:
- Phase 1 - 폴더 사전 생성: unique한 sub-folder 경로 집합을 shallowest-first로 정렬해 직렬로
POST /api/folder?path=<parent>body{name: <leaf>}호출. 응답 201 또는 409(이미 존재)는 OK, 그 외는 즉시 실패하고 폴더 row를 error로 표시. 모든 sub-folder 준비 후에야 Phase 2 진입 — 부분 실패 시 트리가 일관적이다. - Phase 2 - 파일 업로드: 워커 풀 크기 4. 각 워커는 큐에서
{ relativePath, file }을 꺼내POST /api/upload?path=<currentPath>/<relativePath의 부모>로 단일 파일 multipart 전송. 백엔드 핸들러는 변경 없음 — PNG→JPG 자동 변환(§2.8.1)·썸네일 생성·createUniqueFilesuffix 모두 기존 동작 그대로 흐른다. 워커 수 4는 상수(추후 settings 노출 검토 가능, 1차에서는 하드코딩).
진행 UI (폴더 단위 1줄):
📁 myfolder/ ▰▰▰▰▰▰░░░░ 327 / 1234 (5.4 / 18.2 GiB) [취소]
- 막대는 (완료 파일 수 / 전체 파일 수) 비율. 텍스트는 "완료/전체 (누적/총 사이즈)".
- 사이즈 누적은 XHR
upload.progress.loaded합산. - 완료 후 row 4초 뒤 자동 제거 (기존 파일별 progress와 동일 타이밍).
- 실패 요약: 1개 이상 파일 실패 시 row 아래 collapsible "실패 파일 N건" — 경로 + 상태 코드(또는 에러 메시지) 목록. 부분 성공도 row 자체는 dim 회색으로 표시.
- 이름 변경 집계: 응답
warnings: ["renamed"]이 N건이면 row 옆 "이름 변경 N건" 메모.
Browse 갱신 debounce:
- 첫 파일 성공 시
_browse(currentPath, false)호출. 후속 파일 성공마다 호출하면 N번 리렌더가 부담 → debounce 500 ms (폴더 업로드 한정 적용; 단일 파일 업로드는 기존대로 즉시 호출).
취소:
- 폴더 row의 [취소] 버튼 → 큐 비우기 + 진행 중 XHR
abort(). 이미 디스크에 안착한 파일은 롤백 안 함 (단일 사용자 가정 — 사용자가 직접 정리). 폴더 사전 생성도 마찬가지. - 페이지 새로고침 / 탭 닫음: 진행 중 폴더 업로드는 그대로 끊긴다. URL import와 달리 서버 잡 레지스트리 없음(클라이언트 측 큐만).
다중 폴더 동시 업로드:
- 한 폴더 진행 중 추가 폴더 드롭 — 새 row를 띄우고 동시 진행. 워커 풀은 폴더별 분리 — N개 폴더 시 4N개 동시 XHR (단일 사용자 LAN 환경이라 허용, 1차에서는 풀 분리 안 함).
비목표:
- 서버 측 폴더 잡 레지스트리 / 새로고침 후 재구독 (URL import와 달리 클라이언트 큐만 — 단순성 우선).
- 부분 실패 자동 재시도 (사용자가 동일 폴더를 다시 떨어뜨리면 "합치기" 흐름으로 자연 복구).
- 폴더 단위 PNG 자동 변환 토글 — 전역 settings(§2.7)와 동일 적용.
- 빈 폴더 / symlink 명시 생성 — 브라우저 API가 노출하지 않으므로 자연 스킵.
- 진행 중 페이지 이동 시 업로드 보존 — 1차 미지원.
같은 코드베이스의 또 다른 file_server 인스턴스(이하 소스)가 보유한 미디어 파일을 현재 인스턴스(이하 타깃)로 일괄 복사한다. 새 PC로 옮기거나 docker volume을 분리할 때 사용자가 수동 rsync/scp 없이 웹 UI 한 곳에서 시작·관찰·취소할 수 있다. 타깃이 풀(pull) 주체 — 소스의 GET API(/api/browse + /api/stream)를 통해 트리를 디스커버한 뒤 파일 단위로 다운로드한다. URL Import의 영속 잡 모델(§2.6 — internal/importjob)을 거의 동형으로 복제해(internal/migratejob) 새로고침/모달 닫기에도 진행이 살아남는다.
소스 측 변경 (1개 신설):
-
GET /api/version— 핸드셰이크 응답{"product":"file_server","version":"<tag|dev>","capabilities":["migrate-source"]}. 인증 없음, cross-origin GET 안전(read-only 식별 정보). 빌드 시-X main.version=<tag>ldflag로 주입, 미주입 시"dev".
진입점 (타깃 측 UI):
- 설정 모달 하단 "다른 서버에서 가져오기" 섹션. 입력: 소스 URL · 소스 경로(기본
/) · 대상 폴더(기본 현재 browse path). 버튼: 연결 테스트 / 가져오기 시작. - 연결 테스트 →
GET <source>/api/version.product == "file_server"AND capabilities에migrate-source포함이면 통과 —<version> · migrate-source라벨. 실패 분기 한국어 라벨 4종(잘못된 URL / 네트워크 오류 / 서버 응답이 file_server 아님 / migrate-source 없음). - 시작 →
POST /api/migrate?path=<destRel>body{"sourceUrl":"...","sourcePath":"/"}. SSE 응답.
잡 라이프사이클 (서버 측 워커):
-
Handler.migrateSem(size 1) 으로 프로세스 단위 직렬화 — 동시에 한 마이그레이션만 진행. 추가 시도는 큐잉(queuedSSE 1회 emit). - handshake 단계:
Client.Version호출 → 실패 시 SSE error + summaryfailed:0로 종료. - discover 단계: BFS로
Client.Browse재귀 호출 → 파일 목록 수집. dot-prefix는 소스 측 browse가 이미 거름.discover-progress250 ms throttle로 진행 신호. - download 단계: 파일 직렬 1개씩. per-file ctx 등록(취소 가능).
Client.Download(GET /api/stream?path=)→ byte 흐름 + 1 MiB / 250 ms throttle progress emit. atomic write(temp → rename). - 충돌 회피:
media.NameWithSuffix+O_CREATE|O_EXCL루프로name_1.ext,name_2.ext... — §2.11 폴더 업로드·URL Import·폴더 이동과 동일 컨벤션. - TS → MP4 부수효과: 소스의
/api/stream이 TS를 mp4로 remux해 응답하므로 타깃에는.mp4확장자로 도착.done이벤트에warnings: ["ts_remuxed_to_mp4"]로 사용자에게 신호. TS 원본 보존은 비목표. - per-file size 상한: 16 GiB(
migrateMaxFileBytes). - terminal 시
summarySSE —succeeded/failed/cancelled/renamed/bytesCopied.
SSE 이벤트 스키마 (POST /api/migrate 응답 + GET /api/migrate/jobs/{id}/events 재구독):
register {phase, jobId}
queued {phase} (다른 잡 점유 시)
handshake {phase, sourceUrl, version}
discover-start {phase, sourcePath}
discover-progress {phase, filesFound, bytesFound} (250 ms throttle)
discover-done {phase, totalFiles, totalBytes}
start {phase, index, sourcePath, name, total, type}
progress {phase, index, received} (1 MiB / 250 ms throttle)
done {phase, index, path, name, size, renamed, warnings?}
error {phase, index, sourcePath, error} (잡 단위 에러는 index = -1)
summary {phase, succeeded, failed, cancelled, renamed, bytesCopied}
잡 영속·재구독:
- 모달 닫기 = 백그라운드 진행(URL Import 동일 정책). 헤더 우측 미니 배지
MIG ↓ N/M · X GB / Y GB로 진행 신호. 클릭 시 모달 재오픈 + 섹션 스크롤. - 페이지 새로고침 →
GET /api/migrate/jobs부트스트랩 → 활성 잡당 EventSource 재구독으로 진행 이어받기. - 종료 잡(
completed/failed/cancelled)도 dismiss 전까지 모달에 row 유지. - 서버 재시작 → 인메모리 레지스트리 손실(URL Import 동일 정책 — 디스크 영속 큐는 별도 spec).
취소:
- 잡 전체 취소 →
POST /api/migrate/jobs/{id}/cancel(index 없음). 워커가 ctx.Done 관측해 부분파일 정리 + 미시작 파일 cancelled 마크. - 파일 단위 건너뛰기 →
POST /api/migrate/jobs/{id}/cancel?index=N. CancelKindPending(미시작)이면 핸들러가 error 이벤트 1회 발행, CancelKindRunning이면 워커가 자체 발행. - dismiss →
DELETE /api/migrate/jobs/{id}(terminal만 허용, active는 409). - 일괄 정리 →
DELETE /api/migrate/jobs?status=finished.
제외 항목 (비목표):
- 사이드카(
.thumb/,.dur) 마이그레이션 — 타깃에서 lazy 재생성(§2.3.1). .cache/streams/(TS→MP4 일시 캐시) 마이그레이션 — 재생성됨.settings.json마이그레이션 — 사용자가 새 인스턴스에서 직접 재설정.- TS 원본 보존 — 위 부수효과 명시.
- HTTP Range 부분 재개 / 중단 후 이어받기.
- 디스크 영속 잡 — 서버 재시작 시 활성 잡 유실.
- 인증 · 토큰 · 멀티 사용자 격리 — LAN 단일 사용자 가정.
- 양방향 / 증분 sync — 일회성 풀.
- 소스 파일 삭제 / 이동 의미 — 순수 복사.
- 멀티 동시 마이그레이션 —
migrateSem(size 1)로 직렬.
보안:
- 마이그레이션 전용 클라이언트는
urlfetch.NewClient(urlfetch.AllowPrivateNetworks())— LAN private IP를 명시적으로 허용. URL Import의 기본 차단 클라이언트와 분리(§2.6 SSRF 정책 누수 방지). - 모든 mutating 라우트는
requireSameOrigin래핑(§5.3). -
/api/version은 read-only이고 cross-origin GET 가능 — 소스로 동작하는 인스턴스가 다른 LAN 호스트의 타깃에서 핸드셰이크 받을 때 정상 경로. 응답에Access-Control-Allow-Origin: *헤더를 부착해 브라우저 측 fetch가 응답 본문을 호출자 JS에 노출할 수 있도록 한다 — 본 응답은 사용자 데이터를 담지 않고 인증도 없어 와일드카드가 안전하다(spec §7 위협 모델). 다른 read-only 라우트(/api/browse,/api/stream등)는 마이그레이션 워커가 server-to-server로 호출하므로 CORS 헤더가 필요 없다.
| Layer | Choice | Reason |
|---|---|---|
| Backend | Go (net/http stdlib) | 성능, 단일 바이너리 |
| Image processing | github.com/disintegration/imaging |
순수 Go, CGo 불필요. 썸네일 + PNG → JPG 변환(§2.8)에서 공유 |
| WebP first-frame | libwebp-tools (alpine apk: webpmux + dwebp) |
animated WebP 의 thumbnail 첫 프레임 추출(§2.5.6) — Go imaging 의 정적 webp 디코더 한계 보완 |
| Transcoding | ffmpeg (alpine apk) | TS → MP4 실시간 트랜스코딩, 움짤 → animated WebP 인코딩(libwebp + multi-frame 자동 promote, §2.9) |
| Frontend | Vanilla HTML + CSS + JS | 의존성 없음 |
| Container | Docker + Docker Compose | 배포 단순화 |
| Storage | Docker named volume → /data |
영속성 |
file_server/
├── cmd/
│ └── server/
│ └── main.go # 진입점 — 설정 로드 + handler.Register + graceful shutdown
├── internal/
│ ├── handler/ # HTTP 엔드포인트 (각 파일이 라우트군 하나)
│ │ ├── handler.go # Handler 구조체, Register, writeError, requireSameOrigin
│ │ ├── sse.go # SSE bootstrap 헬퍼 (assertFlusher / writeSSEHeaders / sseEmitter)
│ │ ├── names.go # file/folder/upload 공유 유틸 (validateName, atomicRenameFile, ...)
│ │ ├── browse.go # GET /api/browse — 디렉터리 조회
│ │ ├── tree.go # GET /api/tree — 사이드바 트리
│ │ ├── files.go # PATCH/DELETE /api/file — 파일 rename/delete/move + 사이드카 정리
│ │ ├── folders.go # POST/PATCH/DELETE /api/folder — 폴더 create/rename/delete/move
│ │ ├── upload.go # POST /api/upload + PNG → JPG 자동 변환 헬퍼
│ │ ├── stream.go # Range 스트리밍 + TS 실시간 remux (.cache/streams/)
│ │ ├── thumb.go # /api/thumb (lazy 생성 fallback 포함)
│ │ ├── import_url.go # URL/HLS 다운로드 SSE 핸들러
│ │ ├── import_url_jobs.go # /api/import-url/jobs* (목록/구독/취소/삭제)
│ │ ├── convert.go # TS → MP4 영구 변환 SSE 핸들러
│ │ ├── convert_image.go # PNG → JPG 변환 핸들러 (§2.8.2)
│ │ ├── convert_webp.go # 움짤 → animated WebP 변환 SSE 핸들러 (§2.9)
│ │ ├── download_folder.go # GET/POST /api/download-folder — 폴더/선택 ZIP 스트리밍 (§2.10)
│ │ └── settings.go # GET/PATCH /api/settings
│ ├── media/ # 타입 판별, MIME, SafePath, MoveFile (최하위 레이어)
│ ├── thumb/ # 이미지·동영상 섬네일 + duration 사이드카, 워커 풀
│ ├── urlfetch/ # HTTP 다운로드 + HLS remux (SSE 용 Callbacks hook)
│ ├── convert/ # ffmpeg 기반 변환 runner — TS → MP4 remux + 움짤 → WebP 인코딩(§2.9)
│ ├── imageconv/ # PNG → JPG 변환 (§2.8) — disintegration/imaging 기반, 흰 배경 합성
│ ├── importjob/ # 잡 라이프사이클·이벤트 채널·Registry (인메모리)
│ └── settings/ # §2.7 URL import 설정 — JSON 영속화 + 스냅샷 getter
├── web/ # vanilla HTML/CSS/JS — 번들러·외부 의존성 없음
│ ├── index.html
│ ├── style.css
│ ├── main.js # 진입점 (init/wire/popstate)
│ ├── state.js / dom.js / util.js # 공유 상태·DOM ref·유틸
│ ├── router.js # URL 쿼리 동기 + history 관리
│ ├── browse.js # 디렉터리 조회·렌더·라이트박스·오디오
│ ├── tree.js # 사이드바 폴더 트리
│ ├── fileOps.js # drag/drop, rename/delete 모달
│ ├── dragSelect.js # rubber-band 영역 선택 (§2.5.4)
│ ├── settings.js # 설정 모달
│ ├── urlImport.js # URL/HLS import SSE 클라이언트
│ ├── urlImportJobs.js # 백그라운드 잡 복원/취소/dismiss
│ ├── convert.js # TS→MP4 변환 (sseConvertModal 주입)
│ ├── convertImage.js # 이미지 포맷 변환 모달
│ ├── convertWebp.js # 움짤 → WebP 변환 (sseConvertModal 주입)
│ ├── sseConvertModal.js # SSE 변환 공유 모달 팩토리
│ └── modalDismiss.js # 폼 모달 ESC + 백드롭 닫기 헬퍼
├── Dockerfile
├── docker-compose.yml
└── SPEC.md
의존 방향: cmd → handler → (importjob, urlfetch, convert, imageconv, thumb, settings, media) → media. media는 최하위, 상향 의존 금지.
| Method | Path | Description |
|---|---|---|
| GET | /api/browse?path= |
디렉토리 목록 조회 |
| GET | /api/stream?path= |
파일 스트리밍 (Range 지원) |
| GET | /api/thumb?path= |
섬네일 이미지 반환 |
| POST | /api/upload?path= |
파일 업로드 |
| DELETE | /api/file?path= |
파일 삭제 |
| PATCH | /api/file?path= |
파일 이름 변경 (확장자 고정) |
| POST | /api/folder?path= |
폴더 생성 |
| PATCH | /api/folder?path= |
폴더 이름 변경 또는 이동 (body로 분기) |
| DELETE | /api/folder?path= |
폴더 재귀 삭제 (하위 내용 + .thumb/ 포함) |
| POST | /api/import-url?path= |
URL 목록에서 미디어 다운로드 → 저장 (SSE 진행 스트림) |
| POST | /api/convert |
TS 파일 목록을 MP4로 영구 변환 (SSE 진행 스트림) |
| POST | /api/convert-image |
PNG 파일 목록을 JPG로 영구 변환 (동기 JSON, §2.8) |
| POST | /api/convert-webp |
움짤 목록을 animated WebP로 영구 변환 (SSE 진행 스트림, §2.9) |
| GET | /api/download-folder?path= |
폴더 전체를 ZIP으로 스트리밍 다운로드 (§2.10) |
| POST | /api/download-folder?path= |
path 자손 중 items만 ZIP으로 묶어 다운로드 (§2.10) |
| GET | /api/settings |
현재 다운로드 설정 조회 (§2.7) |
| PATCH | /api/settings |
다운로드 설정 갱신 (§2.7) |
| GET | /api/version |
마이그레이션 핸드셰이크용 빌드 식별자 (§2.12) |
| POST | /api/migrate?path= |
다른 file_server 인스턴스에서 미디어 풀 (SSE 진행 스트림, §2.12) |
| GET | /api/migrate/jobs |
마이그레이션 잡 active/finished snapshot (§2.12) |
| DELETE | /api/migrate/jobs?status=finished |
종료된 마이그레이션 잡 일괄 정리 (§2.12) |
| GET | /api/migrate/jobs/{id}/events |
마이그레이션 잡 SSE 재구독 (§2.12) |
| POST | /api/migrate/jobs/{id}/cancel[?index=N] |
잡 전체 / 파일 단위 취소 (§2.12) |
| DELETE | /api/migrate/jobs/{id} |
종료된 마이그레이션 잡 dismiss (§2.12) |
| GET | / |
프론트엔드 SPA |
{
"path": "/movies",
"entries": [
{
"name": "film.mp4",
"path": "/movies/film.mp4",
"type": "video",
"mime": "video/mp4",
"size": 1234567,
"mod_time": "2024-01-15T10:30:00Z",
"is_dir": false,
"thumb_available": false,
"duration_sec": 273.456
},
{
"name": "photo.jpg",
"path": "/movies/photo.jpg",
"type": "image",
"mime": "image/jpeg",
"size": 204800,
"mod_time": "2024-01-14T08:00:00Z",
"is_dir": false,
"thumb_available": true,
"duration_sec": null
},
{
"name": "subfolder",
"path": "/movies/subfolder",
"type": "dir",
"mime": "",
"size": 0,
"mod_time": "2024-01-13T00:00:00Z",
"is_dir": true,
"thumb_available": false,
"duration_sec": null
}
]
}type:"image"|"video"|"audio"|"dir"|"other"mime: 확장자 기반 MIME 타입 (디렉토리/미지원 타입은"")thumb_available:.thumb/{name}.jpg파일 존재 여부duration_sec: 동영상 파일의 재생 시간(초, float). 동영상이 아니거나 ffprobe 실패/사용 불가 시null(썸네일은 정상 서빙)- 에러:
{"error": "message"}+ HTTP 상태 코드
// 성공 201
{
"path": "/movies/film.mp4",
"name": "film.mp4",
"size": 1234567,
"type": "video",
"converted": false,
"warnings": []
}
// 에러 400/500
{"error": "message"}converted: PNG → JPG 자동 변환(§2.8.1)이 수행되어 최종 파일이 원본과 다른 확장자가 된 경우true. 그 외 항상false(PNG 아님 / 자동 변환 OFF / 변환 실패 폴백).warnings가능 값:"renamed"— 자동 변환 후 목표.jpg충돌로_1/_2자동 suffix 부착 (§2.8.1)."convert_failed"— PNG → JPG 변환 시도가 실패해 원본 PNG로 폴백 저장 (§2.8.1). 업로드 자체는 성공.
- 본문 크기 상한:
413 {"error": "too_large"}— multipart 본문이 100 GiB 초과 (internal/handler/limits.go::maxUploadBytes,http.MaxBytesReader로 진입부에서 cap; §9 참고) - 폴더 업로드(§2.11) 클라이언트는 본 엔드포인트를 파일 단건 multipart로 반복 호출 — sub-folder는 사전에
POST /api/folder로 생성해 둔다. 핸들러 자체는 단일 파일 업로드와 동일하게 동작.
- 성공:
204 No Content(body 없음) - 미존재:
404 {"error": "not found"} - traversal:
400 {"error": "invalid path"}
- Body:
{"name": "new-base-name"}(확장자 제외한 base name; 서버가 원본 확장자 재부착)- 사용자 입력에 확장자가 포함되어 있으면 서버가 strip 후 원본 확장자 사용
- 성공:
200 OK{ "path": "/movies/new-base-name.mp4", "name": "new-base-name.mp4" } - 미존재:
404 {"error": "not found"} - path가 디렉토리를 가리킴:
400 {"error": "not a file"} - 유효하지 않은 이름 (빈 문자열,
/·\\포함,.or.., 길이 초과):400 {"error": "invalid name"} - 새 이름 = 기존 이름:
400 {"error": "name unchanged"} - 충돌 (동일 디렉토리 내 동일 이름 존재):
409 {"error": "already exists"} - traversal:
400 {"error": "invalid path"} - JSON body 크기 상한:
413 {"error": "too_large"}— body 64 KiB 초과 (internal/handler/limits.go::maxJSONBodyBytes,http.MaxBytesReader로 진입부에서 cap; §9 참고)
- Body:
{"name": "new-folder"}(현재path파라미터 경로 아래에 생성) - 성공:
201 Created,{"path": "/movies/new-folder"} - 이미 존재:
409 {"error": "already exists"} - 유효하지 않은 이름 (빈 문자열,
/포함,.or..):400 {"error": "invalid name"} - traversal:
400 {"error": "invalid path"} - 폴더 업로드(§2.11) 클라이언트는 본 엔드포인트를 sub-folder 사전 생성에 호출 — 201과 409를 모두 OK(머지)로 취급해 멱등하게 처리한다.
- 재귀 삭제: 폴더 내 모든 파일·하위폴더·
.thumb/디렉토리 포함 - 성공:
204 No Content - 미존재:
404 {"error": "not found"} - path가 파일을 가리킴:
400 {"error": "not a directory"} - traversal:
400 {"error": "invalid path"}
Body 형태로 두 동작 분기 (PATCH /api/file과 동일 패턴):
{"name": "..."}→ 이름 변경 (동일 부모 디렉토리 내){"to": "..."}→ 이동 (다른 디렉토리로, base name 유지)
두 필드를 동시에 보내면 400 {"error": "specify either name or to, not both"}. 둘 다 없으면 400 {"error": "missing name or to"}.
JSON body 크기 상한(두 분기 공통): 413 {"error": "too_large"} — body 64 KiB 초과 (internal/handler/limits.go::maxJSONBodyBytes, http.MaxBytesReader로 진입부에서 cap; §9 참고)
- 성공:
200 OK{ "path": "/movies/new-folder-name", "name": "new-folder-name" } - 미존재:
404 {"error": "not found"} - path가 파일을 가리킴:
400 {"error": "not a directory"} - 루트 rename 시도 (path가 빈 문자열 또는
/):400 {"error": "cannot rename root"} - 유효하지 않은 이름:
400 {"error": "invalid name"} - 새 이름 = 기존 이름:
400 {"error": "name unchanged"} - 충돌:
409 {"error": "already exists"} - traversal:
400 {"error": "invalid path"}
- 성공:
200 OK({ "path": "/photos/2024/sub", "name": "sub" }name은 원본의 base name 그대로, 이동만 수행) - 미존재 (src):
404 {"error": "not found"} - src가 파일을 가리킴:
400 {"error": "not a directory"} - 루트 이동 시도:
400 {"error": "cannot move root"} - 대상 디렉토리 없음 또는 디렉토리 아님:
400 {"error": "invalid destination"} - 자기 자손으로 이동 시도 (
/a→/a/b):400 {"error": "invalid destination"} - 동일 부모 (이동 의미 없음):
400 {"error": "same directory"} - 동일 base name이 destDir에 이미 존재:
409 {"error": "already exists"} - cross-volume(
EXDEV):400 {"error": "cross_device"}— 운영 precondition 미충족(단일 볼륨), 폴더 재귀 copy 폴백 없음 - traversal:
400 {"error": "invalid path"}
- 성공:
200 OK또는206 Partial Content(Range 요청 시) Content-Type: 확장자 기반 MIME 타입Accept-Ranges: bytes헤더 항상 포함.ts파일: ffmpeg 파이프로 실시간 트랜스코딩,Content-Type: video/mp4반환 (Range 미지원)- 미존재:
404
- 쿼리:
path(저장할 디렉토리,/data기준 상대 경로) - Body:
{
"urls": [
"https://example.com/cat.jpg",
"https://example.com/clip.mp4",
"https://example.com/song.mp3"
]
}- 응답:
200 OK,Content-Type: text/event-stream,Cache-Control: no-cache,X-Accel-Buffering: no - SSE 프레임: 각 이벤트는
data: {JSON}\n\n형식. 이벤트 이름은 생략(기본message), JSON의phase필드로 구분 - 배치 단위 흐름:
register— 응답 헤더 직후 1회.jobId(영구 식별자)를 클라이언트에게 전달한다. 새로고침/다른 탭에서 이 jobId로GET /api/import-url/jobs/{id}/events에 재구독 가능 (§2.6 잡 레지스트리)queued— 1회. 서버가 POST를 받아들였음을 알리는 시그널 — 다른 배치가 진행 중이면 후속start가 지연될 수 있다 (§2.6 배치 직렬화). 세마포어가 비어있으면 acquire 직후 바로start가 이어지므로 클라이언트는 보통 이 이벤트를 보지 못하지만, 큐잉이 발생하면 모든 URL row를 "대기 중 (순서 대기)" 상태로 표시한다.- URL당 이벤트 (아래)
summary— 배치의 성공/실패/취소 합계 1회로 종료
- URL당 이벤트 흐름:
start— 다운로드 시작 (응답 헤더 검증 통과 직후)progress— 다운로드 진행 (0개 이상, throttled, §5.1.1)done또는error— 종료 (URL당 정확히 1개; 취소된 URL은error: "cancelled")
이벤트 스키마
index는 요청urls배열의 0-based 인덱스total은Content-Length값(바이트). 알 수 없으면 생략type∈"image" | "video" | "audio"warnings가능 값:"insecure_http"— HTTP(비암호화) URL 사용"renamed"— 파일명 충돌로 자동 리네임 (최종명은name/path반영)"extension_replaced"— URL 확장자와Content-Type불일치로 확장자 교체 (HLS는 항상.m3u8→.mp4교체이므로 함께 부착)
error가능 값:"invalid_scheme"—http/https외 스킴"invalid_url"— URL 파싱 실패"connect_timeout"— 연결 10초 초과"download_timeout"—url_import_timeout_seconds(§2.7) 초과"too_many_redirects"— 5회 초과"private_network"— loopback/private/link-local/multicast/unspecified IP 대상 차단"tls_error"— TLS 인증서 검증 실패"http_error"— 4xx/5xx 응답"unsupported_content_type"— 허용 Content-Type 목록 밖"too_large"—url_import_max_bytes(§2.7) 초과 —Content-Length사전 검증 또는 런타임 누적 카운터"hls_playlist_too_large"— HLS 플레이리스트 본문이 1 MiB 초과 (master 또는 variant) (§2.6.1)"hls_too_many_segments"— media playlist의#EXTINFsegment 개수가 10,000 초과 (§2.6.1) — 정상 VOD에는 도달 불가, 악의적 폭주 1차 방어"ffmpeg_error"— HLS 리먹싱 중 ffmpeg 프로세스 실패 (non-zero exit, 입력 스트림 문제)"ffmpeg_missing"— ffmpeg 바이너리가 서버 PATH에 없음 (운영자 설치 필요) —ffmpeg_error와 구분됨"network_error"— 기타 네트워크 실패"write_error"— 디스크 저장 실패"cancelled"— 명시적 cancel API 호출 또는 배치 cancel로 중단 (§2.6 취소)
- 4xx 케이스 (요청 자체 거부 — SSE 스트림 시작 전 일반 JSON 에러 응답):
400 {"error": "invalid path"}— path traversal400 {"error": "no urls"}— 빈 배열400 {"error": "too many urls"}— 한 번에 500개 초과404 {"error": "path not found"}— 저장 디렉토리 미존재429 {"error": "too_many_jobs"}— 활성 잡 수가MaxQueuedJobs=100초과 (§2.6 활성 잡 cap)
- 응답:
200 OK,Content-Type: application/json - Body:
{ "active": [/* JobSnapshot, ... */], "finished": [/* JobSnapshot, ... */] }- 두 배열 모두
createdAtasc 정렬
- 두 배열 모두
JobSnapshot:{ "id": "imp_a3f8k2lm", "destPath": "movies/2026", // dataDir-relative slash 경로 "status": "running", // queued | running | completed | failed | cancelled "createdAt": "2026-04-25T12:00:00Z", "urls": [ { "url": "https://...", "name": "foo.mp4", // 알려진 후에만 채워짐 "type": "video", // image | video | audio "status": "running", // pending | running | done | error | cancelled "received": 12345, "total": 67890, // 알 수 없으면 omitempty "warnings": [], "error": "" // status=error/cancelled에서만 채워짐 } ], "summary": { "succeeded": 1, "failed": 0, "cancelled": 0 } // 종료 상태 진입 시에만 }
- 응답:
200 OK,Content-Type: text/event-stream - 첫 프레임: snapshot envelope
{"phase":"snapshot","job": <JobSnapshot>} - 이후 라이브 라이프사이클 이벤트 (
queued/start/progress/done/error/summary).register는 POST 응답 전용이라 여기에 등장하지 않음. - 잡이 이미 종료 상태면 snapshot 1회 후 connection close. 종료 상태로 전이된 활성 잡도 동일 —
SetStatus(terminal)이 subscriber 채널을 close하면 핸들러는summary또는 channel-closed 중 먼저 도달한 쪽을 보고 리턴. - 미존재 ID →
404 {"error":"job not found"}
- 쿼리:
- 미지정 → 잡 전체 cancel
?index=N→ URL N만 cancel (잡 진행은 다음 URL부터 계속)
- 응답:
204 No Content - 동작: §2.6 취소 항목 참고
- 이미 종료된 잡 또는 URL →
409 {"error":"job already finished"}/409 {"error":"url already finished"} - 잘못된 index (비숫자, 음수, ≥ urls 길이) →
400 - 미존재 ID →
404
- 응답:
204 No Content - 활성 잡(
queued/running) →409 {"error":"job_active"}(먼저 cancel 필요) - 종료된 잡 → history에서 제거. SetStatus(terminal) 시점에 subscriber 채널은 이미 close되어 있어 broadcast 불필요.
- 미존재 ID →
404
- 쿼리
status=finished필수 (누락 시400 {"error":"missing status=finished filter"}) — 의도하지 않은 "wipe everything" 방지 - 응답:
200 OK,{"removed": <int>} - 종료된 잡만 일괄 제거. 활성 잡은 영향 없음.
progress이벤트는 수신 바이트 1 MiB마다 또는 250 ms마다 중 먼저 도달하는 시점에 방출 (양쪽 모두 ticker/카운터 기반)- 동일 값으로
received가 변하지 않으면 방출 생략 (중복 제거) - 파일이 작아
progress없이start→done바로 가는 케이스 허용 - 최종 바이트 수는 항상
done이벤트size필드로 전달 (progress의 마지막 값은 신뢰하지 말 것)
TS 파일을 MP4로 영구 변환. SSE 스트림으로 진행 상태 전송. 이벤트 스키마·throttling은 URL import(§5.1, §5.1.1)와 동일 — phase: start / progress / done / error / summary.
- Body:
{ "paths": ["movies/clip1.ts", "movies/clip2.ts"], "delete_original": false }paths: 변환할.ts파일 경로 배열(/data기준 상대). 최소 1개, 최대 500개 (URL import와 동일 상한).delete_original: 변환 성공 시 원본.ts+ 사이드카 삭제 여부(기본false).
- 응답:
200 OK,Content-Type: text/event-stream,Cache-Control: no-cache,X-Accel-Buffering: no
이벤트 스키마
// phase: "start"
{"phase":"start","index":0,"path":"movies/clip1.ts",
"name":"clip1.mp4","total":314572800,"type":"video"}
// phase: "progress"
{"phase":"progress","index":0,"received":67108864}
// phase: "done"
{"phase":"done","index":0,"path":"movies/clip1.mp4",
"name":"clip1.mp4","size":310378496,"type":"video","warnings":[]}
// phase: "error"
{"phase":"error","index":1,"path":"movies/clip2.ts",
"error":"already_exists"}
// phase: "summary"
{"phase":"summary","succeeded":1,"failed":1}start.total은 원본.ts파일 크기(최종 MP4와 근사, 정확치 아님)progress.received는 임시.mp4출력 파일의 현재 바이트 수(ffmpeg remux 중 stat 폴링)done.size는 최종 MP4 파일 크기 (atomic rename 직후Stat)warnings가능 값:"delete_original_failed"—delete_original: true였으나 원본.ts(또는 사이드카) 삭제 실패. 변환 자체는 성공.
error가능 값:"invalid_path"— path traversal 또는/data밖 경로"not_found"— 경로에 파일이 없음"not_a_file"— 경로가 디렉토리"not_ts"— 파일 확장자가.ts가 아님(대소문자 무시)"already_exists"— 목표foo.mp4가 이미 존재"ffmpeg_missing"— ffmpeg 바이너리가 서버 PATH에 없음"ffmpeg_error"— ffmpeg non-zero exit(입력 손상, 비호환 코덱 등)"convert_timeout"— 10분 초과"write_error"— 디스크 저장 실패(디스크 풀 등)"canceled"— 클라이언트 연결 끊김/요청 context 취소
- 4xx 케이스 (SSE 스트림 시작 전 일반 JSON 에러 응답):
400 {"error": "no paths"}— 빈 배열400 {"error": "too many paths"}— 500개 초과400 {"error": "invalid request"}— JSON 파싱 실패405 {"error": "method not allowed"}— POST 외
PNG 파일을 JPG로 영구 변환 (§2.8.2). 동기 JSON 응답 — SSE가 아니다. PNG 변환은 빠르고 (정상 사진 1초 내외), 일괄 500개도 단일 사용자 운용에서 수 분 내 종료되어 progress 스트림이 가치보다 복잡도가 큼. 항목별 결과는 응답 배열에 한꺼번에 포함된다.
-
Body:
{ "paths": ["movies/foo.png", "movies/bar.png"], "delete_original": false }paths: 변환할.png파일 경로 배열(/data기준 상대). 최소 1개, 최대 500개 (URL import / convert와 동일 상한).delete_original: 변환 성공 시 원본.png+ 사이드카(.thumb/{name}.png.jpg) 삭제 여부 (기본false).
-
응답:
200 OK,Content-Type: application/json. 항목별 성공/실패가 섞여도 200 — 항목별 결과 객체로 표현.{ "succeeded": 1, "failed": 1, "results": [ { "index": 0, "path": "movies/foo.png", "output": "movies/foo.jpg", "name": "foo.jpg", "size": 234567, "warnings": [] }, { "index": 1, "path": "movies/bar.png", "error": "already_exists" } ] } -
results[i]: 성공이면output/name/size/warnings채움, 실패면error만 채움 (상호 배타). -
warnings가능 값:"delete_original_failed"—delete_original: true였으나 원본 PNG 또는.thumb/사이드카 삭제 실패. 변환 자체는 성공.
-
error가능 값:"invalid_path"— path traversal 또는/data밖 경로"not_found"— 경로에 파일이 없음"not_a_file"— 경로가 디렉토리"not_png"— 파일 확장자가.png가 아님(대소문자 무시)"already_exists"— 목표foo.jpg가 이미 존재 (자동 suffix 없음)"image_too_large"— 헤더의 width × height가 cap(64M 픽셀, ≈ 8K×8K) 초과 — 메모리 폭주 방어로 디코드 전 거부"decode_failed"— PNG 디코드 실패 (손상/비표준)"encode_failed"— JPEG 인코드 실패"write_failed"— 디스크 저장 실패 (디스크 풀 등)"convert_timeout"— 30초 초과 (정상 PNG에서는 도달 불가능한 안전장치)"canceled"— 클라이언트 연결 끊김/요청 context 취소
-
4xx 케이스 (응답 시작 전 일반 JSON 에러):
400 {"error": "no paths"}— 빈 배열400 {"error": "too many paths"}— 500개 초과400 {"error": "invalid request"}— JSON 파싱 실패405 {"error": "method not allowed"}— POST 외
움짤(GIF 또는 짧은 동영상)을 animated WebP로 영구 변환 (§2.9). SSE 진행 스트림 — 이벤트 스키마·throttling은 /api/convert(§5.1)와 동일 (phase: start / progress / done / error / summary). PNG→JPG의 동기 응답이 아닌 이유: ffmpeg 인코딩이 1초 내외로 끝나지 않으므로 progress UX 가치가 충분.
- Body:
{ "paths": ["clips/foo.mp4", "clips/bar.gif"], "delete_original": false }paths: 변환 대상 경로 배열(/data기준 상대). 최소 1개, 최대 500개 (다른 변환 엔드포인트와 동일 상한).delete_original: 변환 성공 시 원본 +.thumb/{name}.jpg(+ 동영상은.jpg.dur) 삭제 여부 (기본false).
- 응답:
200 OK,Content-Type: text/event-stream,Cache-Control: no-cache,X-Accel-Buffering: no.
이벤트 스키마
// phase: "start"
{"phase":"start","index":0,"path":"clips/foo.mp4",
"name":"foo.webp","total":12345678,"type":"video"}
// phase: "progress"
{"phase":"progress","index":0,"received":4194304}
// phase: "done"
{"phase":"done","index":0,"path":"clips/foo.webp",
"name":"foo.webp","size":3210000,"type":"image",
"warnings":["audio_dropped"]}
// phase: "error"
{"phase":"error","index":1,"path":"clips/long.mp4",
"error":"not_clip"}
// phase: "summary"
{"phase":"summary","succeeded":1,"failed":1}start.total은 원본 입력 크기(최종 WebP와 무관, 진행 비율 계산은received/total로 근사).start.type은 입력 기준이라"video"또는"image"(GIF의 경우).progress.received는 임시.webp출력 파일의 현재 바이트 수(ffmpeg 인코딩 중 stat 폴링).done.size는 최종 WebP 파일 크기 (atomic rename 직후Stat).done.type은 항상"image"(출력이 WebP — 정적·애니메이션 모두 image 타입).warnings가능 값:"audio_dropped"— 입력에 audio stream이 있었으나 결과 WebP에는 포함되지 않음 (의도된 동작, GIF는 audio 검사 생략)."delete_original_failed"—delete_original: true였으나 원본 또는 사이드카 삭제 실패. 변환 자체는 성공.
error가능 값:"invalid_path"— path traversal 또는/data밖 경로"not_found"— 경로에 파일이 없음"not_a_file"— 경로가 디렉토리"unsupported_input"— GIF·동영상이 아닌 입력 (PNG/JPG 정적 이미지, 오디오, 기타)"not_clip"— 동영상인데 50 MiB 또는 30s 초과 (움짤 게이트 미충족)"duration_unknown"— 동영상인데 ffprobe로 duration 확보 실패"already_exists"— 목표<base>.webp가 이미 존재 (자동 suffix 없음)"ffmpeg_missing"— ffmpeg 바이너리가 서버 PATH에 없음"ffmpeg_error"— ffmpeg non-zero exit (입력 손상, libwebp 미지원 등)"convert_timeout"— 5분 초과"write_error"— 디스크 저장 실패 (디스크 풀 등)"canceled"— 클라이언트 연결 끊김/요청 context 취소
- 4xx 케이스 (SSE 스트림 시작 전 일반 JSON 에러 응답):
400 {"error": "no paths"}— 빈 배열400 {"error": "too many paths"}— 500개 초과400 {"error": "invalid request"}— JSON 파싱 실패405 {"error": "method not allowed"}— POST 외
- 성공:
200 OK,Content-Type: application/json - 응답: 현재 메모리 캐시된 설정 값 그대로 (§2.7 형식)
{ "url_import_max_bytes": 10737418240, "url_import_timeout_seconds": 1800, "auto_convert_png_to_jpg": true }
- 요청:
Content-Type: application/json, 위 응답과 동일한 스키마(세 필드 모두 필수) - 성공:
200 OK+ 갱신된 값 반환 (디스크 쓰기 + 메모리 캐시 갱신 후) - 실패:
400 {"error": "invalid request"}— JSON 파싱 실패 / 필드 누락 / 타입 불일치(boolean 자리에 다른 타입 포함)400 {"error": "out_of_range", "field": "url_import_max_bytes"}— 1 MiB ~ 1 TiB 경계 밖400 {"error": "out_of_range", "field": "url_import_timeout_seconds"}— 60 ~ 14400 경계 밖500 {"error": "write_failed"}— settings.json 쓰기 실패(디스크 풀, 권한 등) — 메모리 캐시는 변경하지 않음
- 성공:
200 OK,Content-Type: image/jpeg - 이미지 파일:
imaging라이브러리로 섬네일 생성 (기존) - 동영상 파일 (MP4, MKV, AVI, TS): ffmpeg로 프레임 추출
- 프레임 추출 순서: 50% → (전체 흑/백이면) 25% → 75% → placeholder
- ffmpeg 실패 시 placeholder 반환 (
200 OK, placeholder JPEG)
- 이미지/동영상 외 파일:
400 {"error": "unsupported file type"} - 파일 미존재:
404 - Placeholder:
internal/thumb/placeholder.jpg(빌드 바이너리에 embed)
| 확장자 | MIME 타입 |
|---|---|
.mp4 |
video/mp4 |
.mkv |
video/x-matroska |
.avi |
video/x-msvideo |
.ts |
video/mp2t |
.mp3 |
audio/mpeg |
.flac |
audio/flac |
.aac |
audio/aac |
.ogg |
audio/ogg |
.wav |
audio/wav |
.m4a |
audio/mp4 |
.jpg, .jpeg |
image/jpeg |
.png |
image/png |
.webp |
image/webp |
.gif |
image/gif |
LAN 단일 사용자 모델에는 인증이 없으므로, 다른 오리진의 페이지가 사용자 브라우저를 통해 본 서버로 mutating 요청을 보내는 시나리오는 요청 자체의 진위(authenticity) 로만 막는다. internal/handler/handler.go의 requireSameOrigin 미들웨어가 모든 mutating 라우트(POST·PATCH·DELETE·PUT)에 걸려 있다. GET·HEAD·SSE 구독 (EventSource) 은 통과 — 읽기 전용이며 EventSource는 Origin 헤더를 일관되게 전송하지 않기 때문.
requireSameOrigin으로 래핑되는 mutating 라우트(단일 출처 — CLAUDE.md / README 등은 본 절을 참조):
POST /api/uploadPATCH /api/file,DELETE /api/filePOST /api/folder,PATCH /api/folder,DELETE /api/folderPOST /api/import-url,/api/import-url/jobs,/api/import-url/jobs/(cancel/dismiss; SSE 재구독 GET은 비대상)POST /api/convert,POST /api/convert-image,POST /api/convert-webpPOST /api/download-folder(GET은 읽기로 비대상 — §2.10)POST /api/migrate,/api/migrate/jobs,/api/migrate/jobs/(cancel/dismiss; SSE 재구독 GET은 비대상 — §2.12)PATCH /api/settings
읽기 전용(/api/browse, /api/tree, /api/stream, /api/thumb, GET /api/settings, /api/version)은 래핑 대상 아님. 새 mutating 라우트를 추가하면 본 목록과 Register의 wrap 호출을 같이 갱신한다.
검사 규칙:
Origin헤더가 있으면url.Parse(Origin).Host == r.Host일 때만 허용.Origin헤더가 없으면Sec-Fetch-Site폴백을 allowlist 로 검사:""(curl·서버사이드·pre-2020 브라우저),"same-origin","none"(사용자 직접 입력 URL) → 허용."same-site"(같은 eTLD+1의 다른 서브도메인),"cross-site","cross-origin", 미지의 미래 값 → 거부 (fail-closed).
- 거부 시
403 {"error": "cross_origin"}.
차단되는 시나리오:
- 다른 오리진의 페이지가
<form action="http://server/api/...">또는fetch(...)로 mutating 요청 송출. - 같은 eTLD+1의 다른 서브도메인 페이지(브라우저는
Sec-Fetch-Site: same-site송출).
허용되는 시나리오:
- 같은 오리진의 본 서버 프론트엔드 (
Origin == Host). curl등 헤더를 보내지 않는 도구 (LAN 내부 운영 시나리오 —Origin없음 +Sec-Fetch-Site없음).- 사용자가 주소창에 URL을 직접 입력해 발생한 GET 페이지 로드 (
Sec-Fetch-Site: none).
Note: 본 정책은 오리진의 진위 만 검사하며 IP·네트워크 검증은 하지 않는다. SSRF 정책은 §2.6 "약한 SSRF" 규칙을 따른다.
# docker-compose.yml 개요
services:
server:
build: .
ports:
- "8080:8080"
volumes:
- media:/data
volumes:
media:- 미디어 파일:
/data/media/ - 섬네일:
/data/media/**/.thumb/ - 다운로드 설정:
/data/.config/settings.json - 실시간 remux 캐시:
/data/.cache/streams/
gofmt+golangci-lint준수- 패키지별 단일 책임 원칙
- 에러는 반환하여 핸들러에서 HTTP 상태 코드로 변환
- 주석은 WHY가 비자명한 경우에만 작성
- 단위 테스트: 섬네일 생성, MIME 타입 감지, Range 파싱
- 단위 테스트 (동영상 섬네일):
thumb.IsBlankFrame함수 — 전체 흑/백 판정 로직 - 단위 테스트 (duration): 사이드카 read/write round-trip,
formatDurationJS 함수 (4:32,1:02:09, 0/null 케이스) - 단위 테스트 (rename): 이름 검증 (확장자 strip 로직, 빈 문자열,
/·\\,./.., 길이 초과), 확장자 재부착 로직 - 단위 테스트 (URL import):
- 파일명 추출/sanitize (확장자 있음/없음,
../컨트롤 문자, 빈 이름 — image/video/audio 기본값 각각) - 확장자 결정 (URL 우선 → Content-Type 폴백 → 충돌 시 Content-Type 우선, video/audio 확장자 포함)
- 충돌 자동 리네임 (
foo.mp4→foo_1.mp4, race 시 재시도) - URL 스킴 검증 (
http/https만 통과) - Content-Type 허용 목록 검증 (image/video/audio 세 카테고리 각 포맷 + 거부 케이스)
- 현재 설정(
url_import_max_bytes) 초과 Content-Length 사전 거부 — 테스트는 작은 값(예: 1 KiB)으로 cap 주입 후 검증 Content-Length헤더 부재 + 본문이 cap 이내 → 정상 완료Content-Length헤더 부재 + 본문이 cap 초과 → 런타임 카운터가too_large반환 + 임시 파일 정리- Progress counter throttling 로직 (1 MiB/250ms 경계, 중복 제거)
- 파일명 추출/sanitize (확장자 있음/없음,
- 통합 테스트: HTTP 핸들러 (
net/http/httptest사용) - 통합 테스트 (동영상 섬네일): ffmpeg 없는 환경에서 placeholder 반환 확인
- 통합 테스트 (duration): browse 응답에 동영상 entry의
duration_sec포함 확인 (사이드카 있을 때 / 없을 때) - 통합 테스트 (rename):
PATCH /api/file성공 시 파일 +.thumb/{name}.jpg+.thumb/{name}.jpg.dur모두 신규 이름으로 이동 확인- 사이드카가 일부만 있을 때(
.jpg만 있고.dur없음) 에러 없이 200 반환 - 확장자 포함 입력(
new.mp4)이 원본 확장자(.mkv)를 덮어쓰지 않음 확인 - 409 Conflict: 동일 디렉토리 내 기존 파일명으로 rename 시도
- 400 name unchanged: 새 이름이 기존 이름과 동일할 때
PATCH /api/folder(rename) 성공 시 하위 내용(.thumb/포함)이 새 경로에 그대로 존재 확인- Path traversal 방지 (
name에/·\\포함 시 400)
- 단위 테스트 (
media.MoveDir— 신규):- 정상 이동:
srcDir이destDir/<basename>으로 옮겨지고 하위 파일·.thumb/가 모두 따라감 - 충돌:
destDir에 동일 base name이 이미 존재(파일이든 폴더든) →ErrDestExists - 자기 자신 이동:
destDir == srcDir→ErrCircular - 자기 자손 이동:
destDir이srcDir의 자손 →ErrCircular - prefix 가짜양성 방지:
/a/bc로 이동 시/a/b의 자손으로 오판하지 않음 - cross-volume 시뮬레이션(EXDEV 모킹) →
ErrCrossDevice(재귀 copy 폴백 없음 확인)
- 정상 이동:
- 통합 테스트 (
PATCH /api/folder이동 분기):- 정상 이동 → 200 +
{path, name}. 새 위치에 파일/.thumb/하위폴더 모두 존재 확인 - body가
{name, to}동시 → 400specify either name or to, not both - body가
{}→ 400missing name or to - 루트 이동 시도 (path=
/또는 빈 문자열) → 400cannot move root - destDir 미존재/파일 가리킴 → 400
invalid destination - 자기 자손 destDir → 400
invalid destination - 동일 부모 destDir → 400
same directory - 충돌 → 409
already exists - traversal (
to에..등) → 400invalid path
- 정상 이동 → 200 +
- 통합 테스트 (
DELETE /api/folderUI 진입점, 이미 백엔드 통과):- 사이드바 트리 노드의 🗑 클릭 →
confirm()accept →DELETE /api/folder→ 트리·browse 재조회 (수동/E2E)
- 사이드바 트리 노드의 🗑 클릭 →
- 수동 테스트 (DnD 폴더 이동):
- 사이드바 트리 노드 A → 사이드바 트리 노드 B 위로 드래그 → 이동 후 B 아래 A 표시, A의 currentPath이면 자동 navigate
- 사이드바 트리 노드 → breadcrumb 다른 경로 위로 드래그 → 동일 동작
- 메인 리스트 표의 폴더 행 → 사이드바 트리 노드 위로 드래그 → 동작 확인
- 자기 자손 destDir로 드래그 시 drop 거부 시각 피드백 (
dropEffect: 'none')
- 수동 테스트 (새 폴더 버튼 위치):
- 사이드바 헤더의 "새 폴더" 클릭 → currentPath 기준 생성 모달 → 성공 시 트리 + 메인 리스트 동시 갱신
- 메인 툴바에서 기존 "새 폴더" 버튼이 사라졌는지 확인
- 통합 테스트 (URL import):
httptest.Server로 모의 origin 띄워서 검증 (SSE 응답 파싱)- 정상 이미지 다운로드 →
start→done이벤트, 파일 저장 확인 - 정상 MP4 동영상 다운로드 →
type: "video"반환 +.thumb/생성 - 정상 MP3 음악 다운로드 →
type: "audio"반환 + 섬네일 생성 안 함 Content-Length누락 + 본문이 cap 이내 → 정상done- Content-Length가 설정 cap + 1 인 응답 →
error: "too_large"(사전 거부) - Content-Length 누락 + 본문이 설정 cap 초과 →
error: "too_large"(런타임) + 임시 파일 정리 확인 Content-Type: text/html→error: "unsupported_content_type"이벤트- HTTP URL →
done+warnings: ["insecure_http"] - private network URL(
127.0.0.1,192.168.0.0/16등) →error: "private_network" - URL 확장자와 Content-Type 불일치 → 확장자 교체 +
warnings: ["extension_replaced"] - 부분 실패: 3개 URL 혼합 → 각 URL당
done/error1개씩 +summary1개 - 리다이렉트 6회 →
error: "too_many_redirects" - 큰 파일(>1 MiB) →
progress이벤트 ≥1개 포함, 각 이벤트의received단조 증가
- 정상 이미지 다운로드 →
- 단위 테스트 (HLS 플레이리스트 파서):
- Master playlist에서 최고
BANDWIDTHvariant 선택 (동률 시 선언 순서) BANDWIDTH누락 variant는 후순위 처리- variant URL 상대 경로 resolve (master URL base 기준)
#EXT-X-STREAM-INF없는 media playlist는 원본 URL 반환- 1 MiB 초과 본문 → 파싱 단계에서 거부 (
hls_playlist_too_large)
- Master playlist에서 최고
- 통합 테스트 (HLS import): 모의 origin에 master.m3u8 + media.m3u8 + 세그먼트(.ts) 고정 파일 준비
- 표준 Content-Type(
application/vnd.apple.mpegurl) + master playlist → 최고 비트레이트 variant의 세그먼트가 선택되어 MP4로 저장 확인 (파일 존재 + ffprobe로 "video/mp4" 확인 가능한 환경에서만) - Content-Type
text/plain+ URL.m3u8폴백 → 정상 HLS 분기 진입 확인 start이벤트에total필드 부재 확인 (json.Marshal시omitempty동작)progress.received가 임시 파일 크기 기반으로 단조 증가 확인- ffmpeg 종료 코드 non-zero 시뮬레이션 →
error: "ffmpeg_error"+ 임시 파일 정리 확인 - ffmpeg 미설치 환경: skip 또는 fake binary 주입으로 테스트 (CI 정책에 따름)
- 비 http/https variant URL(예:
file:///etc/passwd를 담은 master playlist) → 파싱 단계에서 거부 (error) — ffmpeg까지 내려가지 않음
- 표준 Content-Type(
- 수동 테스트: 브라우저에서 업로드→섬네일→스트리밍 전체 흐름 확인 (썸네일 우하단 시간 오버레이 확인). Rename 후 썸네일·duration 오버레이가 유지되는지 확인.
- 수동 테스트 (URL import): 모달에서 URL 여러 개(이미지/동영상/음악 섞어) 입력 → URL별 프로그래스 바 실시간 진행 확인 → 완료 후 성공/실패 카운트 요약 확인
- 단위 테스트 (settings §2.7):
- JSON read/write round-trip (atomic write temp+rename 검증)
- 파일 부재 → 기본값 반환(디스크 쓰기 없음)
- 파일 손상(JSON 파싱 실패) → 경고 로그 + 기본값 반환
- 경계 위반 값이 디스크에 존재 → 경고 로그 + 기본값 반환
- PATCH 경계 검증: max_bytes
0,1048575(1 MiB-1),1099511627777(1 TiB+1) →out_of_range; timeout59,14401→out_of_range - PATCH 성공 시 메모리 캐시가 즉시 갱신되어 다음 URL import에 반영
- 통합 테스트 (settings):
GET /api/settings→ 기본값 반환,PATCH /api/settings로 cap 축소 후 같은 핸들러에 URL import 요청 → 새 cap 적용된too_large관측 - 수동 테스트 (settings): 헤더 ⚙ → 모달 열기, 크기/타임아웃 편집, GiB helper text 확인, 저장 후 재로드 시 값 유지, 범위 밖 입력 시 에러 메시지 표시
- 단위 테스트 (TS→MP4 변환):
- 경로/확장자 검증:
.ts외 확장자 거부(not_ts), 대소문자(.TS,.Ts) 허용, 디렉토리 경로 거부, path traversal 거부 - 목표 파일명 계산:
foo.ts→foo.mp4,foo.TS→foo.mp4(소문자 확장자 고정) - 충돌 감지:
foo.mp4사전 존재 시already_exists반환(ffmpeg 호출 전)
- 경로/확장자 검증:
- 통합 테스트 (TS→MP4 변환,
httptest.NewRecorder+ 실제 ffmpeg):- 정상 TS 1개 변환 →
start→progress(≥0개) →done→summary이벤트,.mp4파일 생성 + 원본.ts유지 확인 delete_original: true→ 변환 성공 후 원본.ts+.thumb/{name}.ts.jpg+.ts.jpg.dur삭제 확인- 배열 2개 순차 변환 → index 0 → index 1 순서, 각각
done1개씩, 마지막summary에succeeded: 2 - 409 충돌: 목표
foo.mp4사전 존재 시error: "already_exists"이벤트 + 임시 파일 미생성 확인 - 부분 실패: 2개 중 1개는
.ts아님 → 해당 index만error: "not_ts", 나머지는 정상done - 취소: 변환 중 context 취소 → ffmpeg kill + 임시 파일
.convert-*.mp4정리 확인 - ffmpeg 미설치 환경:
ffmpeg_missing이벤트(PATH lookup 실패) - 손상 TS(헤더 truncate 등):
ffmpeg_error+ stderr는 SSE 응답에 노출되지 않음(서버 로그에만) delete_original_failed경고: 원본.ts를 read-only로 설정 후delete_original: true→done.warnings: ["delete_original_failed"]- 4xx: 빈 배열 →
400 "no paths", 501개 →400 "too many paths", 유효하지 않은 JSON →400 "invalid request"
- 정상 TS 1개 변환 →
- 수동 테스트: TS 동영상 카드에 변환 버튼 → 변환 모달 → 진행 바 → 완료 후
.mp4로 재생(seek 동작 확인). 폴더에 TS 3개 있을 때 "모든 TS 변환" 버튼 → 순차 변환 → 성공/실패 요약 확인. - 단위 테스트 (settings §2.7) —
auto_convert_png_to_jpg추가:- PATCH
auto_convert_png_to_jpg토글:true→false,false→true모두 디스크 + 메모리 캐시 동기 반영 - boolean 자리에 string/number 등 잘못된 타입 →
invalid request - 디스크 settings.json에
auto_convert_png_to_jpg키 누락 → 기본값true폴백
- PATCH
- 단위 테스트 (PNG → JPG 변환,
internal/imageconv):- 정상 변환: 알파 없는 RGB PNG → 디코드 가능한 JPEG 생성,
imaging라이브러리로 다시 디코드해 dimensions 일치 확인 - 알파 채널 합성: RGBA PNG (반투명/완전투명 픽셀 포함) → 흰 배경 합성 후 JPEG 인코드, 알파였던 픽셀 위치가 흰색에 가깝게 합성됐는지 샘플 검사
- 경로 검증: src 미존재 →
os.ErrNotExist계열 오류 전파, src가 디렉토리 → 명확한 오류 - 손상 PNG (truncated / 잘못된 magic) → decode 단계에서 오류 (handler에서
decode_failed로 매핑) - atomic write:
os.CreateTemp→os.Rename패턴 검증, ConvertPNGToJPG 중간 실패 시 임시 파일이 destDir에 남지 않음 - 출력 확장자 정규화: 입력이
.PNG/.Png이어도 ConvertPNGToJPG는destPath를 그대로 사용 (handler가 소문자.jpg를 결정)
- 정상 변환: 알파 없는 RGB PNG → 디코드 가능한 JPEG 생성,
- 통합 테스트 (
POST /api/convert-image,httptest.NewRecorder):- 정상 PNG 1개 변환 →
200,succeeded:1,.jpg파일 생성 + 원본.png유지 확인 (delete_original:false) delete_original:true→ 변환 성공 후 원본.png+.thumb/{name}.png.jpg삭제 확인- 배열 2개 변환 →
results배열 길이 2, 각각 index 0 / 1, 두.jpg모두 디스크에 존재 - 충돌: 목표
foo.jpg사전 존재 → 해당 항목error: "already_exists"+ 임시 파일 미생성 + 원본.png무영향 - 부분 실패: 2개 중 1개는
.png아님 (예:.txt) → 해당 index만error: "not_png", 나머지는 정상done delete_original_failed경고: 원본 PNG 또는 사이드카를 read-only 디렉토리에 두고delete_original:true→ 결과 항목의warnings: ["delete_original_failed"], 변환 자체는 성공- 4xx: 빈 배열 →
400 "no paths", 501개 →400 "too many paths", 잘못된 JSON →400 "invalid request", GET →405 "method not allowed" - traversal:
paths: ["../../etc/passwd"]→ 항목error: "invalid_path" - 손상 PNG: 헤더 truncate된 PNG → 항목
error: "decode_failed", 임시 파일 정리 확인
- 정상 PNG 1개 변환 →
- 통합 테스트 (
POST /api/upload자동 변환, §2.8.1):- settings
auto_convert_png_to_jpg:true상태에서 PNG 업로드 → 응답name: "*.jpg",converted:true,warnings:[], 디스크에.jpg만 존재 (원본.png미저장,.pngconvert-*임시 파일도 정리됨) - settings
false상태에서 PNG 업로드 → 원본.png그대로 저장,converted:false,warnings:[] - 변환 실패 폴백: decode 실패하는 손상 PNG 업로드 → 원본 PNG 저장 +
warnings: ["convert_failed"]+converted:false, 응답 코드는 여전히201 - 충돌 자동 suffix:
foo.jpg사전 존재 +foo.png업로드(자동 변환 ON) → 결과 파일명foo_1.jpg+warnings: ["renamed"]+converted:true - 비-PNG 업로드(JPG/MP4 등)는 자동 변환 영향 없음 —
converted:false,warnings:[], 기존 동작 그대로 - 알파 채널 RGBA PNG 업로드 →
.jpg생성 + 알파 위치가 흰색으로 합성됐는지 샘플 검사 (다시 디코드)
- settings
- 수동 테스트 (PNG → JPG 변환):
- 이미지 카드 "JPG로 변환" 버튼 → 모달 확인 → 성공 후
loadBrowse()로 카드가.jpg로 갱신됨 확인 - 폴더에 PNG 3개 + 다른 파일 → "모든 PNG 변환 (3)" 버튼 → 한 번의 요청 → 결과 알림(성공/실패 카운트) 표시
- PNG 5개 중 2개 + 비-PNG 1개를 체크박스 선택 → 툴바 버튼이 "선택 PNG 변환 (2)"로 즉시 전환 → 모달 파일 목록에 PNG 2개만 노출 → 변환 후 두 파일만
.jpg로 갱신, 나머지(선택 안 한 PNG 3개 + 비-PNG 1개) 무영향 - settings 모달의 "PNG 자동 변환" 체크박스 OFF → PNG 업로드 시 원본 PNG 그대로 표시되는지 확인, 토글 다시 ON → 다음 PNG 업로드부터
.jpg로 저장되는지 확인 - 알파 PNG 자동 변환 결과를 다른 뷰어에서 열어 흰 배경 합성 확인
- 이미지 카드 "JPG로 변환" 버튼 → 모달 확인 → 성공 후
- 단위 테스트 (움짤 → WebP 변환,
internal/convert.EncodeWebP):- 정상 동영상 변환: MP4 5초 → WebP 생성, ffprobe로 결과 검증 (codec=webp, frame count > 1)
- GIF 입력 → WebP 생성 (loop / animation 보존)
- audio stream 있는 입력의 결과는 audio 미포함 (
ffprobe -select_streams a비어 있음) - 손상 입력 →
FFmpegExitError로 분류, 임시 파일 미잔존 - context cancel 시 ffmpeg kill + 임시 파일
.webpconvert-*.webp정리 확인 - 출력 확장자 정규화: 입력
.MP4/.GIF이어도 결과는 소문자.webp(handler 책임이지만EncodeWebP는 destPath 그대로 받음 — 단위 테스트는 명시적 lowercase 경로 전달)
- 통합 테스트 (
POST /api/convert-webp,httptest.NewRecorder+ 실제 ffmpeg):- 정상 1개 변환 →
start→progress(≥0개) →done→summary이벤트,.webp파일 생성 + 원본 유지 - audio 있는 mp4 →
done.warnings: ["audio_dropped"], 결과 webp는 무음 - GIF 입력 → 크기·길이 무관하게 통과 (예: 5초/5MiB GIF, 1초/100KB GIF 모두 변환 성공)
delete_original: true→ 원본 +.thumb/{name}.jpg(+.dur) 삭제 확인- 35초 mp4 →
error: "not_clip" - 60 MiB mp4 →
error: "not_clip" - duration 미상 mp4 (ffprobe가 duration 추출 실패하는 입력) →
error: "duration_unknown" - PNG 입력 →
error: "unsupported_input" - 충돌: 목표
foo.webp사전 존재 →error: "already_exists"+ 임시 파일 미생성 + 원본 무영향 - 부분 실패: 2개 중 1개는 not_clip → 해당 index만
error, 나머지는 정상done,summary.succeeded: 1, failed: 1 - 취소: 변환 중 context cancel → ffmpeg kill + 임시 파일 정리 + 후속 항목에
canceled폴백 - ffmpeg 미설치 환경:
error: "ffmpeg_missing" delete_original_failed경고: 원본을 read-only 디렉토리에 두고delete_original: true→done.warnings: ["delete_original_failed"], 변환 자체는 성공- 4xx: 빈 배열 →
400 "no paths", 501개 →400 "too many paths", 잘못된 JSON →400 "invalid request", GET →405 "method not allowed" - traversal:
paths: ["../../etc/passwd"]→ 항목error: "invalid_path" - same-origin:
Origin: evil.example→ 403 (다른 변환 엔드포인트와 동일 게이트)
- 정상 1개 변환 →
- 수동 테스트 (움짤 → WebP 변환):
- 움짤 탭 진입 → "모든 움짤 WebP로 변환 (M개)" 버튼 → 진행 모달 → 완료 후
.webp카드로 갱신 + 자동재생 확인 - 동영상 카드(짧은 mp4) "WebP로 변환" 버튼 → 단건 변환 → 결과 카드 자동재생 확인
- GIF 카드 "WebP로 변환" 버튼 → 변환 → 결과 WebP가 GIF와 동일한 루프 재생
- 음성 있는 짧은 mp4 변환 → 결과 webp 무음 + UI 결과 행에
audio_dropped라벨 표시 - 30s 초과 동영상에 콘솔에서 직접
fetch('/api/convert-webp', ...)호출 →not_clip거부 (UI 게이트 우회 시 서버 방어 검증) - 다른 탭(전체/이미지/동영상)에서는 일괄 버튼이 보이지 않음 확인
- 움짤 5개 중 2개 선택 + 비-움짤 1개 선택 → 툴바 버튼이 "선택 움짤 WebP로 변환 (2)"로 전환 → 변환 후 선택한 2개만
.webp로 갱신
- 움짤 탭 진입 → "모든 움짤 WebP로 변환 (M개)" 버튼 → 진행 모달 → 완료 후
항상 할 것 (Always)
- Range 요청 지원 (스트리밍 seek 필수)
- 업로드 파일은
/data볼륨 내부에만 저장 (path traversal 방지) media.SafePath는 두 단계로 검증한다: (1) lexicalfilepath.Join+ root prefix 검사, (2) 가장 깊은 존재 조상의filepath.EvalSymlinks결과가 root의 resolved 경로 아래에 있는지 추가 확인 — root 안에 root 외부를 가리키는 symlink가 심어져도 차단(defense-in-depth). 미존재 target(upload·mkdir 대상)도 부모로 walk-up하여 검증.- Mutating 진입부에서
http.MaxBytesReader로 streaming/메모리 적재 cap 적용 — multipart 업로드 100 GiB(maxUploadBytes), JSON body 64 KiB(maxJSONBodyBytes). 초과 시413 {"error": "too_large"}반환 (internal/handler/limits.go, §5 각 엔드포인트 4xx 표 참고) - 섬네일은 비동기로 생성 (업로드 응답 차단 안 함)
- Rename 시
media.SafePath로 원본·대상 경로 모두 검증 (path traversal 방지) - Rename은 동일 부모 디렉토리 내에서만 허용 (경로 이동 금지 — 이동은 별도 PATCH body)
- File rename은
os.Link+os.Remove로 atomic EEXIST 보장 (TOCTOU 방지) - 폴더 이동 (§2.1.2): 원본·대상 모두
media.SafePath로 검증, 자기 자신 또는 자손으로의 이동을filepath.Clean+ path-separator 경계 검사로 거부, 동일 부모는 거부, 대상 충돌은 자동 suffix 없이 409 반환, 단일os.Rename으로 폴더 +.thumb/+ 하위 모두 원자 이동, EXDEV는 재귀 copy 폴백 없이 500 - URL import: HTTPS TLS 인증서 검증, 요청 시작 시점에 설정 스냅샷(§2.7)을 찍어 사용,
Content-Length있으면 사전 검증 + 런타임 누적 카운터로 이중 방어(설정값url_import_max_bytes초과 시 중단), Content-Type 허용 목록(image/video/audio) 검증, 임시 파일 → atomic rename, 파일명 sanitize, SSECache-Control: no-cache및 즉시 Flush - HLS import: ffmpeg는 항상 검증된 local 파일만 입력으로 받는다 — master/variant playlist 본문, segment, key, init segment를 모두 Go 보호 클라이언트(
publicOnlyDialContext)가 사전 다운로드한 뒤 임시 디렉터리(<destDir>/.urlimport-hls-<random>/)에 두고 URI를 local 상대 경로로 재작성한 playlist를 ffmpeg에 전달, ffmpeg-protocol_whitelist "file,crypto"강제(네트워크 protocol 모두 차단 — DNS rebinding 우회 차단의 핵심),-allowed_extensions ALL은 local 파일 입력에만 영향, master playlist의 variant URL 스킴도http/https만 허용(이중 검증), variant가 master 자기자신으로 resolve되면 media playlist로 fallback(loop 방지), media playlist segment 개수 cap 10,000(hls_too_many_segments), key 64 KiB / init 16 MiB per-resource cap, 누적 cap은url_import_max_bytes(§2.7) 단일 카운터를 segment 다운로드와 ffmpeg 출력이 공유, ffmpeg 프로세스는 ctx cancel로 종료(외부 cancel·timeout·size cap 모두 동일 경로), 임시 디렉터리는defer os.RemoveAll로 모든 경로(성공/실패/취소/패닉)에서 cleanup, 출력 MP4는 기존renameUnique경로로 atomic rename, 실패 시 stderr는 서버 로그에만 기록(SSE 클라이언트로는 안전한 code만 노출) - Settings: PATCH 시 두 필드 모두 경계 검증 후 atomic write (temp + rename), 저장 실패 시 메모리 캐시는 변경하지 않음 (디스크-메모리 drift 방지), 진행 중인 URL 요청은 시작 시점 스냅샷 값을 끝까지 유지 (race-free)
- TS→MP4 변환: 입력·출력 경로 모두
media.SafePath로 검증, 확장자.ts화이트리스트 검증(대소문자 무시), 목표.mp4사전 존재 시 ffmpeg 호출 전 거부, ffmpeg argv 전달(쉘 미개입), 임시 파일.convert-*.mp4→ atomic rename, context 취소·타임아웃 시 ffmpeg kill + 임시 파일 정리, stderr는 서버 로그에만 기록(SSE에는ffmpeg_error코드만), 동일 소스 경로에 대한 동시 요청은stream.go의 per-path 뮤텍스와 동일 패턴으로 직렬화 - PNG→JPG 변환: 입력·출력 경로 모두
media.SafePath로 검증, 입력 확장자.png화이트리스트(대소문자 무시), 출력 확장자는 항상 소문자.jpg, 알파 채널은 흰색 배경에 합성(JPEG 구조적 한계 처리), JPEG quality 90 고정, 임시 파일(.pngconvert-*.png/.jpg,.imageconv-*) → atomic rename, 자동 업로드 변환은 settings 스냅샷을 요청 시작 시점에 고정(토글 race-free), 자동 변환 실패 시 원본 PNG로 폴백 저장(업로드 성공 유지 +convert_failedwarning), 수동 변환은 목표.jpg사전 존재 시 거부(자동 suffix 없음), 디코드/인코드 실패는 코드 오류로만 노출(스택 트레이스나 내부 경로 비공개) - 움짤→WebP 변환(§2.9): 입력·출력 경로 모두
media.SafePath로 검증, 입력 자격 서버 재검증(GIF 무조건 통과 / 동영상은≤50 MiBANDduration ≤30s), duration은thumb캐시 우선·없으면BackfillDuration1회로 확보, duration 미상이면duration_unknown거부, 출력 확장자 항상 소문자.webp, 충돌 시already_exists거부(자동 suffix 없음), 임시 파일.webpconvert-*.webp→ atomic rename, ffmpeg argv 전달(쉘 미개입), audio stream 검출 시audio_droppedwarning 표기(GIF는 audio 검사 생략), per-path 직렬화(webpLockssync.Map), 파일당 5분 타임아웃, context cancel·timeout 시 ffmpeg kill + 임시 파일 정리, stderr는 서버 로그에만(SSE에는ffmpeg_error코드만), 변경 핸들러로서requireSameOriginwrap
하지 않을 것 (Never)
- TS 이외 포맷 트랜스코딩 (MP4/MKV/AVI는 원본 그대로 스트리밍)
- 사용자 인증/권한 관리
- 외부 CDN이나 클라우드 스토리지 연동
- Rename 시 확장자 변경 허용 (MIME/타입 감지 일관성 유지)
- Rename 시 자동 suffix 부여 (
_1,_2등) — 충돌은 항상 409로 거부 - 폴더 이동 시 자동 suffix 부여 — 충돌은 항상 409로 거부 (rename과 일관)
- 폴더 이동 시 cross-volume 재귀 copy 폴백 — EXDEV는 500으로 즉시 거부 (단일 데이터 볼륨 전제)
- 폴더 이동 시 동시에 이름 변경 —
{"to"}와{"name"}body 동시 지정은 400 (한 호출에 하나의 의도) - 다중 폴더 이동 — 폴더는 multi-select 대상이 아니며, DnD payload는 항상 단건 폴더만 운반
- URL import:
Authorization/쿠키 등 인증 헤더 자동 첨부,http/https외 스킴 허용, 설정값url_import_max_bytes초과 다운로드, 허용 목록 밖 Content-Type 저장, 동시 다운로드(batch는 순차 처리) - Settings: 인증/권한 검사(single-tenant 전제), 경계 밖 값 저장, 진행 중인 요청에 새 값 반영(스냅샷 정책), 설정을 핸들러별로 분기(URL import와 HLS는 반드시 동일 값 공유)
- HLS import: 재인코딩(
-c copy로 리먹싱만, CPU 폭주 방지), DASH(.mpd) 지원, 원본.m3u8+.ts세그먼트를 그대로 저장, DRM/암호화 세그먼트 우회 시도, live stream 특별 처리(엔드리스 스트림은 공통 timeout/size 상한으로만 차단) - TS→MP4 변환: 재인코딩 폴백(remux 실패는
ffmpeg_error반환),.ts외 확장자 변환(범위 외), 다른 포맷 간 변환(MKV↔MP4 등), 원본.ts덮어쓰기(목표.mp4충돌 시 항상 409 — 자동 suffix 없음), 동시 ffmpeg 프로세스 실행(배열은 순차 처리), 변환 큐 영속화(서버 재시작 시 진행 중 변환 폐기) - PNG→JPG 변환: PNG 외 입력 포맷 변환(BMP/TIFF/WEBP/HEIC 등 — 범위 외), JPG 외 출력 포맷(AVIF — 범위 외; 정적 PNG의 WEBP 변환은 본 절 범위 외), JPEG quality 사용자 조절(90 고정), 알파 채널 보존(흰 배경 합성 강제 — 알파가 필요한 케이스는 변환 회피해야 함), EXIF/메타데이터 보존, 수동 변환의 자동 suffix(
_1/_2— 충돌은 항상 409 로 거부), URL import(§2.6) 결과의 자동 변환(다운로드와 변환 의도를 분리 — 수동 변환으로만), 자동 변환 시 원본 PNG도 별도 저장(변환 성공 시 원본은 디스크에 남기지 않음 — 사용자가 원본을 원하면 자동 변환을 OFF), SSE/progress 스트림(동기 응답으로 단순화), 동시 변환(배열은 항상 순차 처리) - 움짤→WebP 변환(§2.9): 움짤 게이트 미충족 입력의 강제 변환(자격 미달은 항상
not_clip/duration_unknown거부 — 50MiB/30s 상한은 정책), audio 보존(WebP는 무음 포맷이라 항상 drop +audio_droppedwarning), 인코딩 파라미터 사용자 조절(quality 80 / fps·해상도 원본 / loop 무한 /compression_level 4모두 고정), 무손실 webp 모드, 자동 업로드 변환(다운로드/업로드 의도와 분리 — 수동만 제공), WebP 외 다른 애니메이션 출력 포맷(AVIF·HEIF·GIF 역변환 — 범위 외), 충돌 시 자동 suffix(_1/_2— 항상already_exists거부), 동시 ffmpeg 프로세스 실행(배열은 순차 처리), 변환 큐 영속화(서버 재시작 시 진행 중 변환 폐기 — TS→MP4와 동일)
Known limitations
- Folder rename은
os.Stat+os.Rename순서로, 두 콜 사이에 동일 이름 폴더가 생성되면 race 발생 가능. 단일 사용자 배포 대상이므로 acceptable. - Folder move도 같은 stat-then-rename 패턴이며 동일 race 가정 — 단일 사용자 배포 대상이므로 acceptable.
- 폴더 이동은 단일 볼륨 가정 (Docker named volume 1개). cross-volume 마운트(예:
/data/external별도 mount)에서는 EXDEV가 발생하며 0.0.1에서는 거부한다. 필요하면 후속 버전에서 재귀 copy+remove 폴백 도입. - HLS live stream은 설정값
url_import_timeout_seconds(§2.7, 기본 30분) 또는url_import_max_bytes(기본 10 GiB) 시점에 강제 종료 — 긴 live 컨텐츠는 끝까지 기록되지 않는다. 명시적 live 감지·분기는 없음. 필요하면 UI에서 값을 키워 재시도 가능. - HLS 다운로드는
start이벤트에total이 없어 클라이언트 프로그래스 바는 indeterminate(수치 없이 애니메이션) 표시가 필요. 기존 UI가total없음을 허용하는지 §2.5 모달 구현 시 확인. - HLS 임시 파일 TOCTOU: ffmpeg가 출력 MP4를 임시 디렉터리(
<destDir>/.urlimport-hls-<random>/output.mp4) 안에 작성하므로 임시 파일 자체에 대한 별도 TOCTOU 창은 없다. atomic rename은 임시 디렉터리 → destDir로 단방향이고, 임시 디렉터리의 random suffix가 충돌 가능성을 사실상 0에 수렴시킨다. - HLS 사전 다운로드 비용: 모든 segment/key/init을 ffmpeg 호출 전에 Go가 먼저 받아오므로 매우 큰 VOD(수만 segment)에선 첫 progress 이벤트까지의 지연이 길어진다. UX 측면에서 progress bar는 indeterminate 상태로 시작하여 점진적 단조 증가로 전환된다. 정상 사용 범위에선 무시할 만한 차이.
첫 공식 릴리즈. 폴더 운영(생성·이름 변경·삭제·이동)이 모두 갖춰져 single-user 미디어 서버로서 기본 기능이 닫힌다.
포함 (모두 머지 완료 — 0.0.1 릴리즈됨, README §0.0.1 릴리즈 노트 참고):
- 폴더 이동 백엔드 (§2.1.2 —
media.MoveDir신설,PATCH /api/folderbody 분기) - 폴더 이동 UI (§2.1.2, §2.1.3 — 사이드바 트리 ↔ 트리 / 메인 표 폴더 행 → 트리·breadcrumb DnD)
- 사이드바 트리 노드 🗑 삭제 버튼 (§2.1.3)
- 새 폴더 버튼 위치 이동 (메인 툴바 → 사이드바 헤더, §2.1.3)
- README 갱신 — 본 SPEC §2.1.2 / §2.1.3을 README features 목록에 반영, 0.0.1 릴리즈 노트 추가, 기존 폴더 작업 설명 업데이트
범위 외 (후속 버전):
- 사이드바 트리 노드별 + 버튼으로 임의 위치 폴더 생성
- 컨텍스트 메뉴 (우클릭) UI
- 다중 폴더 선택 이동
- cross-volume 폴더 이동 (EXDEV 재귀 copy 폴백)
/api/version엔드포인트, GitHub release 자동화- WebDAV / 멀티 사용자 / 인증