diff --git a/README.md b/README.md index 043aaca..576cce9 100644 --- a/README.md +++ b/README.md @@ -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 설치 필수). diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..9f9eb8e --- /dev/null +++ b/bot/.env.example @@ -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 diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index a2496fb..c09c314 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -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; }; 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 { + 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(); diff --git a/bot/src/utils/Config.ts b/bot/src/utils/Config.ts index 91a3187..64ffc3d 100644 --- a/bot/src/utils/Config.ts +++ b/bot/src/utils/Config.ts @@ -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.startsWith('"')) this._youtube_cookie = this._youtube_cookie.slice(1,-1); - return this._youtube_cookie; + 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: { diff --git a/resolver/ytresolver.py b/resolver/ytresolver.py new file mode 100644 index 0000000..727ec5a --- /dev/null +++ b/resolver/ytresolver.py @@ -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() diff --git a/resolver/ytresolver.service b/resolver/ytresolver.service new file mode 100644 index 0000000..aaae2db --- /dev/null +++ b/resolver/ytresolver.service @@ -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 diff --git a/scripts/refresh-cookies.mjs b/scripts/refresh-cookies.mjs new file mode 100644 index 0000000..1f9d810 --- /dev/null +++ b/scripts/refresh-cookies.mjs @@ -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); +})();