First step of the folder/video refactor. Adds an app-local SQLite DB at
data/app.db with schema for nested folders (max 2 levels enforced in
code, not SQL) and user-editable globally-unique video IDs.
- src/db.ts: open + WAL + schema (folders, videos) + partial unique
index for top-level folder names (NULL parent_id case)
- One-shot disk-tree migration: on first start scans data/folders/*
and seeds folder rows; reads each {videoId}/meta.json to seed video
rows. Skips invalid/missing meta gracefully.
- Wired into app.ts startup before routes mount.
- gitignore: data/app.db* (DB + WAL/journal sidecars).
Schema only — no routes or store.ts changes yet. Next step swaps the
file-based store calls to DB-backed equivalents.
Smoke-tested on the current data dir: 3 folders migrated, 0 videos
(folders were empty). Build (npx tsc) passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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<Record<string, any> | null> {
|
|
try {
|
|
const raw = await fsp.readFile(p, 'utf8')
|
|
const v = JSON.parse(raw)
|
|
return v && typeof v === 'object' ? v : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|