mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-03 05:22:49 +08:00
feat(spotify): add go-librespot REST client + WS event client (Stage 2)
GoLibrespotRestClient wraps axios (injectable via deps.http) for
/player/play|pause|resume|stop|seek, GET /status, GET / ping; ping/getStatus
swallow errors to false/null, mutating ops reject. GoLibrespotEventClient
(EventEmitter) parses {type,data} /events frames and re-emits type with data,
reconnects on close with capped backoff, stop() tears down. TDD with a mock
AxiosInstance and a fake WebSocket (no real binary/network).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
470a62129a
commit
f2b14b5f00
2 files changed
+405
No files matched your search
@@ -0,0 +1,187 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
import WebSocket from "ws";
|
||||
|
||||
export interface GoLibrespotStatusTrack {
|
||||
uri: string;
|
||||
name: string;
|
||||
artist_names: string[];
|
||||
album_name: string;
|
||||
album_cover_url: string | null;
|
||||
position: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface GoLibrespotStatus {
|
||||
stopped: boolean;
|
||||
paused: boolean;
|
||||
buffering: boolean;
|
||||
track: GoLibrespotStatusTrack | null;
|
||||
}
|
||||
|
||||
export class GoLibrespotRestClient {
|
||||
private http: AxiosInstance;
|
||||
|
||||
constructor(baseUrl: string, deps?: { http?: AxiosInstance }) {
|
||||
this.http =
|
||||
deps?.http ??
|
||||
axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 10000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.http.get("/");
|
||||
return res.status === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async playTrack(uri: string): Promise<void> {
|
||||
await this.http.post("/player/play", { uri });
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await this.http.post("/player/pause");
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
await this.http.post("/player/resume");
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.http.post("/player/stop");
|
||||
}
|
||||
|
||||
async seek(ms: number): Promise<void> {
|
||||
await this.http.post("/player/seek", { position: ms, relative: false });
|
||||
}
|
||||
|
||||
async getStatus(): Promise<GoLibrespotStatus | null> {
|
||||
try {
|
||||
const res = await this.http.get("/status");
|
||||
const d = res.data ?? {};
|
||||
const t = d.track;
|
||||
return {
|
||||
stopped: Boolean(d.stopped),
|
||||
paused: Boolean(d.paused),
|
||||
buffering: Boolean(d.buffering),
|
||||
track: t
|
||||
? {
|
||||
uri: t.uri ?? "",
|
||||
name: t.name ?? "",
|
||||
artist_names: Array.isArray(t.artist_names) ? t.artist_names : [],
|
||||
album_name: t.album_name ?? "",
|
||||
album_cover_url: t.album_cover_url ?? null,
|
||||
position: t.position ?? 0,
|
||||
duration: t.duration ?? 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GoLibrespotEventType =
|
||||
| "metadata"
|
||||
| "playing"
|
||||
| "paused"
|
||||
| "not_playing"
|
||||
| "stopped"
|
||||
| "will_play"
|
||||
| "seek"
|
||||
| "active"
|
||||
| "inactive"
|
||||
| "volume"
|
||||
| "playback_ready";
|
||||
|
||||
interface WsLike {
|
||||
on(event: string, cb: (...args: any[]) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
type WebSocketCtor = new (url: string) => WsLike;
|
||||
|
||||
const INITIAL_RECONNECT_MS = 500;
|
||||
const MAX_RECONNECT_MS = 10000;
|
||||
|
||||
export class GoLibrespotEventClient extends EventEmitter {
|
||||
private wsUrl: string;
|
||||
private WebSocketCtor: WebSocketCtor;
|
||||
private ws: WsLike | null = null;
|
||||
private stopped = false;
|
||||
private reconnectDelay = INITIAL_RECONNECT_MS;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(wsUrl: string, deps?: { WebSocketCtor?: WebSocketCtor }) {
|
||||
super();
|
||||
this.wsUrl = wsUrl;
|
||||
this.WebSocketCtor = deps?.WebSocketCtor ?? (WebSocket as unknown as WebSocketCtor);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (this.stopped) return;
|
||||
const ws = new this.WebSocketCtor(this.wsUrl);
|
||||
this.ws = ws;
|
||||
ws.on("open", () => {
|
||||
this.reconnectDelay = INITIAL_RECONNECT_MS;
|
||||
});
|
||||
ws.on("message", (buf: unknown) => this.handleMessage(buf));
|
||||
ws.on("close", () => {
|
||||
this.ws = null;
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
ws.on("error", (err: unknown) => {
|
||||
if (this.listenerCount("error") > 0) this.emit("error", err);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(buf: unknown): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const text = Buffer.isBuffer(buf)
|
||||
? buf.toString("utf8")
|
||||
: typeof buf === "string"
|
||||
? buf
|
||||
: String(buf);
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (parsed && typeof (parsed as any).type === "string") {
|
||||
this.emit((parsed as any).type, (parsed as any).data ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped || this.reconnectTimer) return;
|
||||
const delay = this.reconnectDelay;
|
||||
this.reconnectDelay = Math.min(delay * 2, MAX_RECONNECT_MS);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user