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 = 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 { 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 []; } }