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:
@@ -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` — 폴더 목록 (로그인 필요)
|
||||
|
||||
@@ -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 () { /* 자동재생 막힘 무시 */ })
|
||||
}
|
||||
|
||||
@@ -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 로 폴백.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user