From 0ec6b06b57ef701c732c79313e1508d0d71315f1 Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Sat, 6 Jun 2026 16:19:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=81=EC=83=81=20ID=20=EB=A5=BC=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=95=88=EC=97=90=EC=84=9C=EB=A7=8C=20?= =?UTF-8?q?=EC=9C=A0=EC=9D=BC=ED=95=98=EA=B2=8C=20(UNIQUE(folder=5Fid,=20i?= =?UTF-8?q?d))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 폴더가 다르면 같은 영상 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/<폴더>//thumb)로 전환 - 클라이언트 mutation 요청에 folderId 동봉 Co-Authored-By: Claude Opus 4 --- public/editor.js | 7 ++-- public/folder.js | 8 ++-- public/playlist.js | 2 +- src/db.ts | 67 +++++++++++++++++++++++++++++-- src/editor.ts | 15 +++---- src/routes/op.ts | 51 ++++++++++++++++-------- src/routes/public.ts | 79 +++++++++++++++++++++--------------- src/storeDb.ts | 95 ++++++++++++++++++++++++++++---------------- src/youtube.ts | 10 ++--- views/folder.ejs | 2 +- views/op/folder.ejs | 2 +- 11 files changed, 232 insertions(+), 106 deletions(-) diff --git a/public/editor.js b/public/editor.js index 67bc18e..739779b 100644 --- a/public/editor.js +++ b/public/editor.js @@ -56,7 +56,7 @@ changeIdBtn.disabled = true clearTimeout(idDebounce) 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 (j) { // stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시 @@ -87,7 +87,7 @@ fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', { method: 'POST', 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) { if (!j.ok) { setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad') @@ -464,7 +464,8 @@ var payload = { title: titleInput.value, startSec: trimStart, - endSec: trimEnd + endSec: trimEnd, + folderId: ctx.folderId } saveBtn.disabled = true saveBtn.textContent = '저장 중...' diff --git a/public/folder.js b/public/folder.js index f874ffb..426ab66 100644 --- a/public/folder.js +++ b/public/folder.js @@ -39,7 +39,7 @@ fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', { method: 'POST', 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) { if (j.ok) location.reload() else alert(j.message || '이름 변경 실패') @@ -51,7 +51,7 @@ fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', { method: 'POST', 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) { if (j.ok) location.reload() else alert(j.message || 'ID 변경 실패') @@ -60,7 +60,9 @@ } else if (action === 'delete') { if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) { 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) { if (j.ok) location.reload() else alert(j.message || '삭제 실패') diff --git a/public/playlist.js b/public/playlist.js index b1e676c..b12a4c0 100644 --- a/public/playlist.js +++ b/public/playlist.js @@ -140,7 +140,7 @@ entry.idDebounce = setTimeout(function () { var current = state.entries[idx] 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 (j) { var c = state.entries[idx] diff --git a/src/db.ts b/src/db.ts index a3a9edb..7ffa6cb 100644 --- a/src/db.ts +++ b/src/db.ts @@ -14,7 +14,8 @@ * 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만. * 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사. * - * 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다. + * 영상 ID: 사용자 편집 가능. 폴더 안에서만 유일 (UNIQUE(folder_id, id)). + * 서로 다른 폴더면 같은 ID(예: 1,2,3,4)를 써도 된다. 변경 시 디렉토리 이름도 같이 바뀐다. */ import Database from 'better-sqlite3' import { promises as fsp } from 'node:fs' @@ -43,6 +44,7 @@ export async function initDatabase(): Promise { db.pragma('synchronous = NORMAL') createSchema(db) + migrateVideosSchema(db) _db = db // 비어 있으면 디스크 스캔 → 마이그레이션. @@ -70,7 +72,7 @@ function createSchema(db: Database.Database): void { ON folders(name) WHERE parent_id IS NULL; 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, title TEXT NOT NULL, original_file TEXT NOT NULL, @@ -82,12 +84,71 @@ function createSchema(db: Database.Database): void { trim_end_sec REAL, position INTEGER NOT NULL DEFAULT 0, 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); `) } +/** + * 구(舊) 스키마(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 에 적재. * 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다. diff --git a/src/editor.ts b/src/editor.ts index 9efdecf..2e4572b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync } from 'node:child_process' import { promises as fs, existsSync } from 'node:fs' 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' export class FfmpegUnavailableError extends Error { @@ -408,13 +408,14 @@ export async function upscaleOriginalTo60Fps( * stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩. */ export async function applyTrimToVideo( + folderId: number, videoId: string, trim: VideoTrim ): Promise { const bin = getFfmpegPath() - const meta = getVideo(videoId) + const meta = getVideoInFolder(folderId, videoId) if (!meta) throw new Error('비디오를 찾을 수 없습니다.') - const dir = videoDiskDir(videoId) + const dir = videoDiskDir(folderId, videoId) const inputPath = path.join(dir, meta.originalFile) await fs.access(inputPath) @@ -476,7 +477,7 @@ export async function applyTrimToVideo( } await fs.rename(tmpPath, outPath) - updateVideo(videoId, { + updateVideo(folderId, videoId, { editedFile: outName, trim: { startSec, endSec } }) @@ -496,10 +497,10 @@ const THUMB_NAME = 'thumb.jpg' * 카드에서는 ▶ placeholder 로 폴백. * 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답. */ -export async function ensureThumbnail(videoId: string): Promise { - const meta = getVideo(videoId) +export async function ensureThumbnail(folderId: number, videoId: string): Promise { + const meta = getVideoInFolder(folderId, videoId) if (!meta) return null - const dir = videoDiskDir(videoId) + const dir = videoDiskDir(folderId, videoId) const thumbPath = path.join(dir, THUMB_NAME) try { await fs.access(thumbPath) diff --git a/src/routes/op.ts b/src/routes/op.ts index 09b3cc1..ae6312d 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -13,7 +13,8 @@ import { folderPathNames, getFolder, getFolderByPath, - getVideo, + getVideoInFolder, + getVideoByIdGlobal, isSafeVideoId, listChildFolders, listTopFolders, @@ -21,7 +22,7 @@ import { moveUploadIntoVideoDir, renameFolder, updateVideo, - videoIdExists + videoExistsInFolder } from '../storeDb.js' import { tmpDir } from '../paths.js' import { @@ -224,7 +225,7 @@ opRouter.get( } // 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정) 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', { userId: req.session.userId, folder, @@ -304,7 +305,7 @@ opRouter.post( originalFile: destName, 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 }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) @@ -430,7 +431,7 @@ opRouter.post( if (seen.has(videoId)) { throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`) } - if (videoIdExists(videoId)) { + if (videoExistsInFolder(folder.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 }) }) -// ─── 영상 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) => { try { + const folder = folderFromBody(req) const id = req.params.id const title = pickStr(req.body.title).trim() if (!title) throw new Error('제목을 입력해 주세요.') - if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.') - updateVideo(id, { title }) + if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.') + updateVideo(folder.id, id, { title }) res.json({ ok: true }) } catch (err) { 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) => { try { + const folder = folderFromBody(req) const oldId = req.params.id const newId = pickStr(req.body.newId).trim() 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 }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } }) -/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */ +/** 새 ID 가 (해당 폴더 안에서) 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */ opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => { const id = typeof req.query.id === 'string' ? req.query.id.trim() : '' + const folderId = pickIntId(req.query.folderId) if (!id) { res.json({ ok: true, available: false, reason: '비어 있음' }) return @@ -521,13 +534,18 @@ opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => { }) 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 }) }) opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => { try { - await deleteVideo(req.params.id) + const folder = folderFromBody(req) + await deleteVideo(folder.id, req.params.id) res.json({ ok: true }) } catch (err) { 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) => { try { + const folder = folderFromBody(req) const id = req.params.id - const video = getVideo(id) + const video = getVideoInFolder(folder.id, id) if (!video) throw new Error('영상을 찾을 수 없습니다.') const title = pickStr(req.body.title).trim() const startSec = Number(req.body.startSec ?? 0) || 0 @@ -550,10 +569,10 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => { startSec, endSec: endSec == null || Number.isNaN(endSec) ? null : endSec } - updateVideo(id, { trim, ...(title ? { title } : {}) }) + updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) }) // ffmpeg 가 없으면 trim 정보만 저장하고 안내. try { - const outName = await applyTrimToVideo(id, trim) + const outName = await applyTrimToVideo(folder.id, id, trim) res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' }) } catch (err) { if (err instanceof FfmpegUnavailableError) { @@ -581,7 +600,7 @@ export function adminFolderUrl(folderId: number): string { /** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */ export function videoShareUrl(videoId: string): string | null { - const v = getVideo(videoId) + const v = getVideoByIdGlobal(videoId) if (!v) return null const names = folderPathNames(v.folderId) return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/') diff --git a/src/routes/public.ts b/src/routes/public.ts index 85f1a57..3bb16a2 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,7 +1,8 @@ import { Router, type RequestHandler } from 'express' import { getFolderByPath, - getVideo, + getVideoInFolder, + getVideoByIdGlobal, listChildFolders, listTopFolders, listVideosInFolder, @@ -65,17 +66,17 @@ publicRouter.get('/folder/:topName/:subName', folderViewHandler) const videoViewHandler: RequestHandler = (req, res, next) => { try { 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) { res.status(404).send('영상을 찾을 수 없습니다.') return } - const folderPath = folderPathNames(video.folderId) - if (!segmentsEqual(folderPath, folderSegments)) { - res.status(404).send('영상 경로가 일치하지 않습니다.') - return - } - res.render('player', { video, breadcrumb: folderPath }) + res.render('player', { video, breadcrumb: folderPathNames(folder.id) }) } catch (err) { next(err) } @@ -93,7 +94,7 @@ function sendVideoFile(video: Video, req: Parameters[0], res: Pa res.status(404).end() 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[0], res: Pa */ const streamVideoHandler: RequestHandler = (req, res, next) => { try { - const video = getVideo(req.params.videoId) + const video = getVideoByIdGlobal(req.params.videoId) if (!video) { res.status(404).end() return @@ -131,12 +132,13 @@ publicRouter.get('/api/video/:videoId/file', streamVideoHandler) const streamVideoByPathHandler: RequestHandler = (req, res, next) => { try { const { folderSegments, videoId } = collectVideoSegments(req.params) - const video = getVideo(videoId) - if (!video) { + const folder = getFolderByPath(folderSegments) + if (!folder) { res.status(404).end() return } - if (!segmentsEqual(folderPathNames(video.folderId), folderSegments)) { + const video = getVideoInFolder(folder.id, videoId) + if (!video) { res.status(404).end() return } @@ -152,16 +154,39 @@ publicRouter.get('/api/video/:topName/:subName/:videoId', streamVideoByPathHandl * 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시. * 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백. */ +function sendThumb(res: Parameters[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) => { - ensureThumbnail(req.params.videoId) - .then((thumbPath) => { - if (!thumbPath) { - res.status(404).end() - return - } - res.set('Cache-Control', 'public, max-age=3600') - res.sendFile(thumbPath) - }) + const video = getVideoByIdGlobal(req.params.videoId) + if (!video) { + res.status(404).end() + return + } + ensureThumbnail(video.folderId, video.id) + .then((thumbPath) => sendThumb(res, thumbPath)) .catch(next) } 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) => { try { - const video = getVideo(req.params.videoId) + const video = getVideoByIdGlobal(req.params.videoId) if (!video) { res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' }) return @@ -196,12 +221,4 @@ function collectVideoSegments(params: Record): { 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 } diff --git a/src/storeDb.ts b/src/storeDb.ts index 311e8fe..180d240 100644 --- a/src/storeDb.ts +++ b/src/storeDb.ts @@ -299,10 +299,23 @@ export async function deleteFolder(folderId: number): Promise { // ─── 영상 조회 ───────────────────────────────────────────────────────── -export function getVideo(id: string): Video | null { +/** 폴더 안에서 영상 1건. ID 는 폴더 안에서만 유일하므로 folderId 가 반드시 필요. */ +export function getVideoInFolder(folderId: number, id: string): Video | null { if (!isSafeVideoId(id)) return null 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 return r ? rowToVideo(r) : null } @@ -316,34 +329,30 @@ export function listVideosInFolder(folderId: number): Video[] { return rows.map(rowToVideo) } -export function videoIdExists(id: string): boolean { +export function videoExistsInFolder(folderId: number, id: string): boolean { if (!isSafeVideoId(id)) return false const r = getDb() - .prepare(`SELECT 1 FROM videos WHERE id = ?`) - .get(id) + .prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`) + .get(folderId, id) return !!r } -/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */ -export function videoDiskDir(videoId: string): string { - const v = getVideo(videoId) - if (!v) throw new Error('영상을 찾을 수 없습니다.') - return path.join(folderDiskPath(v.folderId), v.id) +/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */ +export function videoDiskDir(folderId: number, id: string): string { + return path.join(folderDiskPath(folderId), id) } /** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */ -export function videoPathNames(videoId: string): string[] | null { - const v = getVideo(videoId) - if (!v) return null - return [...folderPathNames(v.folderId), v.id] +export function videoPathNames(folderId: number, id: string): string[] { + return [...folderPathNames(folderId), 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('..')) { 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