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:
@@ -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
|
||||
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<Video> {
|
||||
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||
let id = input.id ?? newRandomVideoId()
|
||||
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 db = getDb()
|
||||
@@ -392,30 +403,37 @@ export async function createVideo(input: CreateVideoInput): Promise<Video> {
|
||||
try {
|
||||
await fsp.mkdir(diskDir, { recursive: true })
|
||||
} 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
|
||||
}
|
||||
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 (oldId === newId) {
|
||||
const v = getVideo(oldId)
|
||||
const v = getVideoInFolder(folderId, oldId)
|
||||
if (!v) throw new Error('영상을 찾을 수 없습니다.')
|
||||
return v
|
||||
}
|
||||
const current = getVideo(oldId)
|
||||
const current = getVideoInFolder(folderId, oldId)
|
||||
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 newDir = path.join(folderDiskPath(current.folderId), newId)
|
||||
const db = getDb()
|
||||
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,
|
||||
new Date().toISOString(),
|
||||
folderId,
|
||||
oldId
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -431,11 +449,15 @@ export async function changeVideoId(oldId: string, newId: string): Promise<Video
|
||||
// 디스크에 디렉토리가 없으면 빈 디렉토리 생성 (메타만 있는 케이스).
|
||||
await fsp.mkdir(newDir, { recursive: true })
|
||||
} 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
|
||||
}
|
||||
}
|
||||
return getVideo(newId)!
|
||||
return getVideoInFolder(folderId, newId)!
|
||||
}
|
||||
|
||||
export interface UpdateVideoPatch {
|
||||
@@ -447,8 +469,8 @@ export interface UpdateVideoPatch {
|
||||
trim?: VideoTrim | null
|
||||
}
|
||||
|
||||
export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
|
||||
const current = getVideo(id)
|
||||
export function updateVideo(folderId: number, id: string, patch: UpdateVideoPatch): Video {
|
||||
const current = getVideoInFolder(folderId, id)
|
||||
if (!current) throw new Error('영상을 찾을 수 없습니다.')
|
||||
|
||||
const fields: string[] = []
|
||||
@@ -484,22 +506,25 @@ export function updateVideo(id: string, patch: UpdateVideoPatch): Video {
|
||||
if (fields.length === 0) return current
|
||||
fields.push('updated_at = ?')
|
||||
params.push(new Date().toISOString())
|
||||
params.push(id)
|
||||
getDb().prepare(`UPDATE videos SET ${fields.join(', ')} WHERE id = ?`).run(...params)
|
||||
return getVideo(id)!
|
||||
params.push(folderId, id)
|
||||
getDb()
|
||||
.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> {
|
||||
const v = getVideo(id)
|
||||
export async function deleteVideo(folderId: number, id: string): Promise<void> {
|
||||
const v = getVideoInFolder(folderId, id)
|
||||
if (!v) return
|
||||
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 })
|
||||
}
|
||||
|
||||
// ─── 업로드 임시 파일 → 영상 디렉토리 이동 ─────────────────────────────
|
||||
|
||||
export async function moveUploadIntoVideoDir(
|
||||
folderId: number,
|
||||
videoId: string,
|
||||
tmpFile: string,
|
||||
destName: string
|
||||
@@ -507,7 +532,7 @@ export async function moveUploadIntoVideoDir(
|
||||
if (!destName || destName.includes('/') || destName.includes('\\') || destName.includes('..')) {
|
||||
throw new Error('잘못된 파일명입니다.')
|
||||
}
|
||||
const dir = videoDiskDir(videoId)
|
||||
const dir = videoDiskDir(folderId, videoId)
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
const target = path.join(dir, destName)
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user