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