feat: 쿠키 파일 기반 설정(YOUTUBE_COOKIE_PATH)+리졸버 URL 설정화, 다음곡 프리페치로 재생지연 단축, ytresolver 리포 포함, 쿠키 재발급 스크립트, README 상세화
This commit is contained in:
40
bot/.env.example
Normal file
40
bot/.env.example
Normal 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
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user