feat: 영상 ID 를 폴더 안에서만 유일하게 (UNIQUE(folder_id, id))

폴더가 다르면 같은 영상 ID(예: 1,2,3,4)를 재사용할 수 있도록 변경.
ID 가 더 이상 전역 PK 가 아니므로 모든 영상 조회를 폴더 스코프로 전환:
- db: videos PK→UNIQUE(folder_id,id) + 기존 DB 자동 마이그레이션
- storeDb: getVideo→getVideoInFolder(folderId,id) + getVideoByIdGlobal 폴백
- public/op/editor/youtube: 재생·썸네일·편집·삭제·rename 모두 폴더 스코프
- 썸네일을 경로기반 URL(/file/video/<폴더>/<id>/thumb)로 전환
- 클라이언트 mutation 요청에 folderId 동봉

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Claude (owner)
2026-06-06 16:19:02 +09:00
parent 66469ca418
commit 0ec6b06b57
11 changed files with 232 additions and 106 deletions

View File

@@ -56,7 +56,7 @@
changeIdBtn.disabled = true changeIdBtn.disabled = true
clearTimeout(idDebounce) clearTimeout(idDebounce)
idDebounce = setTimeout(function () { idDebounce = setTimeout(function () {
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' }) fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
.then(function (r) { return r.json() }) .then(function (r) { return r.json() })
.then(function (j) { .then(function (j) {
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시 // stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
@@ -87,7 +87,7 @@
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', { fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ newId: newId }) body: JSON.stringify({ newId: newId, folderId: ctx.folderId })
}).then(function (r) { return r.json() }).then(function (j) { }).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) { if (!j.ok) {
setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad') setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad')
@@ -464,7 +464,8 @@
var payload = { var payload = {
title: titleInput.value, title: titleInput.value,
startSec: trimStart, startSec: trimStart,
endSec: trimEnd endSec: trimEnd,
folderId: ctx.folderId
} }
saveBtn.disabled = true saveBtn.disabled = true
saveBtn.textContent = '저장 중...' saveBtn.textContent = '저장 중...'

View File

@@ -39,7 +39,7 @@
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', { fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: t }) body: JSON.stringify({ title: t, folderId: op.folderId })
}).then(function (r) { return r.json() }).then(function (j) { }).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload() if (j.ok) location.reload()
else alert(j.message || '이름 변경 실패') else alert(j.message || '이름 변경 실패')
@@ -51,7 +51,7 @@
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', { fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ newId: newId }) body: JSON.stringify({ newId: newId, folderId: op.folderId })
}).then(function (r) { return r.json() }).then(function (j) { }).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload() if (j.ok) location.reload()
else alert(j.message || 'ID 변경 실패') else alert(j.message || 'ID 변경 실패')
@@ -60,7 +60,9 @@
} else if (action === 'delete') { } else if (action === 'delete') {
if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) { if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) {
fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', { fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', {
method: 'POST' method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ folderId: op.folderId })
}).then(function (r) { return r.json() }).then(function (j) { }).then(function (r) { return r.json() }).then(function (j) {
if (j.ok) location.reload() if (j.ok) location.reload()
else alert(j.message || '삭제 실패') else alert(j.message || '삭제 실패')

View File

@@ -140,7 +140,7 @@
entry.idDebounce = setTimeout(function () { entry.idDebounce = setTimeout(function () {
var current = state.entries[idx] var current = state.entries[idx]
if (!current || current.videoId !== v) return if (!current || current.videoId !== v) return
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' }) fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' })
.then(function (r) { return r.json() }) .then(function (r) { return r.json() })
.then(function (j) { .then(function (j) {
var c = state.entries[idx] var c = state.entries[idx]

View File

@@ -14,7 +14,8 @@
* 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만. * 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만.
* 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사. * 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사.
* *
* 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다. * 영상 ID: 사용자 편집 가능. 폴더 안에서만 유일 (UNIQUE(folder_id, id)).
* 서로 다른 폴더면 같은 ID(예: 1,2,3,4)를 써도 된다. 변경 시 디렉토리 이름도 같이 바뀐다.
*/ */
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import { promises as fsp } from 'node:fs' import { promises as fsp } from 'node:fs'
@@ -43,6 +44,7 @@ export async function initDatabase(): Promise<void> {
db.pragma('synchronous = NORMAL') db.pragma('synchronous = NORMAL')
createSchema(db) createSchema(db)
migrateVideosSchema(db)
_db = db _db = db
// 비어 있으면 디스크 스캔 → 마이그레이션. // 비어 있으면 디스크 스캔 → 마이그레이션.
@@ -70,7 +72,7 @@ function createSchema(db: Database.Database): void {
ON folders(name) WHERE parent_id IS NULL; ON folders(name) WHERE parent_id IS NULL;
CREATE TABLE IF NOT EXISTS videos ( CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY, id TEXT NOT NULL,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
original_file TEXT NOT NULL, original_file TEXT NOT NULL,
@@ -82,12 +84,71 @@ function createSchema(db: Database.Database): void {
trim_end_sec REAL, trim_end_sec REAL,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL,
UNIQUE(folder_id, id)
); );
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id); CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
`) `)
} }
/**
* 구(舊) 스키마(videos.id 가 전역 PRIMARY KEY)를 신(新) 스키마(UNIQUE(folder_id, id))로
* 마이그레이션한다. createSchema 의 CREATE TABLE IF NOT EXISTS 는 이미 존재하는 테이블을
* 건드리지 않으므로, 운영 DB 의 기존 videos 테이블은 이 함수로만 바뀐다.
*
* 판별: PRAGMA table_info(videos) 에서 id 컬럼의 pk 가 1 이면 구 스키마.
* 기존 id 는 전역 유일이므로 (folder_id, id) 로 옮겨도 충돌이 없다.
*/
function migrateVideosSchema(db: Database.Database): void {
const cols = db.prepare(`PRAGMA table_info(videos)`).all() as Array<{
name: string
pk: number
}>
const idCol = cols.find((c) => c.name === 'id')
// 테이블이 없거나(첫 생성), id 가 PK 가 아니면(이미 신 스키마) 할 일 없음.
if (!idCol || idCol.pk === 0) return
db.pragma('foreign_keys = OFF')
try {
const rebuild = db.transaction(() => {
db.exec(`
CREATE TABLE videos_new (
id TEXT NOT NULL,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
title TEXT NOT NULL,
original_file TEXT NOT NULL,
edited_file TEXT,
duration_sec REAL,
source_type TEXT NOT NULL CHECK (source_type IN ('upload', 'youtube')),
source_url TEXT,
trim_start_sec REAL,
trim_end_sec REAL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(folder_id, id)
);
INSERT INTO videos_new
(id, folder_id, title, original_file, edited_file, duration_sec,
source_type, source_url, trim_start_sec, trim_end_sec,
position, created_at, updated_at)
SELECT
id, folder_id, title, original_file, edited_file, duration_sec,
source_type, source_url, trim_start_sec, trim_end_sec,
position, created_at, updated_at
FROM videos;
DROP TABLE videos;
ALTER TABLE videos_new RENAME TO videos;
CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id);
`)
})
rebuild()
console.log('[db] videos 스키마 마이그레이션 완료: UNIQUE(folder_id, id)')
} finally {
db.pragma('foreign_keys = ON')
}
}
/** /**
* 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재. * 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재.
* 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다. * 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다.

View File

@@ -1,7 +1,7 @@
import { spawn, spawnSync } from 'node:child_process' import { spawn, spawnSync } from 'node:child_process'
import { promises as fs, existsSync } from 'node:fs' import { promises as fs, existsSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js' import { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
import { binDir } from './paths.js' import { binDir } from './paths.js'
export class FfmpegUnavailableError extends Error { export class FfmpegUnavailableError extends Error {
@@ -408,13 +408,14 @@ export async function upscaleOriginalTo60Fps(
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩. * stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
*/ */
export async function applyTrimToVideo( export async function applyTrimToVideo(
folderId: number,
videoId: string, videoId: string,
trim: VideoTrim trim: VideoTrim
): Promise<string> { ): Promise<string> {
const bin = getFfmpegPath() const bin = getFfmpegPath()
const meta = getVideo(videoId) const meta = getVideoInFolder(folderId, videoId)
if (!meta) throw new Error('비디오를 찾을 수 없습니다.') if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
const dir = videoDiskDir(videoId) const dir = videoDiskDir(folderId, videoId)
const inputPath = path.join(dir, meta.originalFile) const inputPath = path.join(dir, meta.originalFile)
await fs.access(inputPath) await fs.access(inputPath)
@@ -476,7 +477,7 @@ export async function applyTrimToVideo(
} }
await fs.rename(tmpPath, outPath) await fs.rename(tmpPath, outPath)
updateVideo(videoId, { updateVideo(folderId, videoId, {
editedFile: outName, editedFile: outName,
trim: { startSec, endSec } trim: { startSec, endSec }
}) })
@@ -496,10 +497,10 @@ const THUMB_NAME = 'thumb.jpg'
* 카드에서는 ▶ placeholder 로 폴백. * 카드에서는 ▶ placeholder 로 폴백.
* 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답. * 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답.
*/ */
export async function ensureThumbnail(videoId: string): Promise<string | null> { export async function ensureThumbnail(folderId: number, videoId: string): Promise<string | null> {
const meta = getVideo(videoId) const meta = getVideoInFolder(folderId, videoId)
if (!meta) return null if (!meta) return null
const dir = videoDiskDir(videoId) const dir = videoDiskDir(folderId, videoId)
const thumbPath = path.join(dir, THUMB_NAME) const thumbPath = path.join(dir, THUMB_NAME)
try { try {
await fs.access(thumbPath) await fs.access(thumbPath)

View File

@@ -13,7 +13,8 @@ import {
folderPathNames, folderPathNames,
getFolder, getFolder,
getFolderByPath, getFolderByPath,
getVideo, getVideoInFolder,
getVideoByIdGlobal,
isSafeVideoId, isSafeVideoId,
listChildFolders, listChildFolders,
listTopFolders, listTopFolders,
@@ -21,7 +22,7 @@ import {
moveUploadIntoVideoDir, moveUploadIntoVideoDir,
renameFolder, renameFolder,
updateVideo, updateVideo,
videoIdExists videoExistsInFolder
} from '../storeDb.js' } from '../storeDb.js'
import { tmpDir } from '../paths.js' import { tmpDir } from '../paths.js'
import { import {
@@ -224,7 +225,7 @@ opRouter.get(
} }
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정) // 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
const videoId = typeof req.query.id === 'string' ? req.query.id : null const videoId = typeof req.query.id === 'string' ? req.query.id : null
const video = videoId ? getVideo(videoId) : null const video = videoId ? getVideoInFolder(folder.id, videoId) : null
res.render('op/editor', { res.render('op/editor', {
userId: req.session.userId, userId: req.session.userId,
folder, folder,
@@ -304,7 +305,7 @@ opRouter.post(
originalFile: destName, originalFile: destName,
sourceType: 'upload' sourceType: 'upload'
}) })
await moveUploadIntoVideoDir(video.id, file.path, destName) await moveUploadIntoVideoDir(folder.id, video.id, file.path, destName)
res.json({ ok: true, videoId: video.id }) res.json({ ok: true, videoId: video.id })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message }) res.status(400).json({ ok: false, message: (err as Error).message })
@@ -430,7 +431,7 @@ opRouter.post(
if (seen.has(videoId)) { if (seen.has(videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`) throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`)
} }
if (videoIdExists(videoId)) { if (videoExistsInFolder(folder.id, videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`) throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
} }
// 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리. // 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리.
@@ -479,15 +480,25 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
res.json({ ok: true, job }) res.json({ ok: true, job })
}) })
// ─── 영상 mutation (id 기반 — 전역 유일 ID) ───────────────────────────── // ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ───────
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */
function folderFromBody(req: Request): { id: number } {
const fid = pickIntId(req.body?.folderId)
if (fid === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
const folder = getFolder(fid)
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
return folder
}
opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => { opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
try { try {
const folder = folderFromBody(req)
const id = req.params.id const id = req.params.id
const title = pickStr(req.body.title).trim() const title = pickStr(req.body.title).trim()
if (!title) throw new Error('제목을 입력해 주세요.') if (!title) throw new Error('제목을 입력해 주세요.')
if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.') if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.')
updateVideo(id, { title }) updateVideo(folder.id, id, { title })
res.json({ ok: true }) res.json({ ok: true })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message }) res.status(400).json({ ok: false, message: (err as Error).message })
@@ -496,19 +507,21 @@ opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => { opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
try { try {
const folder = folderFromBody(req)
const oldId = req.params.id const oldId = req.params.id
const newId = pickStr(req.body.newId).trim() const newId = pickStr(req.body.newId).trim()
if (!newId) throw new Error('새 ID 를 입력해 주세요.') if (!newId) throw new Error('새 ID 를 입력해 주세요.')
const video = await changeVideoId(oldId, newId) const video = await changeVideoId(folder.id, oldId, newId)
res.json({ ok: true, video }) res.json({ ok: true, video })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message }) res.status(400).json({ ok: false, message: (err as Error).message })
} }
}) })
/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */ /** 새 ID 가 (해당 폴더 안에서) 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => { opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
const id = typeof req.query.id === 'string' ? req.query.id.trim() : '' const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
const folderId = pickIntId(req.query.folderId)
if (!id) { if (!id) {
res.json({ ok: true, available: false, reason: '비어 있음' }) res.json({ ok: true, available: false, reason: '비어 있음' })
return return
@@ -521,13 +534,18 @@ opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
}) })
return return
} }
const available = !videoIdExists(id) if (folderId === null) {
res.json({ ok: true, available: false, reason: '폴더 정보 없음' })
return
}
const available = !videoExistsInFolder(folderId, id)
res.json({ ok: true, available }) res.json({ ok: true, available })
}) })
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => { opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
try { try {
await deleteVideo(req.params.id) const folder = folderFromBody(req)
await deleteVideo(folder.id, req.params.id)
res.json({ ok: true }) res.json({ ok: true })
} catch (err) { } catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message }) res.status(400).json({ ok: false, message: (err as Error).message })
@@ -536,8 +554,9 @@ opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => { opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
try { try {
const folder = folderFromBody(req)
const id = req.params.id const id = req.params.id
const video = getVideo(id) const video = getVideoInFolder(folder.id, id)
if (!video) throw new Error('영상을 찾을 수 없습니다.') if (!video) throw new Error('영상을 찾을 수 없습니다.')
const title = pickStr(req.body.title).trim() const title = pickStr(req.body.title).trim()
const startSec = Number(req.body.startSec ?? 0) || 0 const startSec = Number(req.body.startSec ?? 0) || 0
@@ -550,10 +569,10 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
startSec, startSec,
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
} }
updateVideo(id, { trim, ...(title ? { title } : {}) }) updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) })
// ffmpeg 가 없으면 trim 정보만 저장하고 안내. // ffmpeg 가 없으면 trim 정보만 저장하고 안내.
try { try {
const outName = await applyTrimToVideo(id, trim) const outName = await applyTrimToVideo(folder.id, id, trim)
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' }) res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
} catch (err) { } catch (err) {
if (err instanceof FfmpegUnavailableError) { if (err instanceof FfmpegUnavailableError) {
@@ -581,7 +600,7 @@ export function adminFolderUrl(folderId: number): string {
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */ /** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
export function videoShareUrl(videoId: string): string | null { export function videoShareUrl(videoId: string): string | null {
const v = getVideo(videoId) const v = getVideoByIdGlobal(videoId)
if (!v) return null if (!v) return null
const names = folderPathNames(v.folderId) const names = folderPathNames(v.folderId)
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/') return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')

View File

@@ -1,7 +1,8 @@
import { Router, type RequestHandler } from 'express' import { Router, type RequestHandler } from 'express'
import { import {
getFolderByPath, getFolderByPath,
getVideo, getVideoInFolder,
getVideoByIdGlobal,
listChildFolders, listChildFolders,
listTopFolders, listTopFolders,
listVideosInFolder, listVideosInFolder,
@@ -65,17 +66,17 @@ publicRouter.get('/folder/:topName/:subName', folderViewHandler)
const videoViewHandler: RequestHandler = (req, res, next) => { const videoViewHandler: RequestHandler = (req, res, next) => {
try { try {
const { folderSegments, videoId } = collectVideoSegments(req.params) const { folderSegments, videoId } = collectVideoSegments(req.params)
const video = getVideo(videoId) const folder = getFolderByPath(folderSegments)
if (!folder) {
res.status(404).send('영상을 찾을 수 없습니다.')
return
}
const video = getVideoInFolder(folder.id, videoId)
if (!video) { if (!video) {
res.status(404).send('영상을 찾을 수 없습니다.') res.status(404).send('영상을 찾을 수 없습니다.')
return return
} }
const folderPath = folderPathNames(video.folderId) res.render('player', { video, breadcrumb: folderPathNames(folder.id) })
if (!segmentsEqual(folderPath, folderSegments)) {
res.status(404).send('영상 경로가 일치하지 않습니다.')
return
}
res.render('player', { video, breadcrumb: folderPath })
} catch (err) { } catch (err) {
next(err) next(err)
} }
@@ -93,7 +94,7 @@ function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Pa
res.status(404).end() res.status(404).end()
return return
} }
res.sendFile(videoFileFsPath(video.id, fileName)) res.sendFile(videoFileFsPath(video.folderId, video.id, fileName))
} }
/** /**
@@ -104,7 +105,7 @@ function sendVideoFile(video: Video, req: Parameters<RequestHandler>[0], res: Pa
*/ */
const streamVideoHandler: RequestHandler = (req, res, next) => { const streamVideoHandler: RequestHandler = (req, res, next) => {
try { try {
const video = getVideo(req.params.videoId) const video = getVideoByIdGlobal(req.params.videoId)
if (!video) { if (!video) {
res.status(404).end() res.status(404).end()
return return
@@ -131,12 +132,13 @@ publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
const streamVideoByPathHandler: RequestHandler = (req, res, next) => { const streamVideoByPathHandler: RequestHandler = (req, res, next) => {
try { try {
const { folderSegments, videoId } = collectVideoSegments(req.params) const { folderSegments, videoId } = collectVideoSegments(req.params)
const video = getVideo(videoId) const folder = getFolderByPath(folderSegments)
if (!video) { if (!folder) {
res.status(404).end() res.status(404).end()
return return
} }
if (!segmentsEqual(folderPathNames(video.folderId), folderSegments)) { const video = getVideoInFolder(folder.id, videoId)
if (!video) {
res.status(404).end() res.status(404).end()
return return
} }
@@ -152,16 +154,39 @@ publicRouter.get('/api/video/:topName/:subName/:videoId', streamVideoByPathHandl
* 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시. * 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시.
* 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백. * 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백.
*/ */
function sendThumb(res: Parameters<RequestHandler>[1], thumbPath: string | null): void {
if (!thumbPath) {
res.status(404).end()
return
}
res.set('Cache-Control', 'public, max-age=3600')
res.sendFile(thumbPath)
}
/** 폴더 경로 + ID — 정규 썸네일 URL. 같은 ID 가 여러 폴더에 있어도 정확히 찾는다. */
const thumbByPathHandler: RequestHandler = (req, res, next) => {
const { folderSegments, videoId } = collectVideoSegments(req.params)
const folder = getFolderByPath(folderSegments)
if (!folder) {
res.status(404).end()
return
}
ensureThumbnail(folder.id, videoId)
.then((thumbPath) => sendThumb(res, thumbPath))
.catch(next)
}
publicRouter.get('/file/video/:topName/:videoId/thumb', thumbByPathHandler)
publicRouter.get('/file/video/:topName/:subName/:videoId/thumb', thumbByPathHandler)
/** ID-only 레거시 썸네일 fallback (전역 첫 매치). */
const thumbHandler: RequestHandler = (req, res, next) => { const thumbHandler: RequestHandler = (req, res, next) => {
ensureThumbnail(req.params.videoId) const video = getVideoByIdGlobal(req.params.videoId)
.then((thumbPath) => { if (!video) {
if (!thumbPath) { res.status(404).end()
res.status(404).end() return
return }
} ensureThumbnail(video.folderId, video.id)
res.set('Cache-Control', 'public, max-age=3600') .then((thumbPath) => sendThumb(res, thumbPath))
res.sendFile(thumbPath)
})
.catch(next) .catch(next)
} }
publicRouter.get('/file/video/:videoId/thumb', thumbHandler) publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
@@ -169,7 +194,7 @@ publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */ /** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
publicRouter.get('/api/video/:videoId', (req, res, next) => { publicRouter.get('/api/video/:videoId', (req, res, next) => {
try { try {
const video = getVideo(req.params.videoId) const video = getVideoByIdGlobal(req.params.videoId)
if (!video) { if (!video) {
res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' }) res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' })
return return
@@ -196,12 +221,4 @@ function collectVideoSegments(params: Record<string, string | undefined>): {
return { folderSegments, videoId: params.videoId ?? '' } return { folderSegments, videoId: params.videoId ?? '' }
} }
function segmentsEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
export type { Folder, Video } export type { Folder, Video }

View File

@@ -299,10 +299,23 @@ export async function deleteFolder(folderId: number): Promise<void> {
// ─── 영상 조회 ───────────────────────────────────────────────────────── // ─── 영상 조회 ─────────────────────────────────────────────────────────
export function getVideo(id: string): Video | null { /** 폴더 안에서 영상 1건. ID 는 폴더 안에서만 유일하므로 folderId 가 반드시 필요. */
export function getVideoInFolder(folderId: number, id: string): Video | null {
if (!isSafeVideoId(id)) return null if (!isSafeVideoId(id)) return null
const r = getDb() const r = getDb()
.prepare(`SELECT * FROM videos WHERE id = ?`) .prepare(`SELECT * FROM videos WHERE folder_id = ? AND id = ?`)
.get(folderId, id) as VideoRow | undefined
return r ? rowToVideo(r) : null
}
/**
* ID 만으로 전역에서 첫 번째 영상을 찾는다 (폴더 경로 없는 레거시/외부공유 fallback).
* 같은 ID 가 여러 폴더에 있으면 folder_id 가 작은(=먼저 만든) 쪽을 돌려준다.
*/
export function getVideoByIdGlobal(id: string): Video | null {
if (!isSafeVideoId(id)) return null
const r = getDb()
.prepare(`SELECT * FROM videos WHERE id = ? ORDER BY folder_id LIMIT 1`)
.get(id) as VideoRow | undefined .get(id) as VideoRow | undefined
return r ? rowToVideo(r) : null return r ? rowToVideo(r) : null
} }
@@ -316,34 +329,30 @@ export function listVideosInFolder(folderId: number): Video[] {
return rows.map(rowToVideo) return rows.map(rowToVideo)
} }
export function videoIdExists(id: string): boolean { export function videoExistsInFolder(folderId: number, id: string): boolean {
if (!isSafeVideoId(id)) return false if (!isSafeVideoId(id)) return false
const r = getDb() const r = getDb()
.prepare(`SELECT 1 FROM videos WHERE id = ?`) .prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`)
.get(id) .get(folderId, id)
return !!r return !!r
} }
/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */ /** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */
export function videoDiskDir(videoId: string): string { export function videoDiskDir(folderId: number, id: string): string {
const v = getVideo(videoId) return path.join(folderDiskPath(folderId), id)
if (!v) throw new Error('영상을 찾을 수 없습니다.')
return path.join(folderDiskPath(v.folderId), v.id)
} }
/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */ /** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */
export function videoPathNames(videoId: string): string[] | null { export function videoPathNames(folderId: number, id: string): string[] {
const v = getVideo(videoId) return [...folderPathNames(folderId), id]
if (!v) return null
return [...folderPathNames(v.folderId), v.id]
} }
/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */ /** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */
export function videoFileFsPath(videoId: string, rel: string): string { export function videoFileFsPath(folderId: number, id: string, rel: string): string {
if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) { if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) {
throw new Error('잘못된 파일 경로입니다.') throw new Error('잘못된 파일 경로입니다.')
} }
return path.join(videoDiskDir(videoId), rel) return path.join(videoDiskDir(folderId, id), rel)
} }
// ─── 영상 변경 ───────────────────────────────────────────────────────── // ─── 영상 변경 ─────────────────────────────────────────────────────────
@@ -362,7 +371,9 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
if (!folder) throw new Error('폴더를 찾을 수 없습니다.') if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
let id = input.id ?? newRandomVideoId() let id = input.id ?? newRandomVideoId()
if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.') if (!isSafeVideoId(id)) throw new Error('영상 ID 가 올바르지 않습니다.')
if (videoIdExists(id)) throw new Error('이미 사용 중인 영상 ID 입니다.') if (videoExistsInFolder(input.folderId, id)) {
throw new Error('이미 사용 중인 영상 ID 입니다.')
}
const now = new Date().toISOString() const now = new Date().toISOString()
const db = getDb() const db = getDb()
@@ -392,30 +403,37 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
try { try {
await fsp.mkdir(diskDir, { recursive: true }) await fsp.mkdir(diskDir, { recursive: true })
} catch (err) { } catch (err) {
db.prepare(`DELETE FROM videos WHERE id = ?`).run(id) db.prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(input.folderId, id)
throw err throw err
} }
return getVideo(id)! return getVideoInFolder(input.folderId, id)!
} }
export async function changeVideoId(oldId: string, newId: string): Promise<Video> { export async function changeVideoId(
folderId: number,
oldId: string,
newId: string
): Promise<Video> {
if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.') if (!isSafeVideoId(newId)) throw new Error('새 영상 ID 가 올바르지 않습니다.')
if (oldId === newId) { if (oldId === newId) {
const v = getVideo(oldId) const v = getVideoInFolder(folderId, oldId)
if (!v) throw new Error('영상을 찾을 수 없습니다.') if (!v) throw new Error('영상을 찾을 수 없습니다.')
return v return v
} }
const current = getVideo(oldId) const current = getVideoInFolder(folderId, oldId)
if (!current) throw new Error('영상을 찾을 수 없습니다.') if (!current) throw new Error('영상을 찾을 수 없습니다.')
if (videoIdExists(newId)) throw new Error('이미 사용 중인 영상 ID 입니다.') if (videoExistsInFolder(folderId, newId)) {
throw new Error('이미 사용 중인 영상 ID 입니다.')
}
const oldDir = path.join(folderDiskPath(current.folderId), current.id) const oldDir = path.join(folderDiskPath(current.folderId), current.id)
const newDir = path.join(folderDiskPath(current.folderId), newId) const newDir = path.join(folderDiskPath(current.folderId), newId)
const db = getDb() const db = getDb()
try { try {
db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE id = ?`).run( db.prepare(`UPDATE videos SET id = ?, updated_at = ? WHERE folder_id = ? AND id = ?`).run(
newId, newId,
new Date().toISOString(), new Date().toISOString(),
folderId,
oldId oldId
) )
} catch (err) { } catch (err) {
@@ -431,11 +449,15 @@ export async function changeVideoId(oldId: string, newId: string): Promise<Video
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스). // 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
await fsp.mkdir(newDir, { recursive: true }) await fsp.mkdir(newDir, { recursive: true })
} else { } else {
db.prepare(`UPDATE videos SET id = ? WHERE id = ?`).run(oldId, newId) db.prepare(`UPDATE videos SET id = ? WHERE folder_id = ? AND id = ?`).run(
oldId,
folderId,
newId
)
throw err throw err
} }
} }
return getVideo(newId)! return getVideoInFolder(folderId, newId)!
} }
export interface UpdateVideoPatch { export interface UpdateVideoPatch {
@@ -447,8 +469,8 @@ export interface UpdateVideoPatch {
trim?: VideoTrim | null trim?: VideoTrim | null
} }
export function updateVideo(id: string, patch: UpdateVideoPatch): Video { export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
const current = getVideo(id) const current = getVideoInFolder(folderId, id)
if (!current) throw new Error('영상을 찾을 수 없습니다.') if (!current) throw new Error('영상을 찾을 수 없습니다.')
const fields: string[] = [] const fields: string[] = []
@@ -484,22 +506,25 @@ export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
if (fields.length === 0) return current if (fields.length === 0) return current
fields.push('updated_at = ?') fields.push('updated_at = ?')
params.push(new Date().toISOString()) params.push(new Date().toISOString())
params.push(id) params.push(folderId, id)
getDb().prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...params) getDb()
return getVideo(id)! .prepare(`UPDATE videos SET ${fields.join(', ')} WHERE folder_id = ? AND id = ?`)
.run(...params)
return getVideoInFolder(folderId, id)!
} }
export async function deleteVideo(id: string): Promise<void> { export async function deleteVideo(folderId: number, id: string): Promise<void> {
const v = getVideo(id) const v = getVideoInFolder(folderId, id)
if (!v) return if (!v) return
const diskDir = path.join(folderDiskPath(v.folderId), v.id) const diskDir = path.join(folderDiskPath(v.folderId), v.id)
getDb().prepare(`DELETE FROM videos WHERE id = ?`).run(id) getDb().prepare(`DELETE FROM videos WHERE folder_id = ? AND id = ?`).run(folderId, id)
await fsp.rm(diskDir, { recursive: true, force: true }) await fsp.rm(diskDir, { recursive: true, force: true })
} }
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ───────────────────────────── // ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
export async function moveUploadIntoVideoDir( export async function moveUploadIntoVideoDir(
folderId: number,
videoId: string, videoId: string,
tmpFile: string, tmpFile: string,
destName: string destName: string
@@ -507,7 +532,7 @@ export async function moveUploadIntoVideoDir(
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) { if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
throw new Error('잘못된 파일명입니다.') throw new Error('잘못된 파일명입니다.')
} }
const dir = videoDiskDir(videoId) const dir = videoDiskDir(folderId, videoId)
await fsp.mkdir(dir, { recursive: true }) await fsp.mkdir(dir, { recursive: true })
const target = path.join(dir, destName) const target = path.join(dir, destName)
try { try {

View File

@@ -6,7 +6,7 @@ import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
import { import {
createVideo, createVideo,
getFolder, getFolder,
getVideo, getVideoInFolder,
updateVideo, updateVideo,
videoDiskDir, videoDiskDir,
type VideoTrim type VideoTrim
@@ -343,7 +343,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
job.message = '다운로드 시작' job.message = '다운로드 시작'
await persistJob(job) await persistJob(job)
const dir = videoDiskDir(job.videoId) const dir = videoDiskDir(job.folderId, job.videoId)
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath // yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱. // 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
const args = [ const args = [
@@ -498,9 +498,9 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
console.error('[youtube] 60fps 후처리 실패:', err) console.error('[youtube] 60fps 후처리 실패:', err)
} }
const meta = getVideo(job.videoId) const meta = getVideoInFolder(job.folderId, job.videoId)
if (meta) { if (meta) {
updateVideo(job.videoId, { originalFile: finalName }) updateVideo(job.folderId, job.videoId, { originalFile: finalName })
} }
// 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에 // 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에
@@ -510,7 +510,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
try { try {
job.message = '자르기 적용 중' job.message = '자르기 적용 중'
await persistJob(job) await persistJob(job)
await applyTrimToVideo(job.videoId, job.trim) await applyTrimToVideo(job.folderId, job.videoId, job.trim)
job.message = '자르기 완료' job.message = '자르기 완료'
} catch (err) { } catch (err) {
console.error('[youtube] 자르기 적용 실패:', err) console.error('[youtube] 자르기 적용 실패:', err)

View File

@@ -52,7 +52,7 @@
<a class="videoCard" href="<%= shareUrl %>" <a class="videoCard" href="<%= shareUrl %>"
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>"> data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
<div class="videoThumb"> <div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" /> <img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<span class="videoThumbPlay">▶</span> <span class="videoThumbPlay">▶</span>
</div> </div>
<div class="videoTitle"><%= v.title %></div> <div class="videoTitle"><%= v.title %></div>

View File

@@ -63,7 +63,7 @@
data-title="<%= v.title %>" data-title="<%= v.title %>"
data-share-url="<%= shareUrl %>"> data-share-url="<%= shareUrl %>">
<div class="videoThumb"> <div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" /> <img class="videoThumbImg" src="/file/video/<%= folderPathEnc %>/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<span class="videoThumbPlay">▶</span> <span class="videoThumbPlay">▶</span>
</div> </div>
<div class="videoTitle"><%= v.title %></div> <div class="videoTitle"><%= v.title %></div>