feat(stream): canonical folder-path video file URL

영상 파일 스트림 정규 주소를 재생 페이지와 동일한 폴더 경로 규칙으로 변경:
  /api/video/:topName[/:subName]/:videoId
경로의 폴더 부분이 영상 위치와 일치할 때만 200, 불일치 시 404.
재생 페이지(/video/...)와 ID 기반 레거시 스트림(/file/video/:id,
/api/video/:id/file)은 그대로 유지(외부 공유 호환).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Claude (owner)
2026-06-02 01:07:20 +09:00
parent 34c040c15d
commit 7105349a45
5 changed files with 56 additions and 16 deletions

View File

@@ -57,7 +57,8 @@ data/
- `/folder/:topName` — 1단계 폴더 (하위 폴더 + 영상 혼합 표시)
- `/folder/:topName/:subName` — 2단계 폴더 (영상만 표시, 더 깊이 들어갈 수 없음)
- `/video/:topName/:videoId` 또는 `/video/:topName/:subName/:videoId` — 외부 공유용 짧은 URL. 경로의 폴더 부분이 영상 위치와 일치할 때만 200.
- `/file/video/:videoId` — 실제 파일 스트림 (편집본 있으면 편집본). `?edited=0` 으로 원본 강제.
- `/api/video/:topName[/:subName]/:videoId` — 실제 파일 스트림 (정규). 재생 페이지와 동일한 폴더 경로 규칙으로, 경로가 영상 위치와 일치할 때만 200. `?edited=0` 으로 원본 강제.
- `/file/video/:videoId`, `/api/video/:videoId/file` — ID 기반 파일 스트림 (레거시·외부 공유 호환).
관리자(`/op`):
- `/op` — 폴더 목록 (로그인 필요)

View File

@@ -11,7 +11,11 @@
var url = shareUrl || ('/file/video/' + encodeURIComponent(videoId))
history.pushState({ player: true, videoId: videoId }, '', url)
titleEl.textContent = title || ''
video.src = '/api/video/' + encodeURIComponent(videoId) + '/file'
// 파일 스트림 URL 은 공유 URL(/video/폴더/.../아이디)의 /video/ 를
// /api/video/ 로 바꾼 폴더 경로 기반 주소. shareUrl 이 없으면 레거시 폴백.
video.src = shareUrl
? shareUrl.replace(/^\/video\//, '/api/video/')
: ('/file/video/' + encodeURIComponent(videoId))
overlay.hidden = false
video.play().catch(function () { /* 자동재생 막힘 무시 */ })
}

View File

@@ -83,10 +83,23 @@ const videoViewHandler: RequestHandler = (req, res, next) => {
publicRouter.get('/video/:topName/:videoId', videoViewHandler)
publicRouter.get('/video/:topName/:subName/:videoId', videoViewHandler)
/** 결정된 비디오의 파일을 응답으로 보낸다 (?edited=0 이면 원본 강제). */
function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Parameters<RequestHandler>[1]): void {
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
const wantOriginal = editedParam === '0' || editedParam === 'false'
const fileName =
!wantOriginal && video.editedFile ? video.editedFile : video.originalFile
if (!fileName || fileName.includes('%(ext)s')) {
res.status(404).end()
return
}
res.sendFile(videoFileFsPath(video.id, fileName))
}
/**
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용).
* 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용 / 레거시).
* - GET /file/video/:videoId
* - GET /api/video/:videoId/file (legacy alias)
* - GET /api/video/:videoId/file
* ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본.
*/
const streamVideoHandler: RequestHandler = (req, res, next) => {
@@ -96,16 +109,7 @@ const streamVideoHandler: RequestHandler = (req, res, next) => {
res.status(404).end()
return
}
const editedParam = typeof req.query.edited === 'string' ? req.query.edited : ''
const wantOriginal = editedParam === '0' || editedParam === 'false'
const fileName =
!wantOriginal && video.editedFile ? video.editedFile : video.originalFile
if (!fileName || fileName.includes('%(ext)s')) {
res.status(404).end()
return
}
const fsPath = videoFileFsPath(video.id, fileName)
res.sendFile(fsPath)
sendVideoFile(video, req, res)
} catch (err) {
next(err)
}
@@ -113,6 +117,37 @@ const streamVideoHandler: RequestHandler = (req, res, next) => {
publicRouter.get('/file/video/:videoId', streamVideoHandler)
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
/**
* 영상 파일 스트리밍 (폴더 경로 + ID — 정규 공유 URL).
* - GET /api/video/:topName/:videoId
* - GET /api/video/:topName/:subName/:videoId
*
* 재생 페이지(/video/...)와 동일한 경로 규칙. 마지막 세그먼트가 영상 ID 이고
* 앞쪽 세그먼트는 그 영상이 실제 속한 폴더 경로와 일치해야 한다.
*
* 라우트 등록 순서 주의: 위의 `/api/video/:videoId/file` (2세그·끝이 리터럴 "file")
* 보다 뒤에 둬서, 레거시 URL 이 이 경로 라우트에 가로채이지 않게 한다.
*/
const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
try {
const { folderSegments, videoId } = collectVideoSegments(req.params)
const video = getVideo(videoId)
if (!video) {
res.status(404).end()
return
}
if (!segmentsEqual(folderPathNames(video.folderId), folderSegments)) {
res.status(404).end()
return
}
sendVideoFile(video, req, res)
} catch (err) {
next(err)
}
}
publicRouter.get('/api/video/:topName/:videoId', streamVideoByPathHandler)
publicRouter.get('/api/video/:topName/:subName/:videoId', streamVideoByPathHandler)
/**
* 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시.
* 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백.

View File

@@ -49,7 +49,7 @@
</div>
<video id="editVideo" controls preload="metadata"
<% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>></video>
<% if (video) { %>src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>?edited=0"<% } %>></video>
<div class="trimTimeline">
<div class="trimTimelineHeader">

View File

@@ -27,7 +27,7 @@
</section>
<div class="standalonePlayer">
<video controls autoplay preload="metadata" src="/api/video/<%= encodeURIComponent(video.id) %>/file"></video>
<video controls autoplay preload="metadata" src="/api/video/<%= folderPathEnc %>/<%= encodeURIComponent(video.id) %>"></video>
</div>
</main>
</body>