143 lines
5.8 KiB
TypeScript
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 [];
|
|
}
|
|
} |