feat(folders): nested folder tree (max depth 2) + id-based mutations

- new storeDb.ts: DB+FS layer (folders w/ parent_id, videos w/ global id)
- depth cap: sub-folder cannot have children (enforced in createFolder)
- video id: editable anytime (changeVideoId), uniqueness via PK
- routes:
  - public /folder/:topName[/:subName], /video/:topName[/:subName]/:videoId
  - admin /op/folder/:topName[/:subName], id-based /op/folders/:id/*
    and /op/videos/:id/* (rename/changeId/delete/save + idAvailable check)
- views: breadcrumb + subFolder grid; 플레이리스트 추가 button shows at 2단계
- client JS: id-based mutation endpoints, share URL data attribute,
  in-site player pushes share URL to address bar
- store.ts slimmed to Account + readAccounts (FS layer moved to storeDb)
- pickIntId accepts numeric JSON bodies

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-31 23:36:02 +09:00
parent 3560dcb802
commit c5faa8808c
17 changed files with 1207 additions and 577 deletions

View File

@@ -4,13 +4,12 @@ import path from 'node:path'
import { jobsDir, projectRoot } from './paths.js'
import { upscaleOriginalTo60Fps } from './editor.js'
import {
loadVideoMeta,
newVideoId,
saveVideoMeta,
sanitizeFolderName,
videoDir,
type VideoMeta
} from './store.js'
createVideo,
getFolder,
getVideo,
updateVideo,
videoDiskDir
} from './storeDb.js'
export class YtDlpUnavailableError extends Error {
constructor(message?: string) {
@@ -94,7 +93,7 @@ export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
export interface DownloadJob {
id: string
folder: string
folderId: number
videoId: string
url: string
status: JobStatus
@@ -122,39 +121,33 @@ export function listActiveJobs(): DownloadJob[] {
}
export interface StartDownloadOpts {
folder: string
folderId: number
url: string
title?: string
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
videoId?: string
}
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
const safeFolder = sanitizeFolderName(opts.folder)
if (!safeFolder) throw new Error('폴더 이름이 올바르지 않습니다.')
const folder = getFolder(opts.folderId)
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
const bin = getYtDlpPath() // 없으면 즉시 던짐
const videoId = newVideoId()
const dir = videoDir(safeFolder, videoId)
await fs.mkdir(dir, { recursive: true })
const now = new Date().toISOString()
const meta: VideoMeta = {
id: videoId,
const video = await createVideo({
id: opts.videoId,
folderId: opts.folderId,
title: opts.title?.trim() || '제목 없음',
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
editedFile: null,
durationSec: null,
sourceType: 'youtube',
sourceUrl: opts.url,
trim: null,
createdAt: now,
updatedAt: now
}
await saveVideoMeta(safeFolder, meta)
sourceUrl: opts.url
})
const now = new Date().toISOString()
const job: DownloadJob = {
id: newVideoId(),
folder: safeFolder,
videoId,
id: 'job-' + Math.random().toString(36).slice(2, 14),
folderId: opts.folderId,
videoId: video.id,
url: opts.url,
status: 'queued',
progress: 0,
@@ -180,7 +173,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
job.message = '다운로드 시작'
await persistJob(job)
const dir = videoDir(job.folder, job.videoId)
const dir = videoDiskDir(job.videoId)
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
const args = [
@@ -288,10 +281,9 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
console.error('[youtube] 60fps 후처리 실패:', err)
}
const meta = await loadVideoMeta(job.folder, job.videoId)
const meta = getVideo(job.videoId)
if (meta) {
meta.originalFile = finalName
await saveVideoMeta(job.folder, meta)
updateVideo(job.videoId, { originalFile: finalName })
}
job.progress = 100
job.status = 'done'