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:
@@ -10,6 +10,7 @@
|
||||
| [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) |
|
||||
| [`page/`](page) | 웹 대시보드(Next.js 16 + NextAuth + Redis). 브라우저에서 봇 제어·상태 표시. | [page/README.md](page/README.md) |
|
||||
| [`resolver/`](resolver) | `ytresolver.py` — 오디오 URL 리졸버(+systemd 유닛). 유튜브 내부 API 우선, 실패 시 yt-dlp로 폴백. 봇이 SABR/연령제한 우회에 사용. | 아래 + bot/README §1·2 |
|
||||
| [`soloist/`](soloist) | `soloist-bridge` — 스포티파이 곡을 스포티파이 공식 헤드리스 클라이언트(Soloist)로 재생해 MP3 스트림으로 Lavalink에 넘기는 중계 서버(docker). 안 되면 봇이 유튜브로 재생. | [soloist/README.md](soloist/README.md) |
|
||||
| [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 |
|
||||
|
||||
## 전체 아키텍처
|
||||
|
||||
@@ -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}분 동안 유튜브로만 재생합니다.`);
|
||||
},
|
||||
};
|
||||
12
soloist/Dockerfile
Normal file
12
soloist/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
# soloist-bridge: 스포티파이 Soloist(공식 헤드리스 클라이언트) → MP3 스트림 중계 서버
|
||||
# soloist 실행 파일은 이미지에 넣지 않고 /data/bin 에 내려받는다(빌드가 90일마다 만료되므로 자동 갱신).
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends pulseaudio pulseaudio-utils ffmpeg python3 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY bridge.py entrypoint.sh /app/
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
ENV SOLOIST_DATA=/data SOLOIST_PORT=8780 PULSE_SINK=soloist
|
||||
VOLUME /data
|
||||
EXPOSE 8780
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
50
soloist/README.md
Normal file
50
soloist/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# soloist-bridge — 스포티파이 곡을 스포티파이 공식 클라이언트로 재생
|
||||
|
||||
스포티파이 곡(스포티파이 주소·재생목록·`-p` 검색·자동재생 추천곡)을 유튜브로 바꾸지 않고
|
||||
스포티파이 공식 헤드리스 클라이언트 [Soloist](https://developer.spotify.com/documentation/soloist)로 재생한다.
|
||||
Soloist가 안 되면(설정 없음·페어링 전·다른 서버가 사용 중·재생 오류) 봇이 바로 기존 유튜브 경로로 재생한다.
|
||||
|
||||
```
|
||||
봇 ── GET /prepare?track=<id>&owner=<봇:길드>&duration=<ms> ──▶ soloist-bridge(.5:8780, docker, host 네트워크)
|
||||
├ soloist --single-track spotify:track:<id>
|
||||
│ └ PulseAudio 빈 싱크 "soloist"로 출력
|
||||
├ parec(싱크 모니터) → 앞쪽 무음 제거 → ffmpeg MP3 320k
|
||||
└ 첫 소리가 나오면 {"path":"/stream/<sid>.mp3"} 응답
|
||||
Lavalink(.6) ── GET /stream/<sid>.mp3 ──▶ 곡이 끝날 때까지 실시간 MP3 스트림(http 소스로 재생)
|
||||
```
|
||||
|
||||
## 동작 규칙
|
||||
|
||||
- **한 번에 한 곡**: 스포티파이 계정 하나는 동시에 한 곳에서만 재생된다. 다른 길드(또는 다른 봇)가 쓰는 중이면
|
||||
`409 busy` → 그 길드는 유튜브로 재생한다. 같은 길드의 다음 곡 요청은 이전 곡을 끊고 시작한다.
|
||||
- **실패 판정**: 15초 안에 소리가 안 나오거나 soloist가 먼저 끝나면 실패 → 유튜브.
|
||||
- **곡 도중 끊김**(같은 계정을 휴대폰에서 재생 등): 청크 종료 없이 연결을 끊어 Lavalink가 오류로 받게 한다.
|
||||
봇은 그 위치부터 같은 곡의 유튜브 음원으로 이어서 재생한다.
|
||||
- **일시정지**: 받은 MP3를 세션에 모두 보관하므로 멈췄다 다시 재생해도 이어진다.
|
||||
- **위치 이동(seek)**: 실시간 스트림이라 직접은 안 된다. 봇이 같은 곡의 유튜브 음원으로 바꿔 그 위치부터 재생한다.
|
||||
- **빌드 만료(90일)**: 실행 파일은 `/data/bin/soloist`에 자동으로 내려받는다. 만료 7일 전이거나
|
||||
종료 코드 10(만료)이 나오면 최신 빌드로 자동 교체한다.
|
||||
|
||||
## 처음 설정 (사람이 해야 하는 일)
|
||||
|
||||
1. 스포티파이 **Premium** 계정으로 https://developer.spotify.com/dashboard 에 로그인 →
|
||||
Spotify Soloist API Key 메뉴에서 약관 동의 후 키 발급.
|
||||
2. .5에 키 저장: `/root/bot/soloist/soloist.env` 에 `SOLOIST_API_KEY=<키>` (권한 600).
|
||||
3. 컨테이너 기동: `docker compose -f /root/bot/music_bot_v2/soloist/compose.yml up -d --build`
|
||||
4. **페어링(1회)**: 키가 있고 로그인 정보가 없으면 브리지가 자동으로 페어링 대기 상태가 된다.
|
||||
서버와 **같은 네트워크(LAN)** 에 있는 휴대폰/PC의 스포티파이 앱 → 기기 목록 → `music_bot` 선택.
|
||||
`curl http://192.168.10.5:8780/health` 의 `"paired": true` 로 확인.
|
||||
5. 봇 환경변수에 `SOLOIST_URL=http://192.168.10.5:8780` 을 넣고 봇을 다시 빌드/기동.
|
||||
|
||||
페어링한 계정으로 다른 기기에서 음악을 틀면 봇 재생이 끊긴다(→ 유튜브로 이어짐). 봇 전용 계정을 권장한다.
|
||||
계정을 바꾸려면 컨테이너를 멈추고 `/root/bot/soloist/data/state` 를 지운 뒤 다시 띄워 4번을 반복한다.
|
||||
|
||||
## 상태 확인
|
||||
|
||||
- `GET /health` → `enabled`(키 있음), `paired`, `pairing`, `version`, `expiresInDays`, `busy`(지금 재생 중인 세션)
|
||||
- `docker logs soloist-bridge` → `소리 시작`, `곡 도중 끊김`, `스트림 연결 종료` 등
|
||||
|
||||
## 주의
|
||||
|
||||
- 스포티파이 약관상 개인 이용 범위를 벗어난다(디스코드 여러 명에게 송출). 계정 제재 위험은 사용자가 감수한다.
|
||||
- `/health`·`/prepare`에는 인증이 없다. LAN 밖으로 8780 포트를 열지 말 것.
|
||||
552
soloist/bridge.py
Normal file
552
soloist/bridge.py
Normal file
@@ -0,0 +1,552 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
soloist-bridge — 스포티파이 공식 헤드리스 클라이언트(Soloist)로 곡을 재생하고, 그 소리를 MP3 스트림으로
|
||||
Lavalink(http 소스)에 넘겨 주는 중계 서버.
|
||||
|
||||
흐름 (곡 하나마다)
|
||||
봇 → GET /prepare?track=<스포티파이 곡 id>&owner=<봇:길드>&duration=<ms>
|
||||
→ parec(빈 싱크 모니터) + ffmpeg(MP3 인코딩)을 먼저 띄우고 soloist --single-track 실행
|
||||
→ 앞쪽 무음을 버리고 실제 소리가 나오기 시작하면 {"path": "/stream/<sid>.mp3"} 응답
|
||||
(정해진 시간 안에 소리가 없거나 soloist가 먼저 끝나면 오류 응답 → 봇이 유튜브로 재생)
|
||||
Lavalink → GET /stream/<sid>.mp3 (처음부터 끝까지 받은 MP3를 그대로 흘려 보냄)
|
||||
→ soloist가 곡을 다 재생하고 종료하면 스트림도 끝남(→ Lavalink "finished")
|
||||
→ 곡 도중에 끊기면(다른 기기에서 같은 계정 재생 등) 청크 종료 없이 연결을 끊어 Lavalink가 오류로 보게 함
|
||||
|
||||
계정 하나는 한 번에 한 곡만 재생할 수 있으므로 세션은 동시에 하나만 돈다. 다른 길드가 쓰는 중이면 409(busy)를 준다.
|
||||
같은 owner의 새 요청은 이전 세션을 끊고 새로 시작한다(건너뛰기).
|
||||
|
||||
환경변수
|
||||
SOLOIST_API_KEY 개발자 대시보드에서 만든 Soloist API 키(필수, 없으면 비활성 → /prepare 503)
|
||||
SOLOIST_DEVICE_NAME 스포티파이 앱에 보일 기기 이름(기본 music_bot)
|
||||
SOLOIST_DATA 세션·실행 파일 보관 폴더(기본 /data, 볼륨으로 유지해야 페어링이 남는다)
|
||||
SOLOIST_BIN soloist 실행 파일 경로(기본 $SOLOIST_DATA/bin/soloist, 없으면 자동 다운로드)
|
||||
SOLOIST_PORT HTTP 포트(기본 8780)
|
||||
SOLOIST_PREPARE_TIMEOUT 첫 소리가 나올 때까지 기다리는 최대 초(기본 15)
|
||||
PULSE_SINK 캡처할 PulseAudio 싱크 이름(기본 soloist)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
from array import array
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
PORT = int(os.environ.get("SOLOIST_PORT", "8780"))
|
||||
API_KEY = os.environ.get("SOLOIST_API_KEY", "").strip()
|
||||
DEVICE_NAME = os.environ.get("SOLOIST_DEVICE_NAME", "music_bot").strip() or "music_bot"
|
||||
DATA = os.environ.get("SOLOIST_DATA", "/data")
|
||||
BIN = os.environ.get("SOLOIST_BIN", "").strip() or os.path.join(DATA, "bin", "soloist")
|
||||
STATE_DIR = os.path.join(DATA, "state")
|
||||
CACHE_DIR = os.path.join(DATA, "cache")
|
||||
SINK = os.environ.get("PULSE_SINK", "soloist")
|
||||
PREPARE_TIMEOUT = float(os.environ.get("SOLOIST_PREPARE_TIMEOUT", "15"))
|
||||
BUILD_URL = os.environ.get(
|
||||
"SOLOIST_BUILD_URL", "https://soloist-builds.spotifycdn.com/soloist_release_x86_64.tar.gz")
|
||||
|
||||
RATE, CHANNELS = 48000, 2
|
||||
PCM_BYTES_PER_SEC = RATE * CHANNELS * 2
|
||||
SILENCE_LEVEL = 8 # 이 값보다 큰 샘플이 나오면 '소리 시작'으로 본다(빈 싱크의 무음은 정확히 0)
|
||||
TAIL_SEC = 0.8 # soloist 종료 후 싱크에 남은 소리를 더 받는 시간
|
||||
IDLE_SEC = 10 # 스트림을 받는 쪽이 없을 때 세션을 정리하기까지의 시간
|
||||
MAX_BUF = 80 * 1024 * 1024 # 세션당 MP3 보관 상한(320kbps 기준 30분 이상)
|
||||
TRACK_RE = re.compile(r"^[A-Za-z0-9]{22}$")
|
||||
EXIT_EXPIRED = 10 # soloist: 빌드 만료
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[soloist-bridge] {msg}", flush=True)
|
||||
|
||||
|
||||
class Soloist:
|
||||
"""soloist 실행 파일·페어링 상태 관리."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.version = ""
|
||||
self.expires_days: int | None = None
|
||||
self.pairing = False
|
||||
self.last_update = 0.0
|
||||
self.last_error = ""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(API_KEY)
|
||||
|
||||
@property
|
||||
def paired(self) -> bool:
|
||||
# soloist는 로그인 토큰을 <data-dir>/cache/dbrts 에 저장한다(페어링 전에는 없거나 0바이트).
|
||||
p = os.path.join(STATE_DIR, "cache", "dbrts")
|
||||
return os.path.exists(p) and os.path.getsize(p) > 0
|
||||
|
||||
def base_args(self) -> list[str]:
|
||||
return [BIN, "-n", DEVICE_NAME, "-k", API_KEY, "-D", STATE_DIR, "-C", CACHE_DIR, "-i", "100", "-v"]
|
||||
|
||||
def read_version(self) -> None:
|
||||
try:
|
||||
out = subprocess.run([BIN, "--version"], capture_output=True, text=True, timeout=15)
|
||||
self.version = (out.stdout or out.stderr).strip()
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.version = ""
|
||||
log(f"버전 확인 실패: {e}")
|
||||
|
||||
def note_output(self, line: str) -> None:
|
||||
m = re.search(r"client expires in (-?\d+) days", line)
|
||||
if m:
|
||||
self.expires_days = int(m.group(1))
|
||||
if self.expires_days <= 7:
|
||||
self.update_async(f"만료 {self.expires_days}일 전")
|
||||
|
||||
def update(self, reason: str) -> bool:
|
||||
"""최신 빌드를 받아 실행 파일을 교체한다. 실행 중인 프로세스는 이전 파일을 계속 쓴다."""
|
||||
with self.lock:
|
||||
if time.time() - self.last_update < 3600 and os.path.exists(BIN):
|
||||
return False
|
||||
self.last_update = time.time()
|
||||
log(f"soloist 빌드 내려받기 ({reason})")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(BIN), exist_ok=True)
|
||||
tmp_tar = BIN + ".tar.gz"
|
||||
with urllib.request.urlopen(BUILD_URL, timeout=120) as r, open(tmp_tar, "wb") as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
with tarfile.open(tmp_tar) as tar:
|
||||
member = tar.getmember("soloist")
|
||||
src = tar.extractfile(member)
|
||||
if src is None:
|
||||
raise RuntimeError("tar 안에 soloist 파일이 없습니다")
|
||||
with open(BIN + ".new", "wb") as f:
|
||||
shutil.copyfileobj(src, f)
|
||||
os.chmod(BIN + ".new", 0o755)
|
||||
check = subprocess.run([BIN + ".new", "--version"], capture_output=True, text=True, timeout=15)
|
||||
if check.returncode != 0:
|
||||
raise RuntimeError(f"새 빌드 실행 실패(code {check.returncode})")
|
||||
os.replace(BIN + ".new", BIN)
|
||||
os.remove(tmp_tar)
|
||||
self.expires_days = None
|
||||
self.read_version()
|
||||
log(f"soloist 갱신 완료: {self.version}")
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f"soloist 갱신 실패: {e}")
|
||||
return False
|
||||
|
||||
def update_async(self, reason: str) -> None:
|
||||
threading.Thread(target=self.update, args=(reason,), daemon=True).start()
|
||||
|
||||
def start_pairing(self) -> None:
|
||||
"""페어링이 안 돼 있으면 soloist --pair 를 띄워 둔다. 같은 네트워크의 스포티파이 앱 기기 목록에서
|
||||
이 기기를 한 번 고르면 로그인 정보가 저장되고 종료된다."""
|
||||
if not self.enabled:
|
||||
return
|
||||
with self.lock:
|
||||
if self.pairing:
|
||||
return
|
||||
self.pairing = True
|
||||
threading.Thread(target=self._pair_loop, daemon=True).start()
|
||||
|
||||
def _pair_loop(self) -> None:
|
||||
try:
|
||||
while not self.paired:
|
||||
if sessions.running():
|
||||
time.sleep(5)
|
||||
continue
|
||||
log(f'페어링 대기: 스포티파이 앱의 기기 목록에서 "{DEVICE_NAME}"를 고르세요')
|
||||
proc = subprocess.Popen(self.base_args() + ["-p"], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True, errors="replace")
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
line = strip_ansi(line.rstrip())
|
||||
self.note_output(line)
|
||||
if " E " in line or " W " in line or "pair" in line.lower():
|
||||
log(f"[pair] {line}")
|
||||
code = proc.wait()
|
||||
if code == 0 and self.paired:
|
||||
log("페어링 완료")
|
||||
break
|
||||
if code == EXIT_EXPIRED:
|
||||
self.update("빌드 만료")
|
||||
self.last_error = f"pair exit {code}"
|
||||
time.sleep(30)
|
||||
finally:
|
||||
with self.lock:
|
||||
self.pairing = False
|
||||
|
||||
|
||||
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def strip_ansi(s: str) -> str:
|
||||
return ANSI_RE.sub("", s)
|
||||
|
||||
|
||||
def kill(proc: subprocess.Popen | None, wait: float = 3.0) -> None:
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=wait)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=wait)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, owner: str, track: str, duration_ms: int) -> None:
|
||||
self.id = uuid.uuid4().hex
|
||||
self.owner = owner
|
||||
self.track = track
|
||||
self.duration_ms = duration_ms
|
||||
self.created = time.time()
|
||||
self.buf = bytearray()
|
||||
self.cond = threading.Condition()
|
||||
self.started = threading.Event() # 실제 소리가 나오기 시작함
|
||||
self.finished = False # MP3 출력이 끝남(정상/비정상 모두)
|
||||
self.truncated = False # 곡 도중에 끊김 → 클라이언트에 오류로 알림
|
||||
self.error = ""
|
||||
self.clients = 0
|
||||
self.last_client_seen = time.time()
|
||||
self.fed_bytes = 0
|
||||
self.solo_exit: int | None = None
|
||||
self.solo_exit_at = 0.0
|
||||
self.stopping = False
|
||||
self.logs: deque[str] = deque(maxlen=40)
|
||||
self.parec: subprocess.Popen | None = None
|
||||
self.ffmpeg: subprocess.Popen | None = None
|
||||
self.solo: subprocess.Popen | None = None
|
||||
|
||||
# ── 상태 ──
|
||||
@property
|
||||
def solo_running(self) -> bool:
|
||||
return self.solo is not None and self.solo.poll() is None
|
||||
|
||||
@property
|
||||
def played_ms(self) -> int:
|
||||
return self.fed_bytes * 1000 // PCM_BYTES_PER_SEC
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {"id": self.id, "owner": self.owner, "track": self.track, "started": self.started.is_set(),
|
||||
"finished": self.finished, "truncated": self.truncated, "error": self.error,
|
||||
"playedMs": self.played_ms, "clients": self.clients, "bytes": len(self.buf)}
|
||||
|
||||
# ── 실행 ──
|
||||
def start(self) -> None:
|
||||
self.parec = subprocess.Popen(
|
||||
["parec", "-d", f"{SINK}.monitor", "--format=s16le", f"--rate={RATE}", f"--channels={CHANNELS}",
|
||||
"--raw", "--latency-msec=50"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
self.ffmpeg = subprocess.Popen(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
# 입력 분석(기본 최대 5초 분량)을 끄지 않으면 첫 MP3가 몇 초 늦게 나온다
|
||||
"-probesize", "32", "-analyzeduration", "0", "-fflags", "nobuffer", "-f", "s16le", "-ar", str(RATE), "-ac", str(CHANNELS),
|
||||
"-i", "pipe:0", "-c:a", "libmp3lame", "-b:a", "320k", "-write_xing", "0", "-id3v2_version", "0",
|
||||
"-flush_packets", "1", "-f", "mp3", "pipe:1"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
self.solo = subprocess.Popen(
|
||||
soloist.base_args() + ["-s", f"spotify:track:{self.track}"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace")
|
||||
threading.Thread(target=self._read_solo, daemon=True).start()
|
||||
threading.Thread(target=self._pump_pcm, daemon=True).start()
|
||||
threading.Thread(target=self._pump_mp3, daemon=True).start()
|
||||
|
||||
def _read_solo(self) -> None:
|
||||
assert self.solo is not None and self.solo.stdout is not None
|
||||
for line in self.solo.stdout:
|
||||
line = strip_ansi(line.rstrip())
|
||||
if not line:
|
||||
continue
|
||||
self.logs.append(line)
|
||||
soloist.note_output(line)
|
||||
code = self.solo.wait()
|
||||
self.solo_exit, self.solo_exit_at = code, time.time()
|
||||
if code == EXIT_EXPIRED:
|
||||
soloist.update_async("빌드 만료")
|
||||
if not self.started.is_set():
|
||||
self.fail(self.reason_from_logs(code))
|
||||
|
||||
def reason_from_logs(self, code: int) -> str:
|
||||
text = "\n".join(self.logs)
|
||||
if code == EXIT_EXPIRED:
|
||||
return "build_expired"
|
||||
if "requires stored credentials" in text or "No session to restore" in text:
|
||||
soloist.start_pairing()
|
||||
return "not_paired"
|
||||
errs = [line for line in self.logs if " E " in line or " W " in line]
|
||||
return f"soloist exit {code}: " + (errs[-1] if errs else (self.logs[-1] if self.logs else ""))
|
||||
|
||||
def _pump_pcm(self) -> None:
|
||||
"""parec → (앞쪽 무음 버림) → ffmpeg. soloist가 끝나면 TAIL_SEC 뒤 ffmpeg 입력을 닫는다."""
|
||||
assert self.parec and self.parec.stdout and self.ffmpeg and self.ffmpeg.stdin
|
||||
fd = self.parec.stdout.fileno()
|
||||
pending = b""
|
||||
try:
|
||||
while not self.stopping:
|
||||
if self.solo_exit is not None and time.time() - self.solo_exit_at > TAIL_SEC:
|
||||
break
|
||||
chunk = os.read(fd, 9600) # 50ms
|
||||
if not chunk:
|
||||
break
|
||||
if not self.started.is_set():
|
||||
chunk = pending + chunk
|
||||
usable = len(chunk) - (len(chunk) % 4)
|
||||
pending = chunk[usable:]
|
||||
samples = array("h", chunk[:usable])
|
||||
if not samples or (max(samples) <= SILENCE_LEVEL and -min(samples) <= SILENCE_LEVEL):
|
||||
continue
|
||||
# 첫 소리가 있는 프레임부터 보낸다
|
||||
idx = next(i for i, v in enumerate(samples) if v > SILENCE_LEVEL or -v > SILENCE_LEVEL)
|
||||
chunk = chunk[(idx - idx % CHANNELS) * 2:usable]
|
||||
self.started.set()
|
||||
log(f"[{self.id[:8]}] 소리 시작 ({time.time() - self.created:.2f}초) track={self.track}")
|
||||
self.ffmpeg.stdin.write(chunk)
|
||||
self.fed_bytes += len(chunk)
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
kill(self.parec, 1)
|
||||
try:
|
||||
self.ffmpeg.stdin.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if self.started.is_set() and not self.stopping:
|
||||
expected = self.duration_ms
|
||||
if (self.solo_exit not in (0, None)) or (expected and self.played_ms < expected - 10000):
|
||||
self.truncated = True
|
||||
log(f"[{self.id[:8]}] 곡 도중 끊김: 재생 {self.played_ms}ms / 곡 {expected}ms, "
|
||||
f"soloist exit={self.solo_exit} {self.logs[-1] if self.logs else ''}")
|
||||
|
||||
def _pump_mp3(self) -> None:
|
||||
assert self.ffmpeg and self.ffmpeg.stdout
|
||||
fd = self.ffmpeg.stdout.fileno()
|
||||
while True:
|
||||
data = os.read(fd, 65536)
|
||||
if not data:
|
||||
break
|
||||
with self.cond:
|
||||
self.buf += data
|
||||
self.cond.notify_all()
|
||||
if len(self.buf) > MAX_BUF:
|
||||
log(f"[{self.id[:8]}] 버퍼 상한 초과, 세션 종료")
|
||||
self.stop(truncate=True)
|
||||
break
|
||||
self.ffmpeg.wait()
|
||||
with self.cond:
|
||||
self.finished = True
|
||||
self.cond.notify_all()
|
||||
kill(self.solo)
|
||||
|
||||
def fail(self, reason: str) -> None:
|
||||
if not self.error:
|
||||
self.error = reason
|
||||
self.stop(truncate=True)
|
||||
self.started.set() # prepare 대기를 깨운다(error로 판별)
|
||||
|
||||
def stop(self, truncate: bool = False) -> None:
|
||||
if truncate:
|
||||
self.truncated = True
|
||||
self.stopping = True
|
||||
kill(self.solo)
|
||||
kill(self.parec, 1)
|
||||
if self.ffmpeg and self.ffmpeg.stdin:
|
||||
try:
|
||||
self.ffmpeg.stdin.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
with self.cond:
|
||||
self.cond.notify_all()
|
||||
|
||||
|
||||
class Sessions:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.items: dict[str, Session] = {}
|
||||
|
||||
def running(self) -> Session | None:
|
||||
with self.lock:
|
||||
return next((s for s in self.items.values() if s.solo_running or (not s.started.is_set() and not s.error)), None)
|
||||
|
||||
def get(self, sid: str) -> Session | None:
|
||||
with self.lock:
|
||||
return self.items.get(sid)
|
||||
|
||||
def create(self, owner: str, track: str, duration_ms: int) -> tuple[Session | None, str]:
|
||||
with self.lock:
|
||||
active = [s for s in self.items.values() if s.solo_running or (not s.started.is_set() and not s.error)]
|
||||
for s in active:
|
||||
in_use = s.clients > 0 or time.time() - s.last_client_seen < IDLE_SEC
|
||||
if s.owner != owner and in_use:
|
||||
return None, "busy"
|
||||
for s in active:
|
||||
if s.started.is_set():
|
||||
s.stop(truncate=True)
|
||||
else:
|
||||
s.fail("preempted") # 아직 준비 중인 요청은 바로 실패로 돌려준다
|
||||
session = Session(owner, track, duration_ms)
|
||||
self.items[session.id] = session
|
||||
# 이전 soloist가 데이터 폴더 잠금(.lock)을 놓을 때까지 기다린다
|
||||
for s in active:
|
||||
if s.solo:
|
||||
try:
|
||||
s.solo.wait(timeout=5)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
session.start()
|
||||
return session, ""
|
||||
|
||||
def reap(self) -> None:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
items = list(self.items.values())
|
||||
for s in items:
|
||||
idle = s.clients == 0 and now - s.last_client_seen > IDLE_SEC
|
||||
if idle and not s.finished and s.started.is_set():
|
||||
log(f"[{s.id[:8]}] 받는 쪽이 없어 세션 종료")
|
||||
s.stop(truncate=True)
|
||||
if (s.finished or s.error) and idle:
|
||||
with self.lock:
|
||||
self.items.pop(s.id, None)
|
||||
|
||||
|
||||
soloist = Soloist()
|
||||
sessions = Sessions()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args): # noqa: N802
|
||||
pass
|
||||
|
||||
def send_json(self, code: int, obj: dict) -> None:
|
||||
body = json.dumps(obj, ensure_ascii=False).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
u = urlparse(self.path)
|
||||
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
if u.path == "/health":
|
||||
run = sessions.running()
|
||||
return self.send_json(200, {
|
||||
"enabled": soloist.enabled, "paired": soloist.paired, "pairing": soloist.pairing,
|
||||
"deviceName": DEVICE_NAME, "version": soloist.version, "expiresInDays": soloist.expires_days,
|
||||
"busy": run.summary() if run else None})
|
||||
if u.path == "/prepare":
|
||||
return self.prepare(q)
|
||||
m = re.fullmatch(r"/stream/([0-9a-f]{32})\.mp3", u.path)
|
||||
if m:
|
||||
return self.stream(m.group(1))
|
||||
self.send_json(404, {"error": "not_found"})
|
||||
|
||||
def prepare(self, q: dict) -> None:
|
||||
track = q.get("track", "")
|
||||
owner = q.get("owner", "")
|
||||
try:
|
||||
duration = int(q.get("duration", "0") or 0)
|
||||
except ValueError:
|
||||
duration = 0
|
||||
if not TRACK_RE.match(track) or not owner:
|
||||
return self.send_json(400, {"error": "bad_request"})
|
||||
if not soloist.enabled:
|
||||
return self.send_json(503, {"error": "disabled"})
|
||||
if not soloist.paired:
|
||||
soloist.start_pairing()
|
||||
return self.send_json(503, {"error": "not_paired"})
|
||||
if soloist.pairing:
|
||||
return self.send_json(503, {"error": "pairing"})
|
||||
t0 = time.time()
|
||||
session, err = sessions.create(owner, track, duration)
|
||||
if not session:
|
||||
return self.send_json(409, {"error": err})
|
||||
session.started.wait(PREPARE_TIMEOUT)
|
||||
if not session.started.is_set():
|
||||
session.fail("no_audio_timeout")
|
||||
if session.error:
|
||||
log(f"[{session.id[:8]}] 준비 실패 track={track}: {session.error}")
|
||||
return self.send_json(502, {"error": session.error})
|
||||
session.last_client_seen = time.time()
|
||||
self.send_json(200, {"path": f"/stream/{session.id}.mp3", "id": session.id,
|
||||
"readyMs": int((time.time() - t0) * 1000)})
|
||||
|
||||
def stream(self, sid: str) -> None:
|
||||
s = sessions.get(sid)
|
||||
if not s or s.error:
|
||||
return self.send_json(404, {"error": "no_session"})
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "audio/mpeg")
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.close_connection = True
|
||||
with s.cond:
|
||||
s.clients += 1
|
||||
s.last_client_seen = time.time()
|
||||
pos = 0
|
||||
clean = False
|
||||
t_open = time.time()
|
||||
try:
|
||||
while True:
|
||||
with s.cond:
|
||||
while len(s.buf) <= pos and not s.finished and not s.stopping:
|
||||
s.cond.wait(1.0)
|
||||
data = bytes(s.buf[pos:pos + 65536])
|
||||
done = (s.finished or s.stopping) and pos + len(data) >= len(s.buf)
|
||||
if data:
|
||||
self.wfile.write(b"%x\r\n" % len(data) + data + b"\r\n")
|
||||
pos += len(data)
|
||||
if done:
|
||||
if not s.truncated:
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
clean = True
|
||||
break
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass
|
||||
finally:
|
||||
with s.cond:
|
||||
s.clients -= 1
|
||||
s.last_client_seen = time.time()
|
||||
log(f"[{s.id[:8]}] 스트림 연결 종료: {pos}바이트, {time.time() - t_open:.1f}초, 정상종료={clean}")
|
||||
if not clean:
|
||||
# 청크 종료 없이 끊어 받는 쪽이 '비정상 종료'로 알게 한다
|
||||
try:
|
||||
self.connection.shutdown(2)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not soloist.enabled:
|
||||
log("SOLOIST_API_KEY 없음 → 비활성 상태로 시작(/prepare는 503, 봇은 유튜브로 재생)")
|
||||
else:
|
||||
if not os.path.exists(BIN):
|
||||
soloist.update("실행 파일 없음")
|
||||
soloist.read_version()
|
||||
log(f"soloist: {soloist.version or '(없음)'} / 기기 이름: {DEVICE_NAME} / 페어링: {soloist.paired}")
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
if not soloist.paired:
|
||||
soloist.start_pairing()
|
||||
threading.Thread(target=sessions.reap, daemon=True).start()
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
server.daemon_threads = True
|
||||
log(f"listening on :{PORT}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
17
soloist/compose.yml
Normal file
17
soloist/compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
# .5 호스트 배포 예시: /root/bot 에서
|
||||
# docker compose -f music_bot_v2/soloist/compose.yml up -d --build
|
||||
# 스포티파이 앱의 기기 목록에 보이려면(페어링) 같은 LAN에서 mDNS가 닿아야 하므로 host 네트워크를 쓴다.
|
||||
services:
|
||||
soloist-bridge:
|
||||
build: .
|
||||
image: soloist-bridge:latest
|
||||
container_name: soloist-bridge
|
||||
restart: always
|
||||
network_mode: host
|
||||
# SOLOIST_API_KEY=... (스포티파이 개발자 대시보드에서 발급, 커밋 금지)
|
||||
env_file: /root/bot/soloist/soloist.env
|
||||
environment:
|
||||
- SOLOIST_DEVICE_NAME=music_bot
|
||||
volumes:
|
||||
# 페어링 정보·soloist 실행 파일이 여기 남는다(지우면 다시 페어링해야 함)
|
||||
- /root/bot/soloist/data:/data
|
||||
12
soloist/entrypoint.sh
Executable file
12
soloist/entrypoint.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# PulseAudio를 빈 싱크(soloist) 하나만 둔 채로 띄우고 중계 서버를 실행한다.
|
||||
# soloist는 PipeWire가 없으면 PulseAudio 기본 출력으로 재생하고, 중계 서버는 그 싱크의 모니터를 캡처한다.
|
||||
set -e
|
||||
export XDG_RUNTIME_DIR=/run/soloist-pulse
|
||||
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
|
||||
pulseaudio -n --daemonize=yes --exit-idle-time=-1 --disallow-exit --log-target=stderr \
|
||||
-L "module-native-protocol-unix" \
|
||||
-L "module-null-sink sink_name=${PULSE_SINK:-soloist} rate=48000 channels=2 format=s16le"
|
||||
for i in $(seq 1 50); do pactl info >/dev/null 2>&1 && break; sleep 0.1; done
|
||||
pactl set-default-sink "${PULSE_SINK:-soloist}"
|
||||
exec python3 /app/bridge.py
|
||||
Reference in New Issue
Block a user