/** * SQLite (better-sqlite3) 기반 메타데이터 저장소. * * 디스크 레이아웃은 기존 그대로 유지한다: * data/folders/{topName}/[{subName}/]{videoId}/{original|edited.*} * * DB 는 그 트리의 source of truth 인덱스 역할. 동기화 규칙: * - 폴더/영상 생성 시 mkdir 과 INSERT 를 같이 한다. * - 폴더/영상 rename 시 fs.rename 과 UPDATE 를 같이 한다. * - 폴더/영상 삭제 시 rm -rf 와 DELETE 를 같이 한다. * - 디스크와 DB 가 어긋난 경우 (수동 손상 등) initDatabase() 마이그레이션이 * 디스크 트리를 스캔해 DB 를 다시 채운다. * * 폴더 깊이 제약: 최상위(parent_id IS NULL) 또는 그 자식(서브폴더) 까지만. * 서브폴더는 자식 폴더를 가질 수 없다. 코드 레벨에서 검사. * * 영상 ID: 사용자 편집 가능. 전역 유일 (PRIMARY KEY). 변경 시 디렉토리 이름도 같이 바뀐다. */ import Database from 'better-sqlite3' import { promises as fsp } from 'node:fs' import path from 'node:path' import { dataDir, dbPath, foldersDir } from './paths.js' let _db: Database.Database | null = null export function getDb(): Database.Database { if (!_db) throw new Error('DB 가 아직 초기화되지 않았습니다. initDatabase() 를 먼저 호출하세요.') return _db } /** * DB 파일을 열고 스키마를 보장한 뒤, 비어 있으면 기존 디스크 구조를 스캔해 채워넣는다. * 앱 시작 시 1회 호출. */ export async function initDatabase(): Promise { await fsp.mkdir(dataDir, { recursive: true }) await fsp.mkdir(foldersDir, { recursive: true }) const db = new Database(dbPath) // 동시 쓰기는 거의 없고 안정성/성능 균형용. db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') db.pragma('synchronous = NORMAL') createSchema(db) _db = db // 비어 있으면 디스크 스캔 → 마이그레이션. const folderCount = db.prepare('SELECT COUNT(*) as c FROM folders').get() as { c: number } if (folderCount.c === 0) { await migrateFromDisk(db) } } function createSchema(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER NULL REFERENCES folders(id) ON DELETE CASCADE, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(parent_id, name) ); CREATE INDEX IF NOT EXISTS folders_parent_idx ON folders(parent_id); -- SQLite 의 UNIQUE 는 NULL 끼리 distinct 로 본다. 최상위(parent_id IS NULL) 동명 폴더를 -- 막으려면 partial unique index 가 별도로 필요. CREATE UNIQUE INDEX IF NOT EXISTS folders_unique_top ON folders(name) WHERE parent_id IS NULL; CREATE TABLE IF NOT EXISTS videos ( id TEXT PRIMARY KEY, 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 ); CREATE INDEX IF NOT EXISTS videos_folder_idx ON videos(folder_id); `) } /** * 기존 디스크 트리 (data/folders/{name}/{videoId}/meta.json) 를 스캔해서 DB 에 적재. * 기존은 1단계(폴더→영상) 만 있으므로 서브폴더는 생기지 않는다. */ async function migrateFromDisk(db: Database.Database): Promise { let topEntries: string[] try { topEntries = await fsp.readdir(foldersDir) } catch { return } const now = new Date().toISOString() const insertFolder = db.prepare( `INSERT INTO folders (parent_id, name, position, created_at, updated_at) VALUES (NULL, ?, ?, ?, ?)` ) const insertVideo = db.prepare( `INSERT INTO videos (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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) let folderPos = 0 let imported = { folders: 0, videos: 0 } for (const topName of topEntries.sort()) { const topPath = path.join(foldersDir, topName) const stat = await fsp.stat(topPath).catch(() => null) if (!stat || !stat.isDirectory()) continue if (!isSafeFolderName(topName)) continue const info = insertFolder.run(topName, folderPos++, now, now) const folderId = Number(info.lastInsertRowid) imported.folders++ const videoEntries = await fsp.readdir(topPath).catch(() => []) let videoPos = 0 for (const videoId of videoEntries.sort()) { const vPath = path.join(topPath, videoId) const vStat = await fsp.stat(vPath).catch(() => null) if (!vStat || !vStat.isDirectory()) continue if (!isSafeVideoId(videoId)) continue const metaPath = path.join(vPath, 'meta.json') const meta = await readJsonSafe(metaPath) if (!meta) continue const sourceType = meta.sourceType === 'upload' || meta.sourceType === 'youtube' ? meta.sourceType : 'upload' try { insertVideo.run( videoId, folderId, typeof meta.title === 'string' ? meta.title : '제목 없음', typeof meta.originalFile === 'string' ? meta.originalFile : 'original', typeof meta.editedFile === 'string' ? meta.editedFile : null, typeof meta.durationSec === 'number' ? meta.durationSec : null, sourceType, typeof meta.sourceUrl === 'string' ? meta.sourceUrl : null, meta.trim && typeof meta.trim.startSec === 'number' ? meta.trim.startSec : null, meta.trim && typeof meta.trim.endSec === 'number' ? meta.trim.endSec : null, videoPos++, typeof meta.createdAt === 'string' ? meta.createdAt : now, typeof meta.updatedAt === 'string' ? meta.updatedAt : now ) imported.videos++ } catch (err) { console.warn(`[db] migrate video skip (${topName}/${videoId}):`, (err as Error).message) } } } if (imported.folders > 0 || imported.videos > 0) { console.log(`[db] 마이그레이션 완료: 폴더 ${imported.folders}, 영상 ${imported.videos}`) } } // ─── 공통 검증 / 유틸 (store.ts 와 중복되지만 의존 방향을 끊기 위해 여기 다시 둠) ── const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u function isSafeFolderName(name: string): boolean { if (!name || name.length > 80) return false if (name === '.' || name === '..') return false return SAFE_NAME.test(name) } function isSafeVideoId(id: string): boolean { return /^[a-zA-Z0-9_-]{1,64}$/.test(id) } async function readJsonSafe(p: string): Promise | null> { try { const raw = await fsp.readFile(p, 'utf8') const v = JSON.parse(raw) return v && typeof v === 'object' ? v : null } catch { return null } }