feat: 쿠키 파일 기반 설정(YOUTUBE_COOKIE_PATH)+리졸버 URL 설정화, 다음곡 프리페치로 재생지연 단축, ytresolver 리포 포함, 쿠키 재발급 스크립트, README 상세화

This commit is contained in:
tkrmagid
2026-09-11 02:34:55 +09:00
parent e198c5244a
commit 56bea22e4a
7 changed files with 472 additions and 21 deletions

146
README.md
View File

@@ -1,13 +1,137 @@
# music bot v2
## 디스코드 음악봇 v2
# music_bot_v2
### 마지막으로 만들었던 내용
- 검색하고 재생 누르면 재생됨
- 플레이리스트 재생 기능(주소 전달해서 재생하는 방식)
- 아래 재생바 연동
- 오른쪽 재생목록 연동
디스코드 음악봇 v2. 검색·재생·대기열·자동재생·성인인증(연령제한) 영상 재생을 지원한다.
`discord.js` + `shoukaku`(Lavalink 클라이언트) 기반이며, 유튜브 재생은 Lavalink `youtube-plugin`을
쓰되 **연령제한/SABR로 막히는 영상은 yt-dlp 리졸버로 직접 오디오 URL을 뽑아 Lavalink `http` 소스로 우회 재생**한다.
### 이제 만들어야되는 내용
- 왼쪽 플레이리스트 가져오기 제작
- 플레이리스트 재생 추가
- claude code 사용해서 개발중
---
## 1. 아키텍처
```
[Discord] ──▶ 봇 컨테이너(music1/2/3) ── shoukaku ──▶ Lavalink (.6:2333)
(.5 호스트, docker) │ └ youtube-plugin (일반 영상)
│
재생 직전 리졸브 요청
▼
ytresolver (.5:8779, systemd)
└ yt-dlp (+쿠키) → 직접 오디오 URL
│
그 URL을 Lavalink http 소스로 재생
```
- **봇**: `.5` 호스트에서 docker compose 로 `music1/2/3` 3개 인스턴스 구동. 소스는 `bot/`.
- **Lavalink**: `.6` 호스트(`192.168.10.6:2333`). 일반 유튜브/스포티파이 미러 재생 담당.
- **ytresolver**: `.5` 호스트에서 systemd 로 구동(`:8779`). 연령제한/SABR 우회용 오디오 URL 리졸버.
- **Redis**(선택): `.7`. 대시보드(`page/`) 상태 연동.
- `.5`와 `.6`은 **같은 공인 IP**를 쓰므로, `.5`에서 뽑은 googlevideo URL을 `.6` Lavalink가 그대로 재생할 수 있다.
### 왜 리졸버가 필요한가
Lavalink `youtube-plugin`은 OAuth 로그인만 지원하는데, 유튜브는 **한국 19금(본인인증 연령제한)** 스트림을
OAuth 세션엔 안 열어준다(“This video requires login”). 반면 **웹 쿠키 세션**엔 열어준다. 그래서 쿠키를 쓸 수 있는
`yt-dlp`로 직접 오디오 URL을 뽑아 Lavalink `http` 소스로 재생하는 우회 경로를 둔다. 일반 영상은 계정을 태우지
않도록 **익명 먼저 시도 → 실패 시에만 쿠키 재시도** 한다.
---
## 2. 설치 / 배포 (.5 호스트)
전제: `.5`에 Docker, `/root/bot/music_bot_v2-compose.yml`(3개 인스턴스 정의), `/root/bot/db`(sqlite + 쿠키) 존재.
```bash
# 1) 소스 최신화
cd /root/bot/music_bot_v2 && git pull
# 2) 봇 3개 빌드 + 재기동
cd /root/bot && docker compose -f music_bot_v2-compose.yml up -d --build
# 3) ytresolver(리졸버) 설치/갱신 (최초 1회 또는 리졸버 코드 변경 시)
cp /root/bot/music_bot_v2/resolver/ytresolver.py /opt/ytresolver.py
cp /root/bot/music_bot_v2/resolver/ytresolver.service /etc/systemd/system/ytresolver.service
systemctl daemon-reload && systemctl enable --now ytresolver
systemctl restart ytresolver # 코드 갱신 시
```
빌드는 `bot/Dockerfile`(node:20-alpine, `npm run build`=tsc)로 이루어진다.
### 인스턴스 정의(`/root/bot/music_bot_v2-compose.yml`)
`music1/2/3` 세 서비스가 각자 다른 `TOKEN/APPID/PREFIX/DBPATH`로 뜬다. `/root/bot/db:/db` 볼륨을 공유하므로
sqlite DB(`music1.db` 등)와 쿠키(`ytcookie.txt`)가 컨테이너에서 `/db/...`로 보인다.
---
## 3. 환경변수 (`bot/.env`)
전체 목록/설명은 [`bot/.env.example`](bot/.env.example) 참고. 핵심만:
| 변수 | 설명 |
|------|------|
| `TOKEN` / `APPID` | 디스코드 봇 토큰 / 앱 ID |
| `PREFIX` | 명령어 접두사(예: `m1;`) |
| `DBPATH` | sqlite 경로(컨테이너 기준, 예 `/db/music1.db`) |
| `LAVALINK_HOST/PORT/PW` | Lavalink(.6:2333) 접속 정보 |
| `SPOTIFY_CLIENTID/SECRET` | 스포티파이 검색/미러 |
| `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 |
| `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 |
| `YTRESOLVER_URL` | 리졸버 주소(기본 `http://192.168.10.5:8779`) |
| `REDIS/REDIS_HOST/REDIS_PORT` | 대시보드 상태 연동(선택) |
> `bot/.gitignore`가 `*.env`, `*.db`를 제외하므로 실제 `.env`와 DB는 커밋되지 않는다.
---
## 4. 유튜브 쿠키 (`ytcookie.txt`) — 성인인증/연령제한 재생의 핵심
- **무엇**: 유튜브에 로그인된 계정의 세션 쿠키(Netscape `cookies.txt` 형식). yt-dlp가 연령제한 영상의
스트림을 받으려면 이 쿠키가 필요하다. 봇의 유튜브뮤직 검색도 이 쿠키를 헤더 문자열로 변환해 쓴다.
- **위치**: `.5` 호스트 `/root/bot/db/ytcookie.txt` (= 컨테이너 `/db/ytcookie.txt`, 리졸버도 같은 파일을 읽음).
- **계정**: 전용(버리는) 구글 계정을 쓰고 성인인증(본인확인)을 해둔 것. 본계정 사용 금지(밴 리스크).
- **형식(Netscape)** 예:
```
# Netscape HTTP Cookie File
.youtube.com TRUE / TRUE 2000000000 SID g.a000...
.youtube.com TRUE / TRUE 2000000000 LOGIN_INFO AFm...
```
최소한 `SID`, `LOGIN_INFO`, `__Secure-1PSID/3PSID`, `SAPISID` 등 로그인 쿠키가 있어야 한다.
### 쿠키 만료 시 재발급 (증상: 19금만 안 되고 일반 영상은 정상)
방법 1 — 스크립트(Chromium 자동/수동 로그인):
```bash
cd /root/bot/music_bot_v2
npm i -D playwright && npx playwright install chromium # 최초 1회
GOOGLE_EMAIL=<계정> GOOGLE_PASSWORD=<비번> \
OUT=/root/bot/db/ytcookie.txt node scripts/refresh-cookies.mjs
# 구글이 자동 로그인을 막으면 뜬 창에서 직접 로그인 → 자동 저장됨(디스플레이 있는 환경에서 실행)
```
방법 2 — 브라우저 확장(수동): PC 크롬에 `Get cookies.txt LOCALLY` 설치 → 전용 계정으로 youtube 로그인 →
Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
재발급 후: `systemctl restart ytresolver` 하면 즉시 반영(봇 재시작 불필요, 리졸버는 매 요청마다 파일을 읽음).
계정/비밀번호는 별도 안전한 곳(비밀번호 관리자/서버 secrets)에 보관한다.
---
## 5. 사용법 (디스코드)
- 지정 채널에 `PREFIX + 검색어/URL` → 검색 후 재생, 대기열 추가.
- 임베드의 버튼으로 일시정지/스킵/셔플/볼륨/자동재생 토글.
- 지원 소스: 유튜브(검색/URL), 유튜브뮤직, 스포티파이(미러), 플레이리스트.
- 자동재생: 마지막 곡 기준 추천(스포티파이 rec → 실패 시 유튜브 RD 믹스 폴백).
---
## 6. 성능(재생 시작 지연) 메모
- 봇은 **다음 곡을 미리 리졸브(prefetch)** 해 곡 전환 시 대기시간을 없앤다(`GuildPlayer` 참조).
- 리졸버는 리졸브된 URL을 30분 캐시한다(같은 곡 재생/재시도 시 즉시).
- 첫 곡 지연의 대부분은 `yt-dlp` 호출 비용이다. 익명 성공 시 1회, 연령제한이면 익명 실패 후 쿠키 재시도로 2회가 든다.
---
## 7. 트러블슈팅
- **19금만 재생 안 됨**: 쿠키 만료 → 4장 참고해 `ytcookie.txt` 재발급 후 `systemctl restart ytresolver`.
- **전부 재생 안 됨**: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. `systemctl status ytresolver`,
`curl http://192.168.10.5:8779/health`, Lavalink `:2333`.
- **유튜브뮤직 검색 0건**: 현재 알려진 이슈(`YoutubeMusic.getSearchFull`). 재생 자체는 `ytsearch` 폴백으로 동작.
- **yt-dlp EJS(시그니처) 오류**: 리졸버는 연령제한 영상에 `--js-runtimes node`가 필요하다(node 설치 필수).

