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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user