/** * DB(SQLite) 기반 폴더/영상 CRUD + 디스크 동기화 레이어. * * 디스크 레이아웃 (변경 없음): * data/folders/{topName}/[{subName}/]{videoId}/... * * DB 가 source of truth. 변경 흐름: * 1) 검증 (이름/ID/깊이/존재여부) * 2) DB UPDATE/INSERT/DELETE * 3) 디스크 동기화 (mkdir / rename / rm) * 4) 실패 시 DB 롤백 (사용 가능한 경우) * * DB → 디스크 순서로 가는 이유: DB 가 UNIQUE 등 제약을 검사해 주므로 충돌을 일찍 잡고 * 디스크 작업으로 안 넘어간다. 디스크 작업이 실패하면 DB 변경을 되돌린다. */ import { promises as fsp } from 'node:fs' import path from 'node:path' import { randomUUID } from 'node:crypto' import { getDb } from './db.js' import { foldersDir } from './paths.js' // ─── 검증 ────────────────────────────────────────────────────────────── const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u const SAFE_VIDEO_ID = /^[a-zA-Z0-9_-]{1,64}$/ export function sanitizeFolderName(raw: string): string { const trimmed = (raw || '').trim() if (!trimmed || trimmed.length > 80) return '' if (trimmed === '.' || trimmed === '..') return '' if (!SAFE_NAME.test(trimmed)) return '' return trimmed } export function isSafeVideoId(id: string): boolean { return typeof id === 'string' && SAFE_VIDEO_ID.test(id) } export function newRandomVideoId(): string { // URL-safe 24-char id (uuid 압축). return randomUUID().replace(/-/g, '').slice(0, 24) } // ─── 타입 ────────────────────────────────────────────────────────────── export interface Folder { id: number parentId: number | null name: string position: number createdAt: string updatedAt: string } export interface VideoTrim { startSec: number endSec: number | null } export interface Video { id: string folderId: number title: string originalFile: string editedFile: string | null durationSec: number | null sourceType: 'upload' | 'youtube' sourceUrl: string | null trim: VideoTrim | null position: number createdAt: string updatedAt: string } interface FolderRow { id: number parent_id: number | null name: string position: number created_at: string updated_at: string } interface VideoRow { id: string folder_id: number title: string original_file: string edited_file: string | null duration_sec: number | null source_type: 'upload' | 'youtube' source_url: string | null trim_start_sec: number | null trim_end_sec: number | null position: number created_at: string updated_at: string } function rowToFolder(r: FolderRow): Folder { return { id: r.id, parentId: r.parent_id, name: r.name, position: r.position, createdAt: r.created_at, updatedAt: r.updated_at } } function rowToVideo(r: VideoRow): Video { const hasTrim = r.trim_start_sec !== null || r.trim_end_sec !== null return { id: r.id, folderId: r.folder_id, title: r.title, originalFile: r.original_file, editedFile: r.edited_file, durationSec: r.duration_sec, sourceType: r.source_type, sourceUrl: r.source_url, trim: hasTrim ? { startSec: r.trim_start_sec ?? 0, endSec: r.trim_end_sec } : null, position: r.position, createdAt: r.created_at, updatedAt: r.updated_at } } // ─── 폴더 조회 ───────────────────────────────────────────────────────── export function listTopFolders(): Folder[] { const rows = getDb() .prepare( `SELECT * FROM folders WHERE parent_id IS NULL ORDER BY position, name` ) .all() as FolderRow[] return rows.map(rowToFolder) } export function listChildFolders(parentId: number): Folder[] { const rows = getDb() .prepare(`SELECT * FROM folders WHERE parent_id = ? ORDER BY position, name`) .all(parentId) as FolderRow[] return rows.map(rowToFolder) } export function getFolder(id: number): Folder | null { const r = getDb() .prepare(`SELECT * FROM folders WHERE id = ?`) .get(id) as FolderRow | undefined return r ? rowToFolder(r) : null } export function getTopFolderByName(name: string): Folder | null { const safe = sanitizeFolderName(name) if (!safe) return null const r = getDb() .prepare(`SELECT * FROM folders WHERE parent_id IS NULL AND name = ?`) .get(safe) as FolderRow | undefined return r ? rowToFolder(r) : null } export function getChildFolderByName(parentId: number, name: string): Folder | null { const safe = sanitizeFolderName(name) if (!safe) return null const r = getDb() .prepare(`SELECT * FROM folders WHERE parent_id = ? AND name = ?`) .get(parentId, safe) as FolderRow | undefined return r ? rowToFolder(r) : null } /** URL 경로 [topName] 또는 [topName, subName] → 폴더 1건. 못 찾으면 null. */ export function getFolderByPath(names: string[]): Folder | null { if (names.length === 0 || names.length > 2) return null const top = getTopFolderByName(names[0]) if (!top) return null if (names.length === 1) return top return getChildFolderByName(top.id, names[1]) } /** 폴더 → 디스크 경로 (parent 까지 거슬러 올라가 이름 join). */ export function folderDiskPath(folderId: number): string { const names = folderPathNames(folderId) return path.join(foldersDir, ...names) } /** 폴더 → URL 경로 세그먼트 배열 (인코딩 전). */ export function folderPathNames(folderId: number): string[] { const names: string[] = [] let cur: Folder | null = getFolder(folderId) while (cur) { names.unshift(cur.name) cur = cur.parentId !== null ? getFolder(cur.parentId) : null } return names } // ─── 폴더 변경 ───────────────────────────────────────────────────────── export async function createFolder(input: { name: string parentId: number | null }): Promise { const safe = sanitizeFolderName(input.name) if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') // 깊이 검사: 부모가 이미 서브폴더면 손자 폴더는 만들 수 없다 (최대 2단계). if (input.parentId !== null) { const parent = getFolder(input.parentId) if (!parent) throw new Error('상위 폴더를 찾을 수 없습니다.') if (parent.parentId !== null) { throw new Error('서브폴더 안에는 폴더를 만들 수 없습니다.') } } const now = new Date().toISOString() const db = getDb() // position = 현재 형제 개수 (끝에 추가) const siblings = (db .prepare( input.parentId === null ? `SELECT COUNT(*) as c FROM folders WHERE parent_id IS NULL` : `SELECT COUNT(*) as c FROM folders WHERE parent_id = ?` ) .get(...(input.parentId === null ? [] : [input.parentId])) as { c: number }).c let info try { info = db .prepare( `INSERT INTO folders (parent_id, name, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?)` ) .run(input.parentId, safe, siblings, now, now) } catch (err) { if ((err as Error).message.includes('UNIQUE')) { throw new Error('이미 존재하는 폴더입니다.') } throw err } const folderId = Number(info.lastInsertRowid) // 디스크 디렉토리 생성 (이미 있을 수 있음 — 마이그레이션 케이스). const diskPath = folderDiskPath(folderId) try { await fsp.mkdir(diskPath, { recursive: true }) } catch (err) { // 디스크 실패 시 DB 롤백. db.prepare(`DELETE FROM folders WHERE id = ?`).run(folderId) throw err } return getFolder(folderId)! } export async function renameFolder(folderId: number, newName: string): Promise { const folder = getFolder(folderId) if (!folder) throw new Error('폴더를 찾을 수 없습니다.') const safe = sanitizeFolderName(newName) if (!safe) throw new Error('새 폴더 이름이 올바르지 않습니다.') if (safe === folder.name) return folder const oldDisk = folderDiskPath(folderId) const db = getDb() try { db.prepare(`UPDATE folders SET name = ?, updated_at = ? WHERE id = ?`).run( safe, new Date().toISOString(), folderId ) } catch (err) { if ((err as Error).message.includes('UNIQUE')) { throw new Error('이미 존재하는 폴더 이름입니다.') } throw err } const newDisk = folderDiskPath(folderId) try { await fsp.rename(oldDisk, newDisk) } catch (err) { // ENOENT (디스크 디렉토리가 없는 경우) 는 무시하고 비어있는 새 디렉토리 생성. if ((err as NodeJS.ErrnoException).code === 'ENOENT') { await fsp.mkdir(newDisk, { recursive: true }) } else { // 디스크 rename 실패 → DB 롤백 db.prepare(`UPDATE folders SET name = ? WHERE id = ?`).run(folder.name, folderId) throw err } } return getFolder(folderId)! } export async function deleteFolder(folderId: number): Promise { const folder = getFolder(folderId) if (!folder) return // idempotent const diskPath = folderDiskPath(folderId) // DB CASCADE 가 자식 폴더/영상까지 모두 삭제. getDb().prepare(`DELETE FROM folders WHERE id = ?`).run(folderId) await fsp.rm(diskPath, { recursive: true, force: true }) } // ─── 영상 조회 ───────────────────────────────────────────────────────── /** 폴더 안에서 영상 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 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 } export function listVideosInFolder(folderId: number): Video[] { const rows = getDb() .prepare( `SELECT * FROM videos WHERE folder_id = ? ORDER BY position, created_at DESC` ) .all(folderId) as VideoRow[] return rows.map(rowToVideo) } export function videoExistsInFolder(folderId: number, id: string): boolean { if (!isSafeVideoId(id)) return false const r = getDb() .prepare(`SELECT 1 FROM videos WHERE folder_id = ? AND id = ?`) .get(folderId, id) return !!r } /** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). DB 조회 없이 폴더경로로 합성. */ export function videoDiskDir(folderId: number, id: string): string { return path.join(folderDiskPath(folderId), id) } /** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */ export function videoPathNames(folderId: number, id: string): string[] { return [...folderPathNames(folderId), id] } /** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */ 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(folderId, id), rel) } // ─── 영상 변경 ───────────────────────────────────────────────────────── export interface CreateVideoInput { id?: string // 미지정 시 랜덤 folderId: number title: string originalFile: string // 디렉토리 안 상대 경로 (또는 'original.%(ext)s' 같은 yt-dlp 임시 패턴) sourceType: 'upload' | 'youtube' sourceUrl?: string | null } export async function createVideo(input: CreateVideoInput): Promise