diff --git a/README.md b/README.md index c54f2b1..b58d0c2 100644 --- a/README.md +++ b/README.md @@ -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 | ## 전체 아키텍처 diff --git a/bot/.env.example b/bot/.env.example index 9f9eb8e..96c5069 100644 --- a/bot/.env.example +++ b/bot/.env.example @@ -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 diff --git a/bot/README.md b/bot/README.md index e80f4d8..7b72f32 100644 --- a/bot/README.md +++ b/bot/README.md @@ -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 설치 필수). --- diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index a7f67c3..0ef1ab9 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -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; }; +// _soloist: 지금 Soloist(스포티파이 공식 클라이언트) 스트림으로 재생 중인지. 이 스트림은 위치 이동이 안 된다. +type QueueTrack = Track & { userId: string; _resolved?: Promise; _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 { + 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 { 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 }); } diff --git a/bot/src/utils/Config.ts b/bot/src/utils/Config.ts index 64ffc3d..a0e2d74 100644 --- a/bot/src/utils/Config.ts +++ b/bot/src/utils/Config.ts @@ -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: { diff --git a/bot/src/utils/api/Soloist.ts b/bot/src/utils/api/Soloist.ts new file mode 100644 index 0000000..271b730 --- /dev/null +++ b/bot/src/utils/api/Soloist.ts @@ -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 { + 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}분 동안 유튜브로만 재생합니다.`); + }, +}; diff --git a/soloist/Dockerfile b/soloist/Dockerfile new file mode 100644 index 0000000..8780dbc --- /dev/null +++ b/soloist/Dockerfile @@ -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"] diff --git a/soloist/README.md b/soloist/README.md new file mode 100644 index 0000000..f740d67 --- /dev/null +++ b/soloist/README.md @@ -0,0 +1,50 @@ +# soloist-bridge — 스포티파이 곡을 스포티파이 공식 클라이언트로 재생 + +스포티파이 곡(스포티파이 주소·재생목록·`-p` 검색·자동재생 추천곡)을 유튜브로 바꾸지 않고 +스포티파이 공식 헤드리스 클라이언트 [Soloist](https://developer.spotify.com/documentation/soloist)로 재생한다. +Soloist가 안 되면(설정 없음·페어링 전·다른 서버가 사용 중·재생 오류) 봇이 바로 기존 유튜브 경로로 재생한다. + +``` +봇 ── GET /prepare?track=&owner=<봇:길드>&duration= ──▶ soloist-bridge(.5:8780, docker, host 네트워크) + ├ soloist --single-track spotify:track: + │ └ PulseAudio 빈 싱크 "soloist"로 출력 + ├ parec(싱크 모니터) → 앞쪽 무음 제거 → ffmpeg MP3 320k + └ 첫 소리가 나오면 {"path":"/stream/.mp3"} 응답 +Lavalink(.6) ── GET /stream/.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 포트를 열지 말 것. diff --git a/soloist/bridge.py b/soloist/bridge.py new file mode 100644 index 0000000..701c4ec --- /dev/null +++ b/soloist/bridge.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +""" +soloist-bridge — 스포티파이 공식 헤드리스 클라이언트(Soloist)로 곡을 재생하고, 그 소리를 MP3 스트림으로 +Lavalink(http 소스)에 넘겨 주는 중계 서버. + +흐름 (곡 하나마다) + 봇 → GET /prepare?track=<스포티파이 곡 id>&owner=<봇:길드>&duration= + → parec(빈 싱크 모니터) + ffmpeg(MP3 인코딩)을 먼저 띄우고 soloist --single-track 실행 + → 앞쪽 무음을 버리고 실제 소리가 나오기 시작하면 {"path": "/stream/.mp3"} 응답 + (정해진 시간 안에 소리가 없거나 soloist가 먼저 끝나면 오류 응답 → 봇이 유튜브로 재생) + Lavalink → GET /stream/.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는 로그인 토큰을 /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()) diff --git a/soloist/compose.yml b/soloist/compose.yml new file mode 100644 index 0000000..c179b77 --- /dev/null +++ b/soloist/compose.yml @@ -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 diff --git a/soloist/entrypoint.sh b/soloist/entrypoint.sh new file mode 100755 index 0000000..d25b716 --- /dev/null +++ b/soloist/entrypoint.sh @@ -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