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>
This commit is contained in:
Claude (owner)
2026-06-06 18:04:25 +09:00
parent 03e97d771d
commit 6ade552683
7 changed files with 238 additions and 3 deletions

89
src/cookies.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* YouTube 로그인 쿠키(Netscape `cookies.txt`) 관리.
*
* 동기:
* - 연령 제한("Sign in to confirm your age") 영상은 로그인 세션 없이는 yt-dlp 가
* 받지 못한다. 브라우저에서 내보낸 Netscape cookies.txt 를 `--cookies` 로 넘기면
* 그 계정 권한으로 다운로드된다.
* - 쿠키에는 세션 토큰이 들어 있어 민감하므로 data 볼륨 하위에 0600 으로 보관한다.
* (data 볼륨이라 컨테이너 재생성에도 유지된다.)
*/
import { promises as fs, existsSync, statSync } from 'node:fs'
import path from 'node:path'
import { dataDir } from './paths.js'
/** 등록된 쿠키 파일 경로 (data 볼륨 하위, 영속). */
export const cookiesPath = path.join(dataDir, 'cookies.txt')
export interface CookiesStatus {
present: boolean
bytes: number
updatedAt: string | null
}
/** 현재 등록된 쿠키 상태 (UI 표시용). */
export function cookiesStatus(): CookiesStatus {
try {
const st = statSync(cookiesPath)
if (!st.isFile() || st.size === 0) return { present: false, bytes: 0, updatedAt: null }
return { present: true, bytes: st.size, updatedAt: st.mtime.toISOString() }
} catch {
return { present: false, bytes: 0, updatedAt: null }
}
}
/**
* yt-dlp 에 넘길 쿠키 인자. 등록돼 있고 비어 있지 않을 때만 `['--cookies', 경로]`,
* 아니면 빈 배열. spawn 직전에 동기로 호출하므로 존재 여부만 빠르게 확인한다.
*/
export function getCookieArgs(): string[] {
try {
if (existsSync(cookiesPath) && statSync(cookiesPath).size > 0) {
return ['--cookies', cookiesPath]
}
} catch {
/* 무시 — 쿠키 없이 진행 */
}
return []
}
/** 등록된 쿠키가 있는지 (에러 메시지 분기용). */
export function hasCookies(): boolean {
return getCookieArgs().length > 0
}
/**
* Netscape cookies.txt 로 보이는지 가볍게 검증한다. 사용자가 실수로 HTML/JSON 등
* 엉뚱한 파일을 올리면 조용히 저장돼 다운로드가 계속 실패하므로 미리 막는다.
* - `# Netscape HTTP Cookie File` 헤더가 있거나
* - 탭으로 7개 필드가 나뉜 쿠키 라인이 한 줄이라도 있으면 통과.
*/
function looksLikeNetscapeCookies(text: string): boolean {
if (/^#\s*(Netscape\s+)?HTTP\s+Cookie\s+File/im.test(text)) return true
for (const line of text.split(/\r?\n/)) {
if (!line || line.startsWith('#')) continue
if (line.split('\t').length >= 7) return true
}
return false
}
/** 쿠키 내용을 검증 후 0600 으로 저장한다. */
export async function saveCookies(content: string): Promise<CookiesStatus> {
const text = content.replace(/\r\n/g, '\n')
if (!text.trim()) throw new Error('쿠키 파일이 비어 있습니다.')
if (!looksLikeNetscapeCookies(text)) {
throw new Error(
'Netscape 형식 cookies.txt 가 아닙니다. 브라우저 확장(Get cookies.txt 등)으로 내보낸 파일을 올려 주세요.'
)
}
await fs.mkdir(dataDir, { recursive: true })
await fs.writeFile(cookiesPath, text, { mode: 0o600 })
// 기존 파일이 더 느슨한 권한이었을 수 있으니 명시적으로 다시 조인다.
await fs.chmod(cookiesPath, 0o600).catch(() => {})
return cookiesStatus()
}
/** 등록된 쿠키를 삭제한다. 없으면 그냥 통과. */
export async function deleteCookies(): Promise<void> {
await fs.rm(cookiesPath, { force: true })
}

View File

@@ -37,7 +37,9 @@ import {
} 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()
@@ -62,6 +64,12 @@ const upload = multer({
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 : ''
@@ -145,6 +153,34 @@ opRouter.post('/op/tools/:name/update', requireAuth, async (req, res) => {
}
})
// ─── 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)

View File

@@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { binDir, jobsDir, projectRoot } from './paths.js'
import { getCookieArgs, hasCookies } from './cookies.js'
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
import {
createVideo,
@@ -54,6 +55,22 @@ export function resetYtDlpResolution(): void {
resolvedYtDlpPath = null
}
/**
* yt-dlp stderr 가 연령/로그인 확인을 요구하면 사용자 친화 메시지로 바꾼다.
* 그 외에는 원본 stderr 를 그대로 돌려준다.
*/
function friendlyYtDlpError(stderr: string, fallback: string): string {
const text = stderr.trim()
if (/Sign in to confirm your age|confirm your age|inappropriate for some users|Sign in to confirm you'?re not a bot|members[- ]only|account.+cookies/i.test(text)) {
const base =
'이 영상은 연령 제한/로그인이 필요합니다. 관리자 대시보드의 "YouTube 쿠키" 에서 로그인된 계정의 cookies.txt 를 등록한 뒤 다시 시도해 주세요.'
return hasCookies()
? base + ' (현재 등록된 쿠키로는 이 영상을 볼 수 없습니다 — 해당 영상을 볼 수 있는 계정의 쿠키가 필요합니다.)'
: base
}
return text || fallback
}
export interface ProbeResult {
title: string
durationSec: number
@@ -70,6 +87,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
return new Promise<ProbeResult>((resolve, reject) => {
const child = spawn(bin, [
'--no-warnings',
...getCookieArgs(),
'--skip-download',
'--print',
'%(title)s\n%(duration)s\n%(filesize_approx)s'
@@ -82,7 +100,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp probe 실패 (code=${code})`)))
return
}
const lines = stdout.trim().split('\n')
@@ -125,6 +143,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
const child = spawn(bin, [
'--no-warnings',
...getCookieArgs(),
'--flat-playlist',
'--dump-json',
url
@@ -137,7 +156,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `yt-dlp playlist probe 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderr, `yt-dlp playlist probe 실패 (code=${code})`)))
return
}
const entries: PlaylistEntry[] = []
@@ -348,6 +367,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
const args = [
'--no-warnings',
...getCookieArgs(),
'--no-playlist',
'--newline',
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
@@ -426,7 +446,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
child.on('error', (err) => reject(err))
child.on('close', async (code) => {
if (code !== 0) {
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
reject(new Error(friendlyYtDlpError(stderrTail, `yt-dlp 실패 (code=${code})`)))
return
}
// 실제 파일명 결정