Files
music_bot_v2/bot/src/classes/LavalinkManager.ts

143 lines
5.8 KiB
TypeScript

import { Connectors, LoadType, Shoukaku, Track } from "shoukaku";
import { Client } from "discord.js";
import { spawn } from "node:child_process";
import { join } from "node:path";
import { GuildPlayer } from "./GuildPlayer";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { parseLink } from "../utils/music/Url";
import { shuffle } from "../utils/Shuffle";
import { Spotify } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
export class LavalinkManager {
public shoukaku: Shoukaku;
private players: Map<string, GuildPlayer> = new Map();
constructor(readonly client: Client) {
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
name: "tkrmagid-Lavalink-Server",
url: `${Config.lavalink.host}:${Config.lavalink.port}`,
auth: Config.lavalink.pw,
secure: false,
}], {
moveOnDisconnect: true,
reconnectTries: 3,
});
this.shoukaku.on("ready", (name) => {
Logger.ready(`[LavalinkManager] 노드 [${name}] 연결 완료!`);
// 노드가 (재)연결될 때마다 poToken 주입 — Lavalink 재시작 후에도 자동 복구
this.refreshPoToken();
});
this.shoukaku.on("error", (name, err) => {
Logger.error(`[LavalinkManager] 노드 [${name}] 에러: ${String(err)}`);
});
// poToken은 수 시간 뒤 만료되므로 주기적으로 재생성/재주입 (2시간)
setInterval(() => this.refreshPoToken(), 2 * 60 * 60 * 1000).unref();
}
/**
* googlevideo 재생 403 우회용 poToken/visitorData를 생성해 Lavalink youtube-plugin에 주입한다.
* BotGuard 생성의 메모리 스파이크를 봇 본체와 격리하기 위해 독립 ESM 스크립트를 자식 프로세스로 실행한다.
*/
private refreshPoToken(): void {
const script = join(__dirname, "..", "..", "scripts", "potoken-refresh.mjs");
const child = spawn(process.execPath, [script], {
env: {
...process.env,
LAVALINK_HOST: Config.lavalink.host,
LAVALINK_PORT: String(Config.lavalink.port),
LAVALINK_PW: Config.lavalink.pw,
},
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
child.stdout?.on("data", (d) => { out += String(d); });
child.stderr?.on("data", (d) => { out += String(d); });
child.on("error", (err) => {
Logger.error(`[LavalinkManager] poToken 갱신 실행 실패: ${String(err)}`);
});
child.on("close", (code) => {
const trimmed = out.trim();
if (code === 0) {
const last = trimmed.split("\n").pop() || "poToken 주입 완료";
Logger.ready(`[LavalinkManager] ${last}`);
} else {
// import/로드 단계 크래시는 마지막 줄이 "Node.js vXX"뿐이므로 전체 출력 꼬리를 남긴다
Logger.error(`[LavalinkManager] poToken 갱신 실패(code ${code}):\n${trimmed.slice(-1500)}`);
}
});
}
/** 연결 가능한(CONNECTED 상태) Lavalink 노드가 하나라도 있는지 확인 */
hasReadyNode(): boolean {
return !!this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
}
getPlayer(guildId: string) {
return this.players.get(guildId);
}
addPlayer(guildId: string, player: GuildPlayer) {
this.players.set(guildId, player);
}
delPlayer(guildId: string) {
this.players.delete(guildId);
}
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) {
this.delPlayer(guildId);
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
}
player = player ?? this.getPlayer(guildId);
if (!player) return;
const { isUrl, text, flags } = parseLink(query.trim());
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
let searchText: string;
if (isUrl) searchText = text;
else if (flags.has('p')) searchText = (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
else searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
const result = await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
// 노래 못찾았을때
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
if (result.loadType === LoadType.PLAYLIST) {
player.addTracks(
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
userId
);
return;
}
if (result.loadType === LoadType.TRACK) {
player.addTrack(result.data, userId);
return;
}
if (result.loadType === LoadType.SEARCH) {
if (result.data.length === 0) {
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
player.addTrack(result.data[0], userId);
return;
}
}
public async youtubeSearch(query: string): Promise<Track[]> {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
const result = await node.rest.resolve(`ytsearch:${query.trim()}`);
// if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) return [];
if (result?.loadType === LoadType.TRACK) return [ result.data ];
if (result?.loadType === LoadType.SEARCH) return result.data;
return [];
}
}