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:
89
src/cookies.ts
Normal file
89
src/cookies.ts
Normal 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 })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
// 실제 파일명 결정
|
||||
|
||||
Reference in New Issue
Block a user