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:
2026-10-03 20:33:46 +09:00
parent 63456d8101
commit 8087e2e98b
11 changed files with 772 additions and 3 deletions

View File

@@ -10,6 +10,7 @@
| [`bot/`](bot) | 디스코드 봇 본체(discord.js + shoukaku/Lavalink). 검색·재생·대기열·자동재생·연령제한 우회. | [bot/README.md](bot/README.md) | | [`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) | | [`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 | | [`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 | | [`scripts/`](scripts) | `refresh-cookies.mjs` — 유튜브 쿠키(`ytcookie.txt`) 재발급 스크립트(Chromium). | bot/README §4 |
## 전체 아키텍처 ## 전체 아키텍처

View File

@@ -34,6 +34,10 @@ YOUTUBE_COOKIE_PATH=/db/ytcookie.txt
# --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) --- # --- yt-dlp 오디오 리졸버 (성인인증 SABR 우회, .5 호스트에서 실행) ---
YTRESOLVER_URL=http://192.168.10.5:8779 YTRESOLVER_URL=http://192.168.10.5:8779
# --- 스포티파이 Soloist 중계 서버 (선택, 리포 루트 soloist/) ---
# 스포티파이 곡을 Soloist로 먼저 재생하고, 안 되면 유튜브로 재생한다. 비우면 유튜브로만 재생.
SOLOIST_URL=http://192.168.10.5:8780
# --- Redis (대시보드 상태 연동, 선택) --- # --- Redis (대시보드 상태 연동, 선택) ---
REDIS=true REDIS=true
REDIS_HOST=192.168.10.7 REDIS_HOST=192.168.10.7

View File

@@ -100,6 +100,7 @@ sqlite DB(`music1.db` 등)와 쿠키(`ytcookie.txt`)가 컨테이너에서 `/db/
| `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 | | `YOUTUBE_COOKIE_PATH` | **Netscape 쿠키 파일 경로**(기본 `/db/ytcookie.txt`). 리졸버·검색 공용 쿠키 소스 |
| `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 | | `YOUTUBE_COOKIE` | (선택·하위호환) 쿠키 헤더 문자열 인라인. 설정 시 이 값이 우선 |
| `YTRESOLVER_URL` | 리졸버 주소(기본 `http://192.168.10.5:8779`) | | `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` | 대시보드 제어/상태 연동(선택) | | `REDIS/REDIS_HOST/REDIS_PORT` | 대시보드 제어/상태 연동(선택) |
> `bot/.gitignore`가 `*.env`, `*.db`를 제외하므로 실제 `.env`와 DB는 커밋되지 않는다. > `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`, - **전부 재생 안 됨**: Lavalink(.6) 또는 ytresolver(.5) 다운 확인. `systemctl status ytresolver`,
`curl http://192.168.10.5:8779/health`, Lavalink `:2333`. `curl http://192.168.10.5:8779/health`, Lavalink `:2333`.
- **유튜브뮤직 검색 품질**: YTM API가 이 계정/엔드포인트에선 음악 카탈로그 '노래'를 거의 안 주고 일반 유튜브 영상(토크쇼·재생목록·MV 등)을 반환한다. 그래서 애매한 결과는 버리고 `ytsearch:… Topic`(공식 오디오)로 폴백하도록 해둠. 더 깨끗한 공식 오디오가 필요하면 검색에 `-p`(스포티파이 우선)를 쓴다. - **유튜브뮤직 검색 품질**: 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 설치 필수). - **yt-dlp EJS(시그니처) 오류**: 리졸버는 연령제한 영상에 `--js-runtimes node`가 필요하다(node 설치 필수).
--- ---

View File

@@ -10,6 +10,7 @@ import { checkTextChannelAndMsg } from "../utils/music/Channel";
import { Logger } from "../utils/Logger"; import { Logger } from "../utils/Logger";
import { Config } from "../utils/Config"; import { Config } from "../utils/Config";
import { Spotify } from "../utils/api/Spotify"; import { Spotify } from "../utils/api/Spotify";
import { Soloist } from "../utils/api/Soloist";
const DelayAfterErrMs = 1000 * 5; const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10; const idleEndTime = 1000 * 60 * 10;
@@ -21,7 +22,8 @@ const LoadFailRetryDelayMs = 2500;
// _resolved: yt-dlp 리졸브 결과(http 소스 encoded)를 미리 받아두는 프리페치 캐시. // _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 { export class GuildPlayer {
private isDead = false; private isDead = false;
@@ -38,6 +40,8 @@ export class GuildPlayer {
private lastPosition = 0; private lastPosition = 0;
private lastProgressAt = 0; private lastProgressAt = 0;
private loadFailCount = 0; private loadFailCount = 0;
/** Soloist 스트림이 곡 도중에 끊긴 재생 위치(ms). end 이벤트에서 유튜브 음원으로 그 위치부터 이어 재생한다. */
private soloistCutAt: number | undefined;
constructor( constructor(
public guild: Guild, public guild: Guild,
@@ -69,6 +73,21 @@ export class GuildPlayer {
try { try {
if (this.isDead) return; if (this.isDead) return;
if (data.reason === "replaced") 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): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면 // 재생 실패(loadFailed): 유튜브의 일시적 봇탐지/차단은 같은 곡을 잠시 뒤 다시 시도하면
// 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대 // 대부분 성공한다(같은 곡이 됐다 안 됐다 하는 확률적 현상). 곡을 건너뛰기 전에 최대
// MaxLoadFailRetry회 같은 곡을 자동 재시도한다. // MaxLoadFailRetry회 같은 곡을 자동 재시도한다.
@@ -80,6 +99,8 @@ export class GuildPlayer {
await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs)); await new Promise((r) => setTimeout(r, LoadFailRetryDelayMs));
if (this.isDead) return; if (this.isDead) return;
if (this.queue[0]?.encoded === cur.encoded) { if (this.queue[0]?.encoded === cur.encoded) {
// Soloist 스트림이 끊긴 경우도 여기로 온다 → 유튜브 음원으로 다시 재생
cur._soloist = false;
const reEncoded = await this.resolvePlayable(cur); const reEncoded = await this.resolvePlayable(cur);
if (reEncoded) { if (reEncoded) {
await this.player.playTrack({ track: { encoded: reEncoded } }); await this.player.playTrack({ track: { encoded: reEncoded } });
@@ -136,6 +157,7 @@ export class GuildPlayer {
this.player.on("exception", async (data) => { this.player.on("exception", async (data) => {
try { try {
if (this.queue[0]?._soloist) this.soloistCutAt = this.player.position;
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`); Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}. (loadFailed end 핸들러가 같은 곡 재시도/스킵 처리)`);
// Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을 // Lavalink는 exception 직후 TrackEndEvent(loadFailed)를 보내므로 end 핸들러가 재시도/스킵을
// 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다. // 처리한다. 여기서 stopTrack이나 errMsg를 부르면 중복 진행/오탐 메시지가 되므로 로그만 남긴다.
@@ -198,7 +220,8 @@ export class GuildPlayer {
this.attachPlayerListeners(); this.attachPlayerListeners();
await this.player.setGlobalVolume(savedVolume || 50); await this.player.setGlobalVolume(savedVolume || 50);
// 곡이 있었으면 이어서 재생 // 곡이 있었으면 이어서 재생 (Soloist 스트림은 이어 받을 수 없으므로 유튜브 음원으로 그 위치부터)
if (currentTrack) currentTrack._soloist = false;
const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : ""; const reEncoded = currentTrack ? await this.resolvePlayable(currentTrack) : "";
if (currentTrack && reEncoded) { if (currentTrack && reEncoded) {
await this.player.playTrack({ await this.player.playTrack({
@@ -342,11 +365,38 @@ export class GuildPlayer {
return track._resolved!; 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> { public async playNext(): Promise<void> {
const track = this.queue[0]; const track = this.queue[0];
if (!track) return this.end(); if (!track) return this.end();
const tResolve = Date.now(); 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에 가까움)`); Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
if (!encoded) { if (!encoded) {
// 재생할 음원을 찾지 못한 곡(스포티파이 곡의 유튜브 원곡을 못 찾음)은 대기열에서 빼고 다음 곡으로 // 재생할 음원을 찾지 못한 곡(스포티파이 곡의 유튜브 원곡을 못 찾음)은 대기열에서 빼고 다음 곡으로
@@ -408,6 +458,18 @@ export class GuildPlayer {
public seek(num: number) { public seek(num: number) {
if (!this.isPlaying) return; if (!this.isPlaying) return;
if (!this.nowTrack) 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); this.player.seekTo(num);
Redis?.publishState("player_update", { guildId: this.guild.id }); Redis?.publishState("player_update", { guildId: this.guild.id });
} }

View File

@@ -106,6 +106,10 @@ export const Config = {
// yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located. // yt-dlp 오디오 리졸버(ytresolver) 주소. 리졸버는 봇/쿠키와 같은 .5 호스트에 co-located.
ytresolverUrl: (process.env.YTRESOLVER_URL?.trim() || "http://192.168.10.5:8779").replace(/\/+$/, ""), 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() || "", proxyUrl: process.env.PROXY_URL?.trim() || "",
_redis: { _redis: {

View 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
View 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
View 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
View 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
View 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
View 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