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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 에는 올라가지 않음). 등록된 쿠키로도 못 보는 영상이면 그 영상을 볼 수 있는 계정의 쿠키가 필요합니다.
|
||||
|
||||
## 데이터 위치
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})()
|
||||
|
||||
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
|
||||
}
|
||||
// 실제 파일명 결정
|
||||
|
||||
@@ -59,6 +59,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||
|
||||
Reference in New Issue
Block a user