40
bot/.env.example Normal file
View File

@@ -0,0 +1,40 @@
# ===== music_bot_v2 봇 환경변수 예시 =====
# 실제 값은 bot/.env 에 넣는다. (bot/.gitignore 의 *.env 로 커밋되지 않음)
# --- 디스코드 봇 ---
TOKEN=디스코드_봇_토큰
APPID=디스코드_애플리케이션_ID
PREFIX=m1; # 명령어 접두사 (예: m1; m2; m3;)
DEBUG=false # true 면 상세 로그
EMBEDCOLOR=Blue # discord.js Colors 이름 (Blue/Orange/Purple 등)
# --- 데이터베이스 (컨테이너 기준 경로, /db 는 호스트 /root/bot/db 마운트) ---
DBPATH=/db/music1.db
# --- 개발/길드 ---
DEV=false
GUILDID=길드_ID
# --- Lavalink (오디오 재생 노드, .6 호스트) ---
LAVALINK_HOST=192.168.10.6
LAVALINK_PORT=2333
LAVALINK_PW=Lavalink_비밀번호
# --- Spotify (검색/미러) ---
SPOTIFY_CLIENTID=스포티파이_클라이언트ID
SPOTIFY_SECRET=스포티파이_시크릿
# --- 유튜브 쿠키 (성인인증/연령제한 재생 + 유튜브뮤직 검색용) ---
# 방식 A(권장): Netscape 쿠키 파일 경로. 리졸버(yt-dlp)와 봇이 공유하는 단일 소스.
# 호스트 /root/bot/db/ytcookie.txt == 컨테이너 /db/ytcookie.txt
YOUTUBE_COOKIE_PATH=/db/ytcookie.txt
# 방식 B(하위호환): 쿠키 헤더 문자열을 인라인으로. 설정되면 이 값이 우선한다.
# YOUTUBE_COOKIE="VISITOR_INFO1_LIVE=...; SID=...; ..."
# --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) ---
YTRESOLVER_URL=http://192.168.10.5:8779
# --- Redis (대시보드 상태 연동, 선택) ---
REDIS=true
REDIS_HOST=192.168.10.7
REDIS_PORT=6379

