feat: 스포티파이 곡을 Soloist(스포티파이 공식 클라이언트)로 먼저 재생, 안 되면 유튜브로
- soloist/: soloist-bridge 중계 서버(docker). 곡마다 soloist --single-track을 띄우고 PulseAudio 빈 싱크를 캡처해 MP3 스트림으로 Lavalink(http 소스)에 넘긴다. 첫 소리가 나와야 성공으로 응답, 15초 안에 소리가 없거나 오류면 실패. 한 계정 한 곡 제한 → 다른 길드가 쓰는 중이면 busy. 빌드 만료(90일) 자동 갱신, 페어링 대기 자동 진입. - bot: SOLOIST_URL 설정 시 스포티파이 곡은 재생 직전에 Soloist를 먼저 시도, 실패하면 기존 유튜브 경로. 곡 도중 끊김·위치 이동·음성 재접속은 같은 곡의 유튜브 음원으로 그 위치부터 이어 재생. 오래 걸려 실패하면 5분 동안 유튜브로만 재생. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,10 @@ YOUTUBE_COOKIE_PATH=/db/ytcookie.txt
|
||||
# --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) ---
|
||||
YTRESOLVER_URL=http://192.168.10.5:8779
|
||||
|
||||
# --- 스포티파이 Soloist 중계 서버 (선택, 리포 루트 soloist/) ---
|
||||
# 스포티파이 곡을 Soloist로 먼저 재생하고, 안 되면 유튜브로 재생한다. 비우면 유튜브로만 재생.
|
||||
SOLOIST_URL=http://192.168.10.5:8780
|
||||
|
||||
# --- Redis (대시보드 상태 연동, 선택) ---
|
||||
REDIS=true
|
||||
REDIS_HOST=192.168.10.7
|
||||
|
||||
@@ -100,6 +100,7 @@ sqlite DB(`music1.db` 등)와 쿠키(`ytcookie.txt`)가 컨테이너에서 `/db/
|
||||
| `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 |
|
||||
| `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 |
|
||||
| `YTRESOLVER_URL` | 리졸버 주소(기본 `http://192.168.10.5:8779`) |
|
||||
| `SOLOIST_URL` | (선택) 스포티파이 Soloist 중계 서버 주소(예 `http://192.168.10.5:8780`). 비우면 스포티파이 곡도 유튜브로만 재생. [`../soloist/README.md`](../soloist/README.md) |
|
||||
| `REDIS/REDIS_HOST/REDIS_PORT` | 대시보드 제어/상태 연동(선택) |
|
||||
|
||||
> `bot/.gitignore`가 `*.env`, `*.db`를 제외하므로 실제 `.env`와 DB는 커밋되지 않는다.
|
||||
@@ -202,6 +203,7 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
|
||||
- **전부 재생 안 됨**: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. `systemctl status ytresolver`,
|
||||
`curl http://192.168.10.5:8779/health`, Lavalink `:2333`.
|
||||
- **유튜브뮤직 검색 품질**: YTM API가 이 계정/엔드포인트에선 음악 카탈로그 '노래'를 거의 안 주고 일반 유튜브 영상(토크쇼·재생목록·MV 등)을 반환한다. 그래서 애매한 결과는 버리고 `ytsearch:… Topic`(공식 오디오)로 폴백하도록 해둠. 더 깨끗한 공식 오디오가 필요하면 검색에 `-p`(스포티파이 우선)를 쓴다.
|
||||
- **스포티파이 곡이 유튜브로만 재생됨**: 봇 로그의 `[Soloist] 재생 준비 실패(...)` 사유 확인. `not_paired`=페어링 필요, `busy`=다른 길드가 사용 중(정상), `disabled`=API 키 없음. `curl http://192.168.10.5:8780/health`.
|
||||
- **yt-dlp EJS(시그니처) 오류**: 리졸버는 연령제한 영상에 `--js-runtimes node`가 필요하다(node 설치 필수).
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,7 @@ import { checkTextChannelAndMsg } from "../utils/music/Channel";
|
||||
import { Logger } from "../utils/Logger";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Spotify } from "../utils/api/Spotify";
|
||||
import { Soloist } from "../utils/api/Soloist";
|
||||
|
||||
const DelayAfterErrMs = 1000 * 5;
|
||||
const idleEndTime = 1000 * 60 * 10;
|
||||
@@ -21,7 +22,8 @@ const LoadFailRetryDelayMs = 2500;
|
||||
|
||||
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시.
|
||||
// 다음 곡을 재생 전에 미리 리졸브해 두어 곡 전환 시 대기시간을 없앤다.
|
||||
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; };
|
||||
// _soloist: 지금 Soloist(스포티파이 공식 클라이언트) 스트림으로 재생 중인지. 이 스트림은 위치 이동이 안 된다.
|
||||
type QueueTrack = Track & { userId: string; _resolved?: Promise<string>; _soloist?: boolean; };
|
||||
|
||||
export class GuildPlayer {
|
||||
private isDead = false;
|
||||
@@ -38,6 +40,8 @@ export class GuildPlayer {
|
||||
private lastPosition = 0;
|
||||
private lastProgressAt = 0;
|
||||
private loadFailCount = 0;
|
||||
/** Soloist 스트림이 곡 도중에 끊긴 재생 위치(ms). end 이벤트에서 유튜브 음원으로 그 위치부터 이어 재생한다. */
|
||||
private soloistCutAt: number | undefined;
|
||||
|
||||
constructor(
|
||||
public guild: Guild,
|
||||
@@ -69,6 +73,21 @@ export class GuildPlayer {
|
||||
try {
|
||||
if (this.isDead) return;
|
||||
if (data.reason === "replaced") return;
|
||||
// Soloist 스트림이 곡 도중에 끊기면(다른 기기에서 같은 계정으로 재생 등) Lavalink는 exception 뒤
|
||||
// finished(또는 loadFailed)로 끝낸다 → 다음 곡으로 넘기지 않고 같은 곡의 유튜브 음원으로 그 위치부터 이어 재생
|
||||
const cutAt = this.soloistCutAt;
|
||||
this.soloistCutAt = undefined;
|
||||
const cut = this.queue[0];
|
||||
if (cutAt !== undefined && cut?._soloist && data.reason !== "stopped" && data.reason !== "cleanup") {
|
||||
cut._soloist = false;
|
||||
const encoded = await this.getResolved(cut);
|
||||
if (this.isDead || this.queue[0] !== cut) return;
|
||||
if (encoded) {
|
||||
Logger.warn(`[GuildPlayer] Soloist 재생이 ${cutAt}ms에서 끊겨 유튜브로 이어서 재생합니다: "${cut.info.title}"`);
|
||||
await this.player.playTrack({ track: { encoded }, position: cutAt });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
|
||||
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
|
||||
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
|
||||
@@ -80,6 +99,8 @@ export class GuildPlayer {
|
||||
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
|
||||
if (this.isDead) return;
|
||||
if (this.queue[0]?.encoded === cur.encoded) {
|
||||
// Soloist 스트림이 끊긴 경우도 여기로 온다 → 유튜브 음원으로 다시 재생
|
||||
cur._soloist = false;
|
||||
const reEncoded = await this.resolvePlayable(cur);
|
||||
if (reEncoded) {
|
||||
await this.player.playTrack({ track: { encoded: reEncoded } });
|
||||
@@ -136,6 +157,7 @@ export class GuildPlayer {
|
||||
|
||||
this.player.on("exception", async (data) => {
|
||||
try {
|
||||
if (this.queue[0]?._soloist) this.soloistCutAt = this.player.position;
|
||||
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
|
||||
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
|
||||
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
|
||||
@@ -198,7 +220,8 @@ export class GuildPlayer {
|
||||
this.attachPlayerListeners();
|
||||
await this.player.setGlobalVolume(savedVolume || 50);
|
||||
|
||||
// 곡이 있었으면 이어서 재생
|
||||
// 곡이 있었으면 이어서 재생 (Soloist 스트림은 이어 받을 수 없으므로 유튜브 음원으로 그 위치부터)
|
||||
if (currentTrack) currentTrack._soloist = false;
|
||||
const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : "";
|
||||
if (currentTrack && reEncoded) {
|
||||
await this.player.playTrack({
|
||||
@@ -342,11 +365,38 @@ export class GuildPlayer {
|
||||
return track._resolved!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스포티파이 곡을 Soloist(스포티파이 공식 헤드리스 클라이언트)로 재생할 encoded를 만든다.
|
||||
* 설정이 없거나, 페어링 전이거나, 다른 길드가 쓰는 중이거나, 재생이 안 되면 "" → 호출한 쪽이 유튜브로 재생한다.
|
||||
* 계정 하나로 한 번에 한 곡만 재생되므로 다음 곡 미리 준비(prefetch)는 하지 않고 재생 직전에만 부른다.
|
||||
*/
|
||||
private async resolveSoloist(track: QueueTrack): Promise<string> {
|
||||
if (!Soloist.enabled || track.info.sourceName !== "spotify") return "";
|
||||
const url = await Soloist.prepare(track.info.identifier, `${Config.appId}:${this.guild.id}`, track.info.length);
|
||||
if (!url) return "";
|
||||
const node = lavalinkManager.shoukaku.options.nodeResolver(lavalinkManager.shoukaku.nodes);
|
||||
if (!node) return "";
|
||||
try {
|
||||
const res = await node.rest.resolve(url);
|
||||
if (res?.loadType === LoadType.TRACK) return res.data.encoded;
|
||||
Logger.warn(`[GuildPlayer] Soloist 스트림을 Lavalink가 읽지 못함(${res?.loadType}), 유튜브로 재생합니다.`);
|
||||
} catch (e) {
|
||||
Logger.warn(`[GuildPlayer] Soloist 스트림 확인 실패, 유튜브로 재생합니다: ${String(e)}`);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public async playNext(): Promise<void> {
|
||||
const track = this.queue[0];
|
||||
if (!track) return this.end();
|
||||
const tResolve = Date.now();
|
||||
const encoded = await this.getResolved(track);
|
||||
// 스포티파이 곡은 Soloist를 먼저 시도하고, 안 되면 유튜브 음원(미리 준비해 둔 것)으로 재생한다.
|
||||
this.soloistCutAt = undefined;
|
||||
let encoded = await this.resolveSoloist(track);
|
||||
if (this.isDead || this.queue[0] !== track) return; // 준비하는 동안 건너뛰기/정지됨
|
||||
track._soloist = !!encoded;
|
||||
if (encoded) this.prefetch(track); // 도중에 끊기거나 위치를 옮길 때 쓸 유튜브 음원도 준비
|
||||
else encoded = await this.getResolved(track);
|
||||
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
|
||||
if (!encoded) {
|
||||
// 재생할 음원을 찾지 못한 곡(스포티파이 곡의 유튜브 원곡을 못 찾음)은 대기열에서 빼고 다음 곡으로
|
||||
@@ -408,6 +458,18 @@ export class GuildPlayer {
|
||||
public seek(num: number) {
|
||||
if (!this.isPlaying) return;
|
||||
if (!this.nowTrack) return;
|
||||
const track = this.nowTrack;
|
||||
if (track._soloist) {
|
||||
// Soloist 스트림은 위치 이동이 안 되므로 같은 곡의 유튜브 음원으로 바꿔 그 위치부터 재생한다.
|
||||
const paused = this.isPaused;
|
||||
this.getResolved(track).then(async (encoded) => {
|
||||
if (!encoded || this.isDead || this.nowTrack !== track) return;
|
||||
track._soloist = false;
|
||||
await this.player.playTrack({ track: { encoded }, position: num, paused });
|
||||
Redis?.publishState("player_update", { guildId: this.guild.id });
|
||||
}).catch((e) => Logger.warn(`[GuildPlayer] 위치 이동(유튜브 전환) 실패: ${String(e)}`));
|
||||
return;
|
||||
}
|
||||
this.player.seekTo(num);
|
||||
Redis?.publishState("player_update", { guildId: this.guild.id });
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ export const Config = {
|
||||
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
|
||||
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""),
|
||||
|
||||
// 스포티파이 Soloist 중계 서버(soloist-bridge) 주소. 비우면 스포티파이 곡도 유튜브로만 재생한다.
|
||||
// Lavalink(.6)도 이 주소로 스트림을 받으므로 LAN 주소여야 한다. 예: http://192.168.10.5:8780
|
||||
soloistUrl: (process.env.SOLOIST_URL?.trim() || "").replace(/\/+$/, ""),
|
||||
|
||||
proxyUrl: process.env.PROXY_URL?.trim() || "",
|
||||
|
||||
_redis: {
|
||||
|
||||
53
bot/src/utils/api/Soloist.ts
Normal file
53
bot/src/utils/api/Soloist.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Config } from "../Config";
|
||||
import { Logger } from "../Logger";
|
||||
|
||||
/** soloist-bridge가 첫 소리를 확인할 때까지 기다리는 최대 시간(브리지 쪽 15초 + 여유). */
|
||||
const PrepareTimeoutMs = 20_000;
|
||||
/** 실패하는 데 이보다 오래 걸렸으면(소리가 안 나와 시간 초과 등) 잠시 Soloist를 쉬어, 곡마다 오래 기다리지 않게 한다. */
|
||||
const SlowFailMs = 5_000;
|
||||
const CooldownMs = 5 * 60_000;
|
||||
let cooldownUntil = 0;
|
||||
|
||||
/**
|
||||
* 스포티파이 공식 헤드리스 클라이언트(Soloist) 중계 서버(soloist-bridge, 리포 루트 soloist/) 클라이언트.
|
||||
* 스포티파이 곡을 Soloist로 재생해 받은 MP3 스트림 주소를 돌려준다. 실패하면 null → 호출한 쪽이 유튜브로 재생한다.
|
||||
*/
|
||||
export const Soloist = {
|
||||
get enabled(): boolean {
|
||||
return !!Config.soloistUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* 곡 재생을 준비한다(브리지가 Soloist를 띄우고 실제 소리가 나올 때까지 기다린다).
|
||||
* 성공하면 Lavalink에 넘길 스트림 주소, 실패(미설정·미페어링·다른 길드가 사용 중·재생 불가)하면 null.
|
||||
* owner: 같은 owner의 새 요청은 이전 곡을 끊고 시작하고, 다른 owner가 쓰는 중이면 거절된다.
|
||||
*/
|
||||
async prepare(trackId: string, owner: string, durationMs: number): Promise<string | null> {
|
||||
if (!this.enabled || Date.now() < cooldownUntil) return null;
|
||||
const t0 = Date.now();
|
||||
const url = `${Config.soloistUrl}/prepare?track=${encodeURIComponent(trackId)}`
|
||||
+ `&owner=${encodeURIComponent(owner)}&duration=${Math.max(0, Math.floor(durationMs || 0))}`;
|
||||
try {
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(PrepareTimeoutMs) });
|
||||
const data = await r.json().catch(() => ({})) as { path?: string; error?: string; readyMs?: number };
|
||||
if (!r.ok || !data.path) {
|
||||
Logger.warn(`[Soloist] 재생 준비 실패(${r.status} ${data.error ?? ""}), 유튜브로 재생합니다: ${trackId}`);
|
||||
// busy(다른 길드가 사용 중)·preempted(이 길드가 곧바로 다음 곡을 요청함)는 정상 상황이라 쉬지 않는다
|
||||
if (data.error !== "busy" && data.error !== "preempted") this.coolDownIfSlow(t0);
|
||||
return null;
|
||||
}
|
||||
Logger.log(`[Soloist] 재생 준비 완료 ${data.readyMs ?? "?"}ms: ${trackId}`);
|
||||
return `${Config.soloistUrl}${data.path}`;
|
||||
} catch (e) {
|
||||
Logger.warn(`[Soloist] 브리지 요청 실패, 유튜브로 재생합니다: ${String(e)}`);
|
||||
this.coolDownIfSlow(t0);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
coolDownIfSlow(t0: number) {
|
||||
if (Date.now() - t0 < SlowFailMs) return;
|
||||
cooldownUntil = Date.now() + CooldownMs;
|
||||
Logger.warn(`[Soloist] 실패에 시간이 오래 걸려 ${CooldownMs / 60_000}분 동안 유튜브로만 재생합니다.`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user