Files
make_video_site/src/routes/op.ts
Claude (owner) 6ade552683 feat: 연령제한 영상용 YouTube 쿠키(cookies.txt) 등록 + yt-dlp --cookies 적용
연령 제한("Sign in to confirm your age") 영상이 플레이리스트 임포트에서
실패하던 문제 해결. 관리자 대시보드에서 Netscape cookies.txt 를 등록하면
단일/플레이리스트 probe 와 실제 다운로드에 모두 --cookies 로 적용된다.
쿠키는 data/cookies.txt 에 0600 으로 저장(git 제외). 연령/로그인 실패 시
yt-dlp 원문 대신 쿠키 등록을 안내하는 메시지를 표시.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 18:04:25 +09:00

713 lines
25 KiB
TypeScript

import { Router } from 'express'
import path from 'node:path'
import multer, { MulterError } from 'multer'
import type { Request, Response, NextFunction } from 'express'
import { requireAuth } from '../auth.js'
import { readAccounts } from '../store.js'
import {
changeVideoId,
createFolder,
createVideo,
deleteFolder,
deleteVideo,
folderPathNames,
getFolder,
getFolderByPath,
getVideoInFolder,
getVideoByIdGlobal,
isSafeVideoId,
listChildFolders,
listTopFolders,
listVideosInFolder,
moveUploadIntoVideoDir,
renameFolder,
updateVideo,
videoExistsInFolder,
savePlaylistDraft,
getPlaylistDraft,
deletePlaylistDraft
} from '../storeDb.js'
import { tmpDir } from '../paths.js'
import {
YtDlpUnavailableError,
getJob,
probeYoutube,
probeYoutubePlaylist,
startYoutubeDownload
} from '../youtube.js'
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js'
import { cookiesStatus, saveCookies, deleteCookies } from '../cookies.js'
import { collectFolderSegments } from './helpers.js'
import { promises as fs } from 'node:fs'
export const opRouter = Router()
// 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능.
const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024
const uploadMaxBytes = (() => {
const raw = process.env.UPLOAD_MAX_BYTES
if (raw === undefined || raw.trim() === '') return DEFAULT_UPLOAD_MAX_BYTES
const trimmed = raw.trim()
if (trimmed === '0' || trimmed.toLowerCase() === 'infinity') return Infinity
const n = Number(trimmed)
if (!Number.isFinite(n) || n <= 0) {
console.warn(
`[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`
)
return DEFAULT_UPLOAD_MAX_BYTES
}
return Math.max(1, Math.floor(n))
})()
const upload = multer({
dest: tmpDir,
limits: { fileSize: uploadMaxBytes }
})
// 쿠키 파일은 작다 — 별도 multer 로 1 MiB 로 제한해 대형 파일 오용을 막는다.
const cookieUpload = multer({
dest: tmpDir,
limits: { fileSize: 1024 * 1024 }
})
function pickStr(v: unknown): string {
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
return typeof v === 'string' ? v : ''
}
function pickIntId(v: unknown): number | null {
// JSON body 로 들어오면 number, form 으로 들어오면 string. 둘 다 지원.
let n: number
if (typeof v === 'number') {
n = v
} else {
const s = pickStr(v).trim()
if (!s) return null
n = Number(s)
}
if (!Number.isFinite(n) || n < 1 || Math.floor(n) !== n) return null
return n
}
// ─── 인증 ─────────────────────────────────────────────────────────────
opRouter.get('/op', (req, res) => {
if (req.session?.userId) {
res.redirect('/op/dashboard')
return
}
res.render('op/login', { error: null })
})
opRouter.post('/op', async (req, res, next) => {
try {
const password = pickStr(req.body.password)
const accounts = await readAccounts()
const matched = accounts.find((a) => a.password === password)
if (!matched) {
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
return
}
req.session.userId = matched.id
res.redirect('/op/dashboard')
} catch (err) {
next(err)
}
})
opRouter.post('/op/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/op')
})
})
// ─── 폴더 뷰 ──────────────────────────────────────────────────────────
opRouter.get('/op/dashboard', requireAuth, (req, res, next) => {
try {
const folders = listTopFolders()
res.render('op/dashboard', { userId: req.session.userId, folders })
} catch (err) {
next(err)
}
})
// ─── 외부 도구(ffmpeg/yt-dlp) 상태 + 최신 버전 재설치 ───────────────────
opRouter.get('/op/tools', requireAuth, (req, res) => {
res.json({ ok: true, tools: getToolsStatus() })
})
opRouter.post('/op/tools/:name/update', requireAuth, async (req, res) => {
try {
const name = req.params.name
let tool
if (name === 'yt-dlp') tool = await updateYtDlp()
else if (name === 'ffmpeg') tool = await updateFfmpeg()
else {
res.status(400).json({ ok: false, message: '알 수 없는 도구입니다.' })
return
}
res.json({ ok: true, tool })
} catch (err) {
res.status(500).json({ ok: false, message: (err as Error).message })
}
})
// ─── YouTube 로그인 쿠키 (연령 제한 영상 다운로드용) ───────────────────────
opRouter.get('/op/cookies', requireAuth, (req, res) => {
res.json({ ok: true, cookies: cookiesStatus() })
})
opRouter.post('/op/cookies', requireAuth, cookieUpload.single('file'), async (req, res) => {
const file = req.file
try {
if (!file) throw new Error('cookies.txt 파일을 선택해 주세요.')
const content = await fs.readFile(file.path, 'utf8')
const status = await saveCookies(content)
res.json({ ok: true, cookies: status })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
} finally {
if (file) await fs.rm(file.path, { force: true }).catch(() => {})
}
})
opRouter.post('/op/cookies/delete', requireAuth, async (req, res) => {
try {
await deleteCookies()
res.json({ ok: true, cookies: cookiesStatus() })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
try {
const segments = collectFolderSegments(req.params)
const folder = getFolderByPath(segments)
if (!folder) {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
const subFolders = folder.parentId === null ? listChildFolders(folder.id) : []
const videos = listVideosInFolder(folder.id)
res.render('op/folder', {
userId: req.session.userId,
folder,
breadcrumb: folderPathNames(folder.id),
subFolders,
videos,
isSubFolder: folder.parentId !== null
})
} catch (err) {
next(err)
}
}
opRouter.get('/op/folder/:topName', requireAuth, folderViewHandler)
opRouter.get('/op/folder/:topName/:subName', requireAuth, folderViewHandler)
// ─── 폴더 mutation (id 기반) ───────────────────────────────────────────
opRouter.post('/op/folders', requireAuth, async (req, res) => {
try {
const name = pickStr(req.body.name)
const parentIdRaw = req.body.parentId
const parentId =
parentIdRaw === undefined || parentIdRaw === null || parentIdRaw === ''
? null
: pickIntId(parentIdRaw)
if (parentIdRaw !== undefined && parentIdRaw !== null && parentIdRaw !== '' && parentId === null) {
throw new Error('상위 폴더 ID 가 올바르지 않습니다.')
}
const folder = await createFolder({ name, parentId })
res.json({ ok: true, folder })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/folders/:id/rename', requireAuth, async (req, res) => {
try {
const id = pickIntId(req.params.id)
if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
const newName = pickStr(req.body.newName)
const folder = await renameFolder(id, newName)
res.json({ ok: true, folder })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/folders/:id/delete', requireAuth, async (req, res) => {
try {
const id = pickIntId(req.params.id)
if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
await deleteFolder(id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
// ─── 영상 에디터 페이지 ────────────────────────────────────────────────
opRouter.get(
['/op/folder/:topName/video/editor', '/op/folder/:topName/:subName/video/editor'],
requireAuth,
(req, res, next) => {
try {
const segments = collectFolderSegments(req.params)
const folder = getFolderByPath(segments)
if (!folder) {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
// 영상은 1단계·2단계 폴더 어디서나 추가할 수 있다. (플레이리스트만 2단계 한정)
const videoId = typeof req.query.id === 'string' ? req.query.id : null
const video = videoId ? getVideoInFolder(folder.id, videoId) : null
res.render('op/editor', {
userId: req.session.userId,
folder,
breadcrumb: folderPathNames(folder.id),
video
})
} catch (err) {
next(err)
}
}
)
// ─── 플레이리스트 import 페이지 ─────────────────────────────────────────
opRouter.get(
'/op/folder/:topName/:subName/playlist/new',
requireAuth,
(req, res, next) => {
try {
const segments = collectFolderSegments(req.params)
const folder = getFolderByPath(segments)
if (!folder) {
res.status(404).send('폴더를 찾을 수 없습니다.')
return
}
if (folder.parentId === null) {
res.status(400).send('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.')
return
}
res.render('op/playlist', {
userId: req.session.userId,
folder,
breadcrumb: folderPathNames(folder.id)
})
} catch (err) {
next(err)
}
}
)
// ─── 플레이리스트 임시저장(draft) ──────────────────────────────────────
// 다운로드 시작 전 수정해둔 값(URL + 항목별 제목/ID/구간)을 폴더당 1개 저장/복원.
/** 하위 폴더를 경로로 해석. 못 찾거나 최상위면 throw. */
function requireSubFolder(req: Request): { id: number } {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
if (folder.parentId === null) {
throw new Error('플레이리스트는 하위 폴더에서만 사용할 수 있습니다.')
}
return folder
}
// 임시저장 JSON 크기 상한 (과도한 payload 방지). 2 MiB 면 수백 항목도 충분.
const DRAFT_MAX_CHARS = 2 * 1024 * 1024
opRouter.get(
'/op/folder/:topName/:subName/playlist/draft',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
const draft = getPlaylistDraft(folder.id)
res.json({ ok: true, draft })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
'/op/folder/:topName/:subName/playlist/draft',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
const data = req.body?.data
if (typeof data !== 'object' || data === null) {
throw new Error('저장할 데이터가 올바르지 않습니다.')
}
const json = JSON.stringify(data)
if (json.length > DRAFT_MAX_CHARS) {
throw new Error('임시저장 데이터가 너무 큽니다.')
}
const saved = savePlaylistDraft(folder.id, json)
res.json({ ok: true, updatedAt: saved.updatedAt })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
'/op/folder/:topName/:subName/playlist/draft/delete',
requireAuth,
(req, res) => {
try {
const folder = requireSubFolder(req)
deletePlaylistDraft(folder.id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
function uploadSingle(fieldName: string) {
const mw = upload.single(fieldName)
return (req: Request, res: Response, next: NextFunction) => {
mw(req, res, (err: unknown) => {
if (!err) return next()
if (err instanceof MulterError) {
const message =
err.code === 'LIMIT_FILE_SIZE'
? `파일이 너무 큽니다. (한도: ${uploadMaxBytes === Infinity ? '무제한' : uploadMaxBytes + ' bytes'}; UPLOAD_MAX_BYTES 환경변수로 조정)`
: `업로드 실패: ${err.message}`
res.status(413).json({ ok: false, code: err.code, message })
return
}
res.status(400).json({ ok: false, message: (err as Error).message || '업로드 실패' })
})
}
}
opRouter.post(
['/op/folder/:topName/video/upload', '/op/folder/:topName/:subName/video/upload'],
requireAuth,
uploadSingle('file'),
async (req, res) => {
try {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
const file = req.file
if (!file) throw new Error('파일이 없습니다.')
const title = pickStr(req.body?.title).trim() || file.originalname
const ext = (path.extname(file.originalname) || '.mp4').toLowerCase()
const destName = `original${ext}`
const video = await createVideo({
folderId: folder.id,
title,
originalFile: destName,
sourceType: 'upload'
})
await moveUploadIntoVideoDir(folder.id, video.id, file.path, destName)
res.json({ ok: true, videoId: video.id })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
['/op/folder/:topName/video/youtube/probe', '/op/folder/:topName/:subName/video/youtube/probe'],
requireAuth,
async (req, res) => {
try {
const url = pickStr(req.body.url).trim()
if (!url) throw new Error('URL 을 입력해 주세요.')
const probe = await probeYoutube(url)
res.json({ ok: true, probe })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.post(
['/op/folder/:topName/video/youtube/start', '/op/folder/:topName/:subName/video/youtube/start'],
requireAuth,
async (req, res) => {
try {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
const url = pickStr(req.body.url).trim()
const title = pickStr(req.body.title).trim() || undefined
if (!url) throw new Error('URL 을 입력해 주세요.')
const job = await startYoutubeDownload({ folderId: folder.id, url, title })
res.json({ ok: true, jobId: job.id, videoId: job.videoId })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
/** 플레이리스트 URL 을 받아 항목 목록을 돌려준다. (등록은 별도 start 호출) */
opRouter.post(
['/op/folder/:topName/:subName/playlist/probe'],
requireAuth,
async (req, res) => {
try {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
if (folder.parentId === null) {
throw new Error('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.')
}
const url = pickStr(req.body.url).trim()
if (!url) throw new Error('플레이리스트 URL 을 입력해 주세요.')
const result = await probeYoutubePlaylist(url)
res.json({ ok: true, entries: result.entries })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
/**
* UI 가 정리한 항목 목록을 받아 영상 row 를 만들고 다운로드 잡을 큐잉.
* body.entries: [{ url, title, videoId }]
* - videoId 는 클라이언트가 랜덤/시퀀셜 토글 결과로 이미 확정한 값
* - 형식/요청 내 중복/DB 충돌은 서버에서도 다시 검증 (정합성 + 동시성)
*/
opRouter.post(
['/op/folder/:topName/:subName/playlist/start'],
requireAuth,
async (req, res) => {
try {
const folder = getFolderByPath(collectFolderSegments(req.params))
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
if (folder.parentId === null) {
throw new Error('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.')
}
// yt-dlp 가 아예 없으면 항목을 만들지조차 못하므로 사전에 던진다.
// (단일 항목 시작 endpoint 와 동일한 정책)
// startYoutubeDownload 내부에서도 호출하지만, 일괄 등록 중간 실패를 막기 위해
// 먼저 한 번 확인한다.
// 결과는 무시 (path 캐싱 효과).
// — YtDlpUnavailableError 가 던져지면 아래 catch 에서 503 으로 응답.
// ts: getYtDlpPath 는 youtube.ts 에서 내부 함수라 import 하지 않고
// startYoutubeDownload 첫 호출 시점에 자연스럽게 노출됨.
const rawEntries = Array.isArray(req.body.entries) ? req.body.entries : []
if (rawEntries.length === 0) {
throw new Error('등록할 항목이 없습니다.')
}
type Cleaned = {
url: string
title: string
videoId: string
startSec: number
endSec: number | null
}
const cleaned: Cleaned[] = []
const seen = new Set<string>()
for (let i = 0; i < rawEntries.length; i += 1) {
const e = rawEntries[i] as Record<string, unknown>
const url = pickStr(e?.url).trim()
const title = pickStr(e?.title).trim()
const videoId = pickStr(e?.videoId).trim()
if (!url) throw new Error(`항목 ${i + 1}: URL 이 비어 있습니다.`)
if (!title) throw new Error(`항목 ${i + 1}: 제목이 비어 있습니다.`)
if (!videoId) throw new Error(`항목 ${i + 1}: 영상 ID 가 비어 있습니다.`)
if (!isSafeVideoId(videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID 형식이 올바르지 않습니다 (영문/숫자/_/- 1~64자).`)
}
if (seen.has(videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 요청 내에서 중복됩니다.`)
}
if (videoExistsInFolder(folder.id, videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
}
// 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리.
const startRaw = Number(e?.startSec)
const startSec = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0
const endRaw = Number(e?.endSec)
const endSec =
Number.isFinite(endRaw) && endRaw > startSec ? endRaw : null
seen.add(videoId)
cleaned.push({ url, title, videoId, startSec, endSec })
}
// 검증 통과 → 순차적으로 잡 시작. createVideo 의 unique 제약이 마지막 안전망.
const jobs: { jobId: string; videoId: string }[] = []
for (const entry of cleaned) {
const job = await startYoutubeDownload({
folderId: folder.id,
url: entry.url,
title: entry.title,
videoId: entry.videoId,
startSec: entry.startSec,
endSec: entry.endSec
})
jobs.push({ jobId: job.id, videoId: job.videoId })
}
res.json({ ok: true, jobs })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
}
)
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
// 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다.
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
res.set('Pragma', 'no-cache')
const job = getJob(req.params.id)
if (!job) {
res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' })
return
}
res.json({ ok: true, job })
})
// ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ───────
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */
function folderFromBody(req: Request): { id: number } {
const fid = pickIntId(req.body?.folderId)
if (fid === null) throw new Error('폴더 ID 가 올바르지 않습니다.')
const folder = getFolder(fid)
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
return folder
}
opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => {
try {
const folder = folderFromBody(req)
const id = req.params.id
const title = pickStr(req.body.title).trim()
if (!title) throw new Error('제목을 입력해 주세요.')
if (!getVideoInFolder(folder.id, id)) throw new Error('영상을 찾을 수 없습니다.')
updateVideo(folder.id, id, { title })
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => {
try {
const folder = folderFromBody(req)
const oldId = req.params.id
const newId = pickStr(req.body.newId).trim()
if (!newId) throw new Error('새 ID 를 입력해 주세요.')
const video = await changeVideoId(folder.id, oldId, newId)
res.json({ ok: true, video })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
/** 새 ID 가 (해당 폴더 안에서) 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */
opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => {
const id = typeof req.query.id === 'string' ? req.query.id.trim() : ''
const folderId = pickIntId(req.query.folderId)
if (!id) {
res.json({ ok: true, available: false, reason: '비어 있음' })
return
}
if (!isSafeVideoId(id)) {
res.json({
ok: true,
available: false,
reason: '형식: 영문/숫자/_/- (1~64자)'
})
return
}
if (folderId === null) {
res.json({ ok: true, available: false, reason: '폴더 정보 없음' })
return
}
const available = !videoExistsInFolder(folderId, id)
res.json({ ok: true, available })
})
opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => {
try {
const folder = folderFromBody(req)
await deleteVideo(folder.id, req.params.id)
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => {
try {
const folder = folderFromBody(req)
const id = req.params.id
const video = getVideoInFolder(folder.id, id)
if (!video) throw new Error('영상을 찾을 수 없습니다.')
const title = pickStr(req.body.title).trim()
const startSec = Number(req.body.startSec ?? 0) || 0
const endRaw = req.body.endSec
const endSec =
endRaw === null || endRaw === undefined || endRaw === ''
? null
: Number(endRaw)
const trim = {
startSec,
endSec: endSec == null || Number.isNaN(endSec) ? null : endSec
}
updateVideo(folder.id, id, { trim, ...(title ? { title } : {}) })
// ffmpeg 가 없으면 trim 정보만 저장하고 안내.
try {
const outName = await applyTrimToVideo(folder.id, id, trim)
res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' })
} catch (err) {
if (err instanceof FfmpegUnavailableError) {
res.json({
ok: true,
editedFile: null,
note: 'ffmpeg 가 설치되지 않아 편집본을 만들지 못했습니다. trim 설정만 저장됐습니다.'
})
return
}
throw err
}
} catch (err) {
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
/** 폴더 ID → admin URL. 클라이언트가 form action 만들 때 미사용이면 제거 가능. */
export function adminFolderUrl(folderId: number): string {
const names = folderPathNames(folderId)
return '/op/folder/' + names.map((s) => encodeURIComponent(s)).join('/')
}
/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */
export function videoShareUrl(videoId: string): string | null {
const v = getVideoByIdGlobal(videoId)
if (!v) return null
const names = folderPathNames(v.folderId)
return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/')
}