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

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@ data/app.db
data/app.db-journal data/app.db-journal
data/app.db-shm data/app.db-shm
data/app.db-wal data/app.db-wal
data/bin/
data/cookies.txt
.env .env
!.env.example !.env.example
*.log *.log

View File

@@ -35,6 +35,7 @@ npm start
- `yt-dlp` — YouTube 영상 가져오기. `npm run setup``./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다. Alpine / distroless 같이 glibc 가 없는 슬림 도커 베이스에서는 번들 바이너리가 안 도니 `apk add yt-dlp` (또는 `apt-get install yt-dlp`, `pip install yt-dlp`) 로 PATH 에 직접 설치하세요. 도커 빌드에서 아예 받지 않으려면 `SKIP_YT_DLP=1 npm run setup`. (검증 실패해도 setup 은 경고만 출력하고 빌드는 계속 진행됩니다.) - `yt-dlp` — YouTube 영상 가져오기. `npm run setup``./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다. Alpine / distroless 같이 glibc 가 없는 슬림 도커 베이스에서는 번들 바이너리가 안 도니 `apk add yt-dlp` (또는 `apt-get install yt-dlp`, `pip install yt-dlp`) 로 PATH 에 직접 설치하세요. 도커 빌드에서 아예 받지 않으려면 `SKIP_YT_DLP=1 npm run setup`. (검증 실패해도 setup 은 경고만 출력하고 빌드는 계속 진행됩니다.)
- `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다. - `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다.
- 하드웨어 인코더(`h264_nvenc` / `h264_qsv` / `h264_videotoolbox` / `h264_vaapi`) — 있으면 자동 감지해 사용. 없으면 `libx264 -preset ultrafast` 폴백. VAAPI 는 `/dev/dri/renderD128` 이 보일 때만 후보로 들어갑니다. - 하드웨어 인코더(`h264_nvenc` / `h264_qsv` / `h264_videotoolbox` / `h264_vaapi`) — 있으면 자동 감지해 사용. 없으면 `libx264 -preset ultrafast` 폴백. VAAPI 는 `/dev/dri/renderD128` 이 보일 때만 후보로 들어갑니다.
- YouTube 로그인 쿠키 — 연령 제한("Sign in to confirm your age") 영상은 로그인 세션 없이는 yt-dlp 가 받지 못합니다. 관리자 대시보드(`/op/dashboard`)의 "YouTube 쿠키" 패널에서 브라우저 확장으로 내보낸 Netscape 형식 `cookies.txt` 를 등록하면 단일 영상 probe·플레이리스트 probe·실제 다운로드에 모두 `--cookies` 로 적용됩니다. 파일은 `data/cookies.txt` 에 권한 `0600` 으로 저장됩니다(세션 토큰이 들어 있어 git 에는 올라가지 않음). 등록된 쿠키로도 못 보는 영상이면 그 영상을 볼 수 있는 계정의 쿠키가 필요합니다.
## 데이터 위치 ## 데이터 위치

View File

@@ -128,4 +128,74 @@
}) })
if (document.querySelector('.toolsSection')) loadTools() if (document.querySelector('.toolsSection')) loadTools()
// YouTube 쿠키 관리
var cookieSection = document.getElementById('cookiesSection')
if (cookieSection) {
var cookieStatusEl = document.getElementById('cookieStatus')
var cookieFileEl = document.getElementById('cookieFile')
var cookieUploadBtn = document.getElementById('cookieUploadBtn')
var cookieDeleteBtn = document.getElementById('cookieDeleteBtn')
var cookieMsgEl = document.getElementById('cookieMsg')
function renderCookies(c) {
if (c && c.present) {
var kb = Math.max(1, Math.round(c.bytes / 1024))
var when = c.updatedAt ? new Date(c.updatedAt).toLocaleString() : ''
cookieStatusEl.textContent = '등록됨 (' + kb + ' KB' + (when ? ' · ' + when : '') + ')'
cookieDeleteBtn.hidden = false
} else {
cookieStatusEl.textContent = '등록되지 않음'
cookieDeleteBtn.hidden = true
}
}
function loadCookies() {
fetch('/op/cookies').then(function (r) { return r.json() }).then(function (j) {
if (j.ok) renderCookies(j.cookies)
}).catch(function () {})
}
cookieUploadBtn.addEventListener('click', function () {
var file = cookieFileEl.files && cookieFileEl.files[0]
if (!file) { cookieMsgEl.textContent = '파일을 선택해 주세요.'; return }
var fd = new FormData()
fd.append('file', file)
cookieUploadBtn.disabled = true
cookieMsgEl.textContent = '등록 중…'
fetch('/op/cookies', { method: 'POST', body: fd })
.then(function (r) { return r.json() })
.then(function (j) {
if (j.ok) {
cookieMsgEl.textContent = '등록 완료'
cookieFileEl.value = ''
renderCookies(j.cookies)
} else {
cookieMsgEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
}
})
.catch(function (err) {
cookieMsgEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
})
.then(function () { cookieUploadBtn.disabled = false })
})
cookieDeleteBtn.addEventListener('click', function () {
if (!window.confirm('등록된 YouTube 쿠키를 삭제할까요?')) return
cookieDeleteBtn.disabled = true
cookieMsgEl.textContent = '삭제 중…'
fetch('/op/cookies/delete', { method: 'POST' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j.ok) { cookieMsgEl.textContent = '삭제됨'; renderCookies(j.cookies) }
else cookieMsgEl.textContent = '실패: ' + (j.message || '알 수 없는 오류')
})
.catch(function (err) {
cookieMsgEl.textContent = '실패: ' + (err && err.message ? err.message : '요청 오류')
})
.then(function () { cookieDeleteBtn.disabled = false })
})
loadCookies()
}
})() })()

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' } from '../youtube.js'
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js' import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js'
import { cookiesStatus, saveCookies, deleteCookies } from '../cookies.js'
import { collectFolderSegments } from './helpers.js' import { collectFolderSegments } from './helpers.js'
import { promises as fs } from 'node:fs'
export const opRouter = Router() export const opRouter = Router()
@@ -62,6 +64,12 @@ const upload = multer({
limits: { fileSize: uploadMaxBytes } limits: { fileSize: uploadMaxBytes }
}) })
// 쿠키 파일은 작다 — 별도 multer 로 1 MiB 로 제한해 대형 파일 오용을 막는다.
const cookieUpload = multer({
dest: tmpDir,
limits: { fileSize: 1024 * 1024 }
})
function pickStr(v: unknown): string { function pickStr(v: unknown): string {
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : '' if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
return typeof v === 'string' ? v : '' 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 { function folderViewHandler(req: Request, res: Response, next: NextFunction): void {
try { try {
const segments = collectFolderSegments(req.params) 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 { promises as fs } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { binDir, jobsDir, projectRoot } from './paths.js' import { binDir, jobsDir, projectRoot } from './paths.js'
import { getCookieArgs, hasCookies } from './cookies.js'
import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js' import { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
import { import {
createVideo, createVideo,
@@ -54,6 +55,22 @@ export function resetYtDlpResolution(): void {
resolvedYtDlpPath = null 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 { export interface ProbeResult {
title: string title: string
durationSec: number durationSec: number
@@ -70,6 +87,7 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
return new Promise<ProbeResult>((resolve, reject) => { return new Promise<ProbeResult>((resolve, reject) => {
const child = spawn(bin, [ const child = spawn(bin, [
'--no-warnings', '--no-warnings',
...getCookieArgs(),
'--skip-download', '--skip-download',
'--print', '--print',
'%(title)s\n%(duration)s\n%(filesize_approx)s' '%(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('error', (err) => reject(err))
child.on('close', (code) => { child.on('close', (code) => {
if (code !== 0) { if (code !== 0) {
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`)) reject(new Error(friendlyYtDlpError(stderr, `yt-dlp probe 실패 (code=${code})`)))
return return
} }
const lines = stdout.trim().split('\n') const lines = stdout.trim().split('\n')
@@ -125,6 +143,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목. // --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
const child = spawn(bin, [ const child = spawn(bin, [
'--no-warnings', '--no-warnings',
...getCookieArgs(),
'--flat-playlist', '--flat-playlist',
'--dump-json', '--dump-json',
url url
@@ -137,7 +156,7 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
child.on('error', (err) => reject(err)) child.on('error', (err) => reject(err))
child.on('close', (code) => { child.on('close', (code) => {
if (code !== 0) { 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 return
} }
const entries: PlaylistEntry[] = [] const entries: PlaylistEntry[] = []
@@ -348,6 +367,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱. // 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
const args = [ const args = [
'--no-warnings', '--no-warnings',
...getCookieArgs(),
'--no-playlist', '--no-playlist',
'--newline', '--newline',
// 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은 // 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은
@@ -426,7 +446,7 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
child.on('error', (err) => reject(err)) child.on('error', (err) => reject(err))
child.on('close', async (code) => { child.on('close', async (code) => {
if (code !== 0) { if (code !== 0) {
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`)) reject(new Error(friendlyYtDlpError(stderrTail, `yt-dlp 실패 (code=${code})`)))
return return
} }
// 실제 파일명 결정 // 실제 파일명 결정

View File

@@ -59,6 +59,23 @@
</div> </div>
</div> </div>
</section> </section>
<section class="toolsSection" id="cookiesSection">
<h2>YouTube 쿠키</h2>
<p class="muted">연령 제한("Sign in to confirm your age") 영상은 로그인된 계정의 쿠키가 있어야 다운로드됩니다. 브라우저 확장(예: "Get cookies.txt LOCALLY")으로 내보낸 Netscape 형식 <code>cookies.txt</code> 를 등록하세요. 단일 영상·플레이리스트·실제 다운로드에 모두 적용됩니다.</p>
<div class="toolCard">
<div class="toolHead">
<span class="toolName">cookies.txt</span>
<span class="toolVersion" id="cookieStatus">확인 중…</span>
</div>
<div class="toolRow">
<input type="file" id="cookieFile" accept=".txt,text/plain" />
<button type="button" class="secondaryButton" id="cookieUploadBtn">등록 / 교체</button>
<button type="button" class="dangerLink" id="cookieDeleteBtn" hidden>삭제</button>
<span class="toolStatus muted" id="cookieMsg"></span>
</div>
</div>
</section>
</main> </main>
<div class="ctxMenu" id="ctxMenu" hidden> <div class="ctxMenu" id="ctxMenu" hidden>