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

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.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: {