From 6ade55268363f4a59e82d9f005b9d742cfd01a91 Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Sat, 6 Jun 2026 18:04:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=B0=EB=A0=B9=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EC=98=81=EC=83=81=EC=9A=A9=20YouTube=20=EC=BF=A0=ED=82=A4(cook?= =?UTF-8?q?ies.txt)=20=EB=93=B1=EB=A1=9D=20+=20yt-dlp=20--cookies=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 연령 제한("Sign in to confirm your age") 영상이 플레이리스트 임포트에서 실패하던 문제 해결. 관리자 대시보드에서 Netscape cookies.txt 를 등록하면 단일/플레이리스트 probe 와 실제 다운로드에 모두 --cookies 로 적용된다. 쿠키는 data/cookies.txt 에 0600 으로 저장(git 제외). 연령/로그인 실패 시 yt-dlp 원문 대신 쿠키 등록을 안내하는 메시지를 표시. Co-Authored-By: Claude Opus 4 --- .gitignore | 2 + README.md | 1 + public/dashboard.js | 70 +++++++++++++++++++++++++++++++++ src/cookies.ts | 89 ++++++++++++++++++++++++++++++++++++++++++ src/routes/op.ts | 36 +++++++++++++++++ src/youtube.ts | 26 ++++++++++-- views/op/dashboard.ejs | 17 ++++++++ 7 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/cookies.ts diff --git a/.gitignore b/.gitignore index 865251b..9f47b10 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ data/app.db data/app.db-journal data/app.db-shm data/app.db-wal +data/bin/ +data/cookies.txt .env !.env.example *.log diff --git a/README.md b/README.md index 626b75f..250e786 100644 --- a/README.md +++ b/README.md @@ -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 은 경고만 출력하고 빌드는 계속 진행됩니다.) - `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다. - 하드웨어 인코더(`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 에는 올라가지 않음). 등록된 쿠키로도 못 보는 영상이면 그 영상을 볼 수 있는 계정의 쿠키가 필요합니다. ## 데이터 위치 diff --git a/public/dashboard.js b/public/dashboard.js index 38ff717..576b033 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -128,4 +128,74 @@ }) 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() + } })() diff --git a/src/cookies.ts b/src/cookies.ts new file mode 100644 index 0000000..080ab05 --- /dev/null +++ b/src/cookies.ts @@ -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 { + 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 { + await fs.rm(cookiesPath, { force: true }) +} diff --git a/src/routes/op.ts b/src/routes/op.ts index 589d080..3a4e978 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -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) diff --git a/src/youtube.ts b/src/youtube.ts index bb98237..acca4cc 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -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 { return new Promise((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 { 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 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 { // 진행률은 --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 { 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 } // 실제 파일명 결정 diff --git a/views/op/dashboard.ejs b/views/op/dashboard.ejs index 6460d40..b419f4d 100644 --- a/views/op/dashboard.ejs +++ b/views/op/dashboard.ejs @@ -59,6 +59,23 @@ + +
+

YouTube 쿠키

+

연령 제한("Sign in to confirm your age") 영상은 로그인된 계정의 쿠키가 있어야 다운로드됩니다. 브라우저 확장(예: "Get cookies.txt LOCALLY")으로 내보낸 Netscape 형식 cookies.txt 를 등록하세요. 단일 영상·플레이리스트·실제 다운로드에 모두 적용됩니다.

+
+
+ cookies.txt + 확인 중… +
+
+ + + + +
+
+