View File

@@ -8,6 +8,7 @@ import { DB } from "../utils/Database";
import { shuffle } from "../utils/Shuffle";
import { checkTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger";
import { Config } from "../utils/Config";
const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10;
@@ -17,7 +18,9 @@ const StallThresholdMs = 1000 * 20;
const MaxLoadFailRetry = 3;
const LoadFailRetryDelayMs = 2500;
type QueueTrack = Track & { userId: string; };
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시.
// 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다.
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; };
export class GuildPlayer {
private isDead = false;
@@ -53,6 +56,8 @@ export class GuildPlayer {
}
// 새 곡이 시작되면 재생 위치 멈춤 감시 시작
this.startStallWatchdog();
// 현재 곡이 재생되는 동안 다음 곡을 미리 리졸브(곡 전환 지연 제거)
this.prefetch(this.queue[1]);
Redis?.publishState("player_update", {
guildId: this.guild.id,
});
@@ -232,7 +237,8 @@ export class GuildPlayer {
// 큐가 비어있으면 바로 재생
await this.playNext();
} else {
// 재생목록 추가되었을때
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
this.prefetch(this.queue[1]);
this.setMsg({ player: false, queue: true });
}
}
@@ -241,10 +247,12 @@ export class GuildPlayer {
const first = this.queue.length === 0;
for (const track of tracks) this.queue.push({ ...track, userId });
if (first) {
// 큐가 비어있으면 바로 재생
// 큐가 비어있으면 바로 재생 (+ 다음 곡 미리 리졸브)
this.prefetch(this.queue[1]);
await this.playNext();
} else {
// 재생목록 추가되었을때
// 재생목록 추가되었을때: 다음 재생될 곡을 미리 리졸브
this.prefetch(this.queue[1]);
this.setMsg({ player: false, queue: true });
}
}
@@ -258,8 +266,7 @@ export class GuildPlayer {
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return track.encoded;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return track.encoded;
const host = process.env.LAVALINK_HOST || "192.168.10.6";
const r = await fetch(`http://${host}:8779/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
const r = await fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) });
if (!r.ok) return track.encoded;
const data = (await r.json()) as { url?: string };
if (!data.url) return track.encoded;
@@ -274,10 +281,28 @@ export class GuildPlayer {
}
}
/** 트랙의 재생용 encoded를 미리(백그라운드) 리졸브해 캐시한다. 이미 진행 중이면 무시. */
private prefetch(track?: QueueTrack) {
if (!track || track._resolved) return;
track._resolved = this.resolvePlayable(track).catch((e) => {
Logger.warn(`[GuildPlayer] 프리페치 실패(재생 시 재시도): ${String(e)}`);
track._resolved = undefined; // 실패 시 캐시 비워 재생 시점에 다시 시도
return track.encoded;
});
}
/** 재생용 encoded를 가져온다. 프리페치되어 있으면 즉시, 아니면 지금 리졸브한다. */
private getResolved(track: QueueTrack): Promise<string> {
if (!track._resolved) this.prefetch(track);
return track._resolved!;
}
public async playNext() {
const track = this.queue[0];
if (!track) return this.end();
const encoded = await this.resolvePlayable(track);
const encoded = await this.getResolved(track);
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거
this.prefetch(this.queue[1]);
await this.player.playTrack({ track: { encoded } });
// 재생관련
this.setMsg();

View File

@@ -1,6 +1,22 @@
import "dotenv/config";
import { ColorResolvable, Colors } from "discord.js";
import { join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
/**
* Netscape 형식 쿠키 파일(cookies.txt)을 "name=value; name=value" 헤더 문자열로 변환한다.
* youtubei.js/YoutubeMusic 검색은 쿠키 헤더 문자열을 쓰고, yt-dlp 리졸버는 Netscape 파일을 그대로 쓴다.
*/
function cookieFileToHeader(path: string): string {
const lines = readFileSync(path, "utf8").split(/\r?\n/);
const pairs: string[] = [];
for (const line of lines) {
if (!line || line.startsWith("#")) continue;
const p = line.split("\t");
if (p.length >= 7 && p[5]) pairs.push(`${p[5]}=${p[6] ?? ""}`);
}
return pairs.join("; ");
}
export const Config = {
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
@@ -62,13 +78,34 @@ export const Config = {
}
},
// Netscape 쿠키 파일 경로. 리졸버(yt-dlp)와 봇(YoutubeMusic 검색)이 공유하는 단일 쿠키 소스.
// 기본값은 컨테이너 내 /db/ytcookie.txt (= 호스트 /root/bot/db/ytcookie.txt, db 볼륨 마운트).
youtubeCookiePath: process.env.YOUTUBE_COOKIE_PATH?.trim() || "/db/ytcookie.txt",
_youtube_cookie: process.env.YOUTUBE_COOKIE?.trim(),
_youtube_cookie_cache: undefined as string | undefined,
/**
* 쿠키 헤더 문자열. 우선순위:
* 1) YOUTUBE_COOKIE (인라인 문자열, 하위호환)
* 2) YOUTUBE_COOKIE_PATH 의 Netscape 파일을 헤더 문자열로 변환
* 둘 다 없으면 예외.
*/
get youtube_cookie() {
if (!this._youtube_cookie) throw new ReferenceError("YOUTUBE_COOKIE is missing");
if (this._youtube_cookie) {
if (this._youtube_cookie.startsWith('"')) this._youtube_cookie = this._youtube_cookie.slice(1,-1);
return this._youtube_cookie;
}
// 파일에서 매번 새로 읽되(쿠키 갱신 반영), 파싱 결과는 캐시
if (existsSync(this.youtubeCookiePath)) {
this._youtube_cookie_cache = cookieFileToHeader(this.youtubeCookiePath);
return this._youtube_cookie_cache;
}
throw new ReferenceError("YOUTUBE_COOKIE / YOUTUBE_COOKIE_PATH 둘 다 없습니다");
},
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
proxyUrl: process.env.PROXY_URL?.trim() || "",
_redis: {

120
resolver/ytresolver.py Normal file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
ytresolver - yt-dlp 오디오 URL 리졸버 (HTTP 서비스)
봇(GuildPlayer.resolvePlayable)이 유튜브/스포티파이-미러 트랙을 재생하기 직전에 호출한다.
Lavalink의 youtube-plugin은 집 IP의 SABR 차단/성인인증(연령제한) 때문에 실재생이 막히므로,
여기서 yt-dlp로 "직접 오디오 URL"을 뽑아 봇에 돌려주고 봇은 그 URL을 Lavalink http 소스로 재생한다.
동작:
1) 먼저 익명(쿠키 없이)으로 시도 -> 일반 영상은 계정을 태우지 않는다(밴 리스크 최소화).
2) 실패하면 쿠키 + node(EJS 시그니처 해독) 로 재시도 -> 성인인증(연령제한) 영상 우회.
환경변수:
YTRESOLVER_COOKIE : Netscape 쿠키 파일 경로 (기본 /root/bot/db/ytcookie.txt)
YTRESOLVER_PORT : 리슨 포트 (기본 8779)
YTDLP : yt-dlp 실행 경로 (기본 /usr/local/bin/yt-dlp)
HTTP API:
GET /resolve?url=<유튜브 URL | ytsearch: | 검색어> -> {"url": "<직접 오디오 URL>"}
GET /health -> {"ok": true}
"""
import json, subprocess, time, threading, os, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
YTDLP = os.environ.get("YTDLP", "/usr/local/bin/yt-dlp")
COOKIE_FILE = os.environ.get("YTRESOLVER_COOKIE", "/root/bot/db/ytcookie.txt")
PORT = int(os.environ.get("YTRESOLVER_PORT", "8779"))
CACHE = {}
LOCK = threading.Lock()
TTL = 1800 # 리졸브된 URL 캐시 30분(googlevideo URL 자체는 약 6시간 유효)
# 속도/안정성 공통 플래그
COMMON = ["-f", "bestaudio/best", "--no-playlist", "--no-warnings",
"--no-check-certificate", "--socket-timeout", "15"]
def run_ytdlp(target, use_cookies=False):
if use_cookies and os.path.exists(COOKIE_FILE):
# 성인인증(연령제한): web 클라이언트 + 쿠키 + node(EJS) 시그니처 해독 필요
cmd = [YTDLP, "--js-runtimes", "node", "--cookies", COOKIE_FILE,
*COMMON, "--extractor-args", "youtube:player_client=web"]
else:
# 익명: 계정 미사용. 일반 영상에서 잘 나오는 클라이언트 조합.
cmd = [YTDLP, *COMMON,
"--extractor-args", "youtube:player_client=default,web_safari,tv"]
cmd += ["--get-url", target]
return subprocess.run(cmd, capture_output=True, text=True, timeout=45)
def first_url(out):
lines = (out.stdout or "").strip().splitlines()
return lines[0] if lines else None
def resolve(target):
now = time.time()
with LOCK:
hit = CACHE.get(target)
if hit and hit[1] > now:
return hit[0]
try:
out = run_ytdlp(target, use_cookies=False)
url = first_url(out)
if not (url and url.startswith("http")) and os.path.exists(COOKIE_FILE):
print(f"[retry-cookies] {(out.stderr or out.stdout or '')[:180]}", file=sys.stderr, flush=True)
out = run_ytdlp(target, use_cookies=True)
url = first_url(out)
except subprocess.TimeoutExpired:
print("[result] TIMEOUT", file=sys.stderr, flush=True)
return None
ok = bool(url and url.startswith("http"))
if not ok:
print(f"[result] FAIL {(out.stderr or out.stdout or '')[:240]}", file=sys.stderr, flush=True)
return None
print(f"[result] OK {url[:60]}", file=sys.stderr, flush=True)
with LOCK:
CACHE[target] = (url, now + TTL)
return url
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
p = urlparse(self.path)
q = parse_qs(p.query)
if p.path == "/health":
self._send(200, {"ok": True, "cookie": os.path.exists(COOKIE_FILE)})
return
if p.path != "/resolve":
self._send(404, {"error": "not found"})
return
target = (q.get("url") or q.get("q") or [None])[0]
print(f"[resolve] {target[:80] if target else None}", file=sys.stderr, flush=True)
if not target:
self._send(400, {"error": "missing url/q"})
return
if not (target.startswith("http") or target.startswith("ytsearch")):
target = "ytsearch1:" + target
url = resolve(target)
if url:
self._send(200, {"url": url})
else:
self._send(502, {"error": "resolve failed"})
def _send(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
if __name__ == "__main__":
print(f"ytresolver listening on :{PORT} (cookie={COOKIE_FILE}, exists={os.path.exists(COOKIE_FILE)})",
file=sys.stderr, flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()

View File

@@ -0,0 +1,14 @@
[Unit]
Description=music_bot_v2 yt-dlp audio resolver
After=network.target
[Service]
# 쿠키/봇과 같은 호스트(.5)에서 실행. 쿠키 파일은 db 폴더에 둔다(봇 컨테이너와 공유).
Environment=YTRESOLVER_COOKIE=/root/bot/db/ytcookie.txt
Environment=YTRESOLVER_PORT=8779
ExecStart=/usr/bin/python3 /opt/ytresolver.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,91 @@
/**
* refresh-cookies.mjs — 성인인증 계정 쿠키(ytcookie.txt) 재발급 스크립트
*
* 쿠키가 만료되면(19금 재생만 막히고 일반 재생은 계속 됨) 이 스크립트로 새 쿠키를 뽑는다.
* Chromium을 띄워 구글 로그인 → youtube.com 쿠키를 Netscape 형식으로 저장한다.
*
* 사용 전: npm i -D playwright (그리고 최초 1회) npx playwright install chromium
*
* 실행 예:
* GOOGLE_EMAIL=tmdgks040466@gmail.com GOOGLE_PASSWORD='...' \
* OUT=/root/bot/db/ytcookie.txt node scripts/refresh-cookies.mjs
*
* 환경변수:
* GOOGLE_EMAIL / GOOGLE_PASSWORD : 자동 로그인 시도용(생략 가능 — 그러면 수동 로그인)
* OUT : 저장 경로 (기본 ./ytcookie.txt). 운영은 /root/bot/db/ytcookie.txt
* HEADLESS : "1"이면 창 없이(자동 로그인 성공 시에만 권장). 기본은 창 표시(수동 로그인 가능)
*
* 구글이 자동 로그인을 막으면(2차인증/캡차/"안전하지 않은 브라우저") 뜬 창에서 직접 로그인하면 된다.
* 스크립트는 로그인 세션 쿠키(SID)가 잡힐 때까지 최대 5분 기다린 뒤 저장한다.
*/
import { chromium } from "playwright";
import { writeFileSync } from "node:fs";
const EMAIL = process.env.GOOGLE_EMAIL;
const PASSWORD = process.env.GOOGLE_PASSWORD;
const OUT = process.env.OUT || "./ytcookie.txt";
const HEADLESS = process.env.HEADLESS === "1";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Playwright 쿠키 배열 -> Netscape cookies.txt (yt-dlp/youtubei.js 호환)
function toNetscape(cookies) {
const lines = ["# Netscape HTTP Cookie File", "# music_bot_v2 refresh-cookies.mjs"];
for (const c of cookies) {
if (!/(^|\.)youtube\.com$|(^|\.)google\.com$/.test(c.domain)) continue;
const domain = c.domain.startsWith(".") ? c.domain : "." + c.domain;
const expires = c.expires && c.expires > 0 ? Math.floor(c.expires) : 2000000000;
lines.push([
domain, "TRUE", c.path || "/", c.secure ? "TRUE" : "FALSE",
expires, c.name, c.value,
].join("\t"));
}
return lines.join("\n") + "\n";
}
async function tryAutoLogin(page) {
if (!EMAIL || !PASSWORD) return;
try {
await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/", { waitUntil: "domcontentloaded" });
await page.fill('input[type="email"]', EMAIL, { timeout: 15000 });
await page.click('#identifierNext, button:has-text("다음"), button:has-text("Next")');
await page.waitForTimeout(2500);
await page.fill('input[type="password"]', PASSWORD, { timeout: 15000 });
await page.click('#passwordNext, button:has-text("다음"), button:has-text("Next")');
await page.waitForTimeout(3000);
} catch (e) {
console.log("[auto-login] 자동 로그인 중단(수동 로그인으로 진행):", String(e).slice(0, 100));
}
}
async function waitForLogin(context, page, maxMs = 5 * 60 * 1000) {
const start = Date.now();
while (Date.now() - start < maxMs) {
const cookies = await context.cookies("https://www.youtube.com");
if (cookies.some((c) => c.name === "SID") && cookies.some((c) => c.name === "LOGIN_INFO")) {
return cookies;
}
await sleep(3000);
}
return null;
}
(async () => {
const browser = await chromium.launch({ headless: HEADLESS });
const context = await browser.newContext();
const page = await context.newPage();
await tryAutoLogin(page);
// youtube를 한번 열어 로그인 세션 쿠키를 확실히 발급
try { await page.goto("https://www.youtube.com/", { waitUntil: "domcontentloaded" }); } catch {}
console.log("로그인 완료를 기다리는 중... (자동 로그인이 막혔으면 뜬 창에서 직접 로그인하세요)");
const cookies = await waitForLogin(context, page);
if (!cookies) {
console.error("❌ 로그인 세션 쿠키(SID/LOGIN_INFO)를 못 잡았습니다. 다시 시도하세요.");
await browser.close();
process.exit(1);
}
const all = await context.cookies();
writeFileSync(OUT, toNetscape(all));
console.log(`✅ 쿠키 저장 완료: ${OUT} (youtube/google 쿠키 ${all.length}개 중 관련 항목)`);
await browser.close();
process.exit(0);
})();