added multiPlatform

added multiPlatform and support for receiving data from Yandex smart speakers
This commit is contained in:
Druidblack 2026-04-09 22:31:18 +03:00 committed by GitHub
parent 6a81fc1be2
commit eeb55aa02e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -29,15 +29,21 @@ interface BridgeTrackData {
explicit?: boolean
context_type?: string
queue_id?: string
player_id?: string
device_id?: string
device_name?: string
platform?: string
source?: string
timestamp?: number
}
interface BridgeNowPlayingResponse {
interface BridgePlayersResponse {
ok: boolean
source?: string
fresh?: boolean
data?: BridgeTrackData | null
players_count?: number
primary?: BridgeTrackData | null
data?: BridgeTrackData[] | BridgeTrackData | null
}
interface SyntheticPlaybackState {
@ -48,14 +54,51 @@ interface SyntheticPlaybackState {
reachedTrackEndAtMs?: number
}
const normalizeBridgeData = (obj: BridgeTrackData): BridgeTrackData => {
const normalized: BridgeTrackData = { ...obj };
if (normalized.progress_ms !== undefined && normalized.progress_ms !== null) {
normalized.progress_ms = Math.max(0, Number(normalized.progress_ms) || 0);
}
if (normalized.duration_ms !== undefined && normalized.duration_ms !== null) {
const dur = Number(normalized.duration_ms);
normalized.duration_ms = Number.isFinite(dur) && dur > 0 ? dur : undefined;
}
if (normalized.duration_ms !== undefined && normalized.progress_ms !== undefined) {
normalized.progress_ms = Math.min(normalized.progress_ms, normalized.duration_ms);
}
return normalized;
}
const getSafePositionSec = (obj: BridgeTrackData): number | undefined => {
if (obj.progress_ms === undefined || obj.progress_ms === null) {
return undefined;
}
const progressSec = Math.max(0, obj.progress_ms / 1000);
const durationSec = obj.duration_ms !== undefined && obj.duration_ms !== null && obj.duration_ms > 0
? obj.duration_ms / 1000
: undefined;
if (obj.source === 'station-local') {
return durationSec !== undefined ? Math.min(progressSec, durationSec) : 0;
}
return durationSec !== undefined ? Math.min(progressSec, durationSec) : progressSec;
}
export default class YandexMusicBridgeSource extends MemoryPositionalSource {
declare config: YandexMusicBridgeSourceConfig;
urlData!: URLData;
private syntheticPlayback?: SyntheticPlaybackState;
private lastBridgeData?: BridgeTrackData;
private lastPlay?: PlayObject;
private lastBridgeSeenAtMs?: number;
private syntheticPlaybackByPlayer = new Map<string, SyntheticPlaybackState>();
private lastBridgeDataByPlayer = new Map<string, BridgeTrackData>();
private lastPlayByPlayer = new Map<string, PlayObject>();
private lastBridgeSeenAtMsByPlayer = new Map<string, number>();
private keepAliveMinSec = 90;
private keepAlivePaddingSec = 120;
private keepAliveHardCapSec = 600;
@ -65,6 +108,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
super('ymbridge', name, config, internal, emitter);
this.requiresAuth = false;
this.canPoll = true;
this.multiPlatform = true;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
@ -91,36 +135,68 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
this.logger.info(`Yandex Music bridge is reachable at ${this.urlData.url.host}`);
return true;
}
throw new UpstreamError('Bridge health endpoint did not return JSON', {responseBody: resp.body, showStopper: true});
throw new UpstreamError('Bridge health endpoint did not return JSON', { responseBody: resp.body, showStopper: true });
} catch (e: any) {
const hint = e?.response?.text ?? e?.message ?? undefined;
throw new UpstreamError(`Could not connect to Yandex Music bridge${hint !== undefined ? ` (${hint})` : ''}`, { cause: e, responseBody: e?.response?.text, showStopper: true });
}
}
private async callBridge(): Promise<BridgeNowPlayingResponse> {
const bridgeUrl = joinedUrl(this.urlData.url, 'now-playing').toString();
const req = request.get(bridgeUrl).timeout({ response: 5000, deadline: 10000 });
private async callBridge(): Promise<BridgePlayersResponse> {
const apiKey = this.config.data?.apiKey;
if (apiKey !== undefined && apiKey.trim() !== '') {
req.set('X-API-Key', apiKey);
}
try {
const resp = await req;
if (resp.body === undefined || typeof resp.body !== 'object') {
throw new UpstreamError('Bridge returned no JSON payload', {responseBody: resp.body, showStopper: true});
const makeReq = (path: string) => {
const req = request.get(joinedUrl(this.urlData.url, path).toString()).timeout({ response: 5000, deadline: 10000 });
if (apiKey !== undefined && apiKey.trim() !== '') {
req.set('X-API-Key', apiKey);
}
return resp.body as BridgeNowPlayingResponse;
} catch (e) {
throw new UpstreamError('Bridge not return an expected response', {responseBody: e?.response?.text, cause: e, showStopper: true});
return req;
};
try {
const resp = await makeReq('players');
if (resp.body === undefined || typeof resp.body !== 'object') {
throw new UpstreamError('Bridge returned no JSON payload', { responseBody: resp.body, showStopper: true });
}
return resp.body as BridgePlayersResponse;
} catch (e: any) {
if (e?.status !== 404) {
throw new UpstreamError('Bridge did not return an expected response from /players', { responseBody: e?.response?.text, cause: e, showStopper: true });
}
const resp = await makeReq('now-playing');
if (resp.body === undefined || typeof resp.body !== 'object') {
throw new UpstreamError('Bridge returned no JSON payload', { responseBody: resp.body, showStopper: true });
}
const body = resp.body as any;
const single = body?.data ?? null;
return {
ok: body?.ok ?? true,
source: body?.source,
fresh: body?.fresh,
players_count: single ? 1 : 0,
primary: single,
data: single ? [single] : [],
};
}
}
private resetSyntheticPlayback() {
this.syntheticPlayback = undefined;
this.lastBridgeData = undefined;
this.lastPlay = undefined;
this.lastBridgeSeenAtMs = undefined;
private getPlayerId(bridgeData: BridgeTrackData): string {
const raw = bridgeData.player_id ?? bridgeData.device_id ?? bridgeData.queue_id ?? bridgeData.track_id;
return raw !== undefined && raw !== null && `${raw}`.trim() !== '' ? `${raw}` : NO_DEVICE;
}
private isStationLocal(bridgeData: BridgeTrackData): boolean {
return bridgeData.source === 'station-local';
}
private removePlayerState(playerId: string): void {
this.syntheticPlaybackByPlayer.delete(playerId);
this.lastBridgeDataByPlayer.delete(playerId);
this.lastPlayByPlayer.delete(playerId);
this.lastBridgeSeenAtMsByPlayer.delete(playerId);
}
private clearSyntheticPlayback(playerId: string): void {
this.syntheticPlaybackByPlayer.delete(playerId);
}
private getSyntheticKey(bridgeData: BridgeTrackData, play: PlayObject): string {
@ -128,7 +204,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
? bridgeData.artists_list.join(',')
: (bridgeData.artists ?? play.data.artists?.join(',') ?? '');
return [
bridgeData.queue_id ?? '',
this.getPlayerId(bridgeData),
bridgeData.track_id ?? '',
bridgeData.title ?? play.data.track ?? '',
artists,
@ -136,55 +212,58 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
].join('::');
}
private getPlaybackState(
bridgeData: BridgeTrackData,
play: PlayObject,
options: { keepAlive?: boolean } = {},
): { status: typeof REPORTED_PLAYER_STATUSES.playing, position: number } {
const { keepAlive = false } = options;
private getPlaybackState(playerId: string, bridgeData: BridgeTrackData, play: PlayObject): { status: typeof REPORTED_PLAYER_STATUSES.playing, position: number } {
const now = Date.now();
const reportedPositionSec = bridgeData.progress_ms !== undefined && bridgeData.progress_ms !== null
? Math.max(0, bridgeData.progress_ms / 1000)
: undefined;
const durationSec = play.data.duration;
const key = this.getSyntheticKey(bridgeData, play);
if (this.syntheticPlayback === undefined || this.syntheticPlayback.key !== key) {
if (this.isStationLocal(bridgeData)) {
const position = durationSec !== undefined && durationSec > 0
? Math.min(reportedPositionSec ?? play.meta.trackProgressPosition ?? 0, durationSec)
: (reportedPositionSec ?? play.meta.trackProgressPosition ?? 0);
this.clearSyntheticPlayback(playerId);
return {
status: REPORTED_PLAYER_STATUSES.playing,
position: Math.max(0, position),
};
}
const key = this.getSyntheticKey(bridgeData, play);
const current = this.syntheticPlaybackByPlayer.get(playerId);
if (current === undefined || current.key !== key) {
const initialPosition = reportedPositionSec ?? play.meta.trackProgressPosition ?? 0;
this.syntheticPlayback = {
this.syntheticPlaybackByPlayer.set(playerId, {
key,
lastSeenAtMs: now,
lastPositionSec: initialPosition,
durationSec,
reachedTrackEndAtMs: durationSec !== undefined && durationSec > 0 && initialPosition >= durationSec ? now : undefined,
};
});
return {
status: REPORTED_PLAYER_STATUSES.playing,
position: durationSec !== undefined && durationSec > 0 ? Math.min(initialPosition, durationSec) : initialPosition,
};
}
const elapsedSec = Math.max(0, (now - this.syntheticPlayback.lastSeenAtMs) / 1000);
const elapsedSec = Math.max(0, (now - current.lastSeenAtMs) / 1000);
const startingPoint = reportedPositionSec !== undefined
? Math.max(reportedPositionSec, this.syntheticPlayback.lastPositionSec)
: this.syntheticPlayback.lastPositionSec;
? Math.max(reportedPositionSec, current.lastPositionSec)
: current.lastPositionSec;
let nextPosition = startingPoint + elapsedSec;
if (durationSec !== undefined && durationSec > 0) {
// Never let synthetic time run past track duration. Once it reaches the end
// we will keep the player alive briefly and then emit a synthetic STOP.
if (nextPosition >= durationSec) {
nextPosition = durationSec;
if (this.syntheticPlayback.reachedTrackEndAtMs === undefined) {
this.syntheticPlayback.reachedTrackEndAtMs = now;
}
if (durationSec !== undefined && durationSec > 0 && nextPosition >= durationSec) {
nextPosition = durationSec;
if (current.reachedTrackEndAtMs === undefined) {
current.reachedTrackEndAtMs = now;
}
}
this.syntheticPlayback.lastSeenAtMs = now;
this.syntheticPlayback.lastPositionSec = nextPosition;
this.syntheticPlayback.durationSec = durationSec;
current.lastSeenAtMs = now;
current.lastPositionSec = nextPosition;
current.durationSec = durationSec;
this.syntheticPlaybackByPlayer.set(playerId, current);
return {
status: REPORTED_PLAYER_STATUSES.playing,
@ -192,14 +271,22 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
};
}
private shouldKeepAliveSynthetic(nowMs: number = Date.now()): boolean {
if (this.syntheticPlayback === undefined || this.lastPlay === undefined || this.lastBridgeData === undefined || this.lastBridgeSeenAtMs === undefined) {
private shouldKeepAliveSynthetic(playerId: string, nowMs: number = Date.now()): boolean {
const lastBridgeData = this.lastBridgeDataByPlayer.get(playerId);
if (lastBridgeData !== undefined && this.isStationLocal(lastBridgeData)) {
return false;
}
const silenceSec = Math.max(0, (nowMs - this.lastBridgeSeenAtMs) / 1000);
const durationSec = this.syntheticPlayback.durationSec ?? this.lastPlay.data.duration;
const currentPosSec = this.syntheticPlayback.lastPositionSec ?? this.lastPlay.meta.trackProgressPosition ?? 0;
const synthetic = this.syntheticPlaybackByPlayer.get(playerId);
const lastPlay = this.lastPlayByPlayer.get(playerId);
const lastSeen = this.lastBridgeSeenAtMsByPlayer.get(playerId);
if (synthetic === undefined || lastPlay === undefined || lastBridgeData === undefined || lastSeen === undefined) {
return false;
}
const silenceSec = Math.max(0, (nowMs - lastSeen) / 1000);
const durationSec = synthetic.durationSec ?? lastPlay.data.duration;
const currentPosSec = synthetic.lastPositionSec ?? lastPlay.meta.trackProgressPosition ?? 0;
let allowedSilenceSec = this.keepAliveMinSec;
if (durationSec !== undefined && durationSec > 0) {
@ -212,103 +299,138 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource {
return true;
}
this.logger.debug(`Dropping synthetic keepalive after ${silenceSec.toFixed(0)}s without bridge data (allowed ${allowedSilenceSec.toFixed(0)}s)`);
this.logger.debug(`Dropping synthetic keepalive for player ${playerId} after ${silenceSec.toFixed(0)}s without bridge data (allowed ${allowedSilenceSec.toFixed(0)}s)`);
return false;
}
private shouldFinalizeSyntheticTrack(nowMs: number = Date.now()): boolean {
if (this.syntheticPlayback === undefined || this.lastPlay === undefined) {
private shouldFinalizeSyntheticTrack(playerId: string, nowMs: number = Date.now()): boolean {
const synthetic = this.syntheticPlaybackByPlayer.get(playerId);
const lastPlay = this.lastPlayByPlayer.get(playerId);
if (synthetic === undefined || lastPlay === undefined) {
return false;
}
const durationSec = this.syntheticPlayback.durationSec ?? this.lastPlay.data.duration;
const durationSec = synthetic.durationSec ?? lastPlay.data.duration;
if (durationSec === undefined || durationSec <= 0) {
return false;
}
if (this.syntheticPlayback.lastPositionSec < durationSec) {
if (synthetic.lastPositionSec < durationSec) {
return false;
}
if (this.syntheticPlayback.reachedTrackEndAtMs === undefined) {
this.syntheticPlayback.reachedTrackEndAtMs = nowMs;
if (synthetic.reachedTrackEndAtMs === undefined) {
synthetic.reachedTrackEndAtMs = nowMs;
this.syntheticPlaybackByPlayer.set(playerId, synthetic);
return false;
}
const sinceEndSec = Math.max(0, (nowMs - this.syntheticPlayback.reachedTrackEndAtMs) / 1000);
const sinceEndSec = Math.max(0, (nowMs - synthetic.reachedTrackEndAtMs) / 1000);
return sinceEndSec >= this.postEndStopGraceSec;
}
private buildStoppedState(): PlayerStateDataMaybePlay | undefined {
if (this.lastBridgeData === undefined) {
private buildStoppedState(playerId: string): PlayerStateDataMaybePlay | undefined {
const bridgeData = this.lastBridgeDataByPlayer.get(playerId);
if (bridgeData === undefined) {
return undefined;
}
return {
platformId: [NO_DEVICE, NO_USER],
sessionId: this.lastBridgeData.queue_id ?? this.lastBridgeData.track_id,
platformId: [playerId, NO_USER],
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
status: REPORTED_PLAYER_STATUSES.stopped,
};
}
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
const payload = await this.callBridge();
const bridgeData = payload.data;
const rawData = payload.data;
const players = Array.isArray(rawData)
? rawData.filter((x): x is BridgeTrackData => x !== null && x !== undefined && typeof x === 'object')
: (rawData !== undefined && rawData !== null && typeof rawData === 'object' ? [rawData as BridgeTrackData] : []);
const nowMs = Date.now();
const seenPlayerIds = new Set<string>();
const states: PlayerStateDataMaybePlay[] = [];
if (payload.ok && bridgeData !== undefined && bridgeData !== null && bridgeData.title) {
const play = formatPlayObj(bridgeData);
const playbackState = this.getPlaybackState(bridgeData, play);
this.lastBridgeData = bridgeData;
this.lastPlay = play;
this.lastBridgeSeenAtMs = nowMs;
for (const rawBridgeData of players) {
const bridgeData = normalizeBridgeData(rawBridgeData);
if (!payload.ok || bridgeData.title === undefined || bridgeData.title === null || `${bridgeData.title}`.trim() === '') {
continue;
}
const playerId = this.getPlayerId(bridgeData);
seenPlayerIds.add(playerId);
const play = formatPlayObj(bridgeData, playerId);
this.lastBridgeDataByPlayer.set(playerId, bridgeData);
this.lastPlayByPlayer.set(playerId, play);
this.lastBridgeSeenAtMsByPlayer.set(playerId, nowMs);
if (this.shouldFinalizeSyntheticTrack(nowMs)) {
const durationSec = this.syntheticPlayback?.durationSec ?? play.data.duration ?? 0;
this.logger.info(`Synthetic playback exceeded track duration for '${play.data.artists?.join(', ') ?? 'Unknown'} - ${play.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
const stoppedState = this.buildStoppedState();
this.resetSyntheticPlayback();
return await this.processRecentPlays(stoppedState !== undefined ? [stoppedState] : []);
if (this.isStationLocal(bridgeData) && bridgeData.paused === true) {
this.clearSyntheticPlayback(playerId);
states.push({
platformId: [playerId, NO_USER],
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
status: REPORTED_PLAYER_STATUSES.paused,
play,
position: getSafePositionSec(bridgeData) ?? play.meta.trackProgressPosition ?? 0,
});
continue;
}
const playerState: PlayerStateData = {
platformId: [NO_DEVICE, NO_USER],
sessionId: bridgeData.queue_id ?? bridgeData.track_id,
const playbackState = this.getPlaybackState(playerId, bridgeData, play);
if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) {
const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? play.data.duration ?? 0;
this.logger.info(`Synthetic playback exceeded track duration for player ${playerId} '${play.data.artists?.join(', ') ?? 'Unknown'} - ${play.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
const stoppedState = this.buildStoppedState(playerId);
this.removePlayerState(playerId);
if (stoppedState !== undefined) {
states.push(stoppedState);
}
continue;
}
states.push({
platformId: [playerId, NO_USER],
sessionId: bridgeData.queue_id ?? bridgeData.track_id ?? playerId,
status: playbackState.status,
play,
position: playbackState.position,
};
return await this.processRecentPlays([playerState]);
});
}
if (this.shouldKeepAliveSynthetic(nowMs)) {
const bridgeDataForKeepAlive = this.lastBridgeData!;
const playForKeepAlive = this.lastPlay!;
const playbackState = this.getPlaybackState(bridgeDataForKeepAlive, playForKeepAlive, { keepAlive: true });
for (const [playerId, lastPlay] of Array.from(this.lastPlayByPlayer.entries())) {
if (seenPlayerIds.has(playerId)) {
continue;
}
if (this.shouldKeepAliveSynthetic(playerId, nowMs)) {
const bridgeDataForKeepAlive = this.lastBridgeDataByPlayer.get(playerId)!;
const playbackState = this.getPlaybackState(playerId, bridgeDataForKeepAlive, lastPlay);
if (this.shouldFinalizeSyntheticTrack(nowMs)) {
const durationSec = this.syntheticPlayback?.durationSec ?? playForKeepAlive.data.duration ?? 0;
this.logger.info(`Synthetic keepalive exceeded track duration for '${playForKeepAlive.data.artists?.join(', ') ?? 'Unknown'} - ${playForKeepAlive.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
const stoppedState = this.buildStoppedState();
this.resetSyntheticPlayback();
return await this.processRecentPlays(stoppedState !== undefined ? [stoppedState] : []);
if (this.shouldFinalizeSyntheticTrack(playerId, nowMs)) {
const durationSec = this.syntheticPlaybackByPlayer.get(playerId)?.durationSec ?? lastPlay.data.duration ?? 0;
this.logger.info(`Synthetic keepalive exceeded track duration for player ${playerId} '${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}'; emitting STOP after ${this.postEndStopGraceSec}s past track end at ${durationSec.toFixed(0)}s.`);
const stoppedState = this.buildStoppedState(playerId);
this.removePlayerState(playerId);
if (stoppedState !== undefined) {
states.push(stoppedState);
}
continue;
}
this.logger.trace(`Bridge returned no current track for player ${playerId}; keeping synthetic playback alive for ${lastPlay.data.artists?.join(', ') ?? 'Unknown'} - ${lastPlay.data.track ?? 'Unknown'}`);
states.push({
platformId: [playerId, NO_USER],
sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id ?? playerId,
status: REPORTED_PLAYER_STATUSES.playing,
play: lastPlay,
position: playbackState.position,
});
continue;
}
this.logger.trace(`Bridge returned no current track; keeping synthetic playback alive for ${playForKeepAlive.data.artists?.join(', ') ?? 'Unknown'} - ${playForKeepAlive.data.track ?? 'Unknown'}`);
const playerState: PlayerStateData = {
platformId: [NO_DEVICE, NO_USER],
sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id,
status: REPORTED_PLAYER_STATUSES.playing,
play: playForKeepAlive,
position: playbackState.position,
};
return await this.processRecentPlays([playerState]);
this.removePlayerState(playerId);
}
this.resetSyntheticPlayback();
return await this.processRecentPlays([]);
return await this.processRecentPlays(states);
}
}
const formatPlayObj = (obj: BridgeTrackData): PlayObject => {
const formatPlayObj = (obj: BridgeTrackData, playerId: string): PlayObject => {
const artists = Array.isArray(obj.artists_list) && obj.artists_list.length > 0
? obj.artists_list
: (obj.artists ? obj.artists.split(/\s*,\s*/).filter(x => x.trim() !== '') : []);
@ -323,10 +445,10 @@ const formatPlayObj = (obj: BridgeTrackData): PlayObject => {
: undefined,
},
meta: {
trackProgressPosition: obj.progress_ms !== undefined && obj.progress_ms !== null ? obj.progress_ms / 1000 : undefined,
deviceId: NO_DEVICE,
mediaPlayerName: 'Yandex Music',
mediaPlayerVersion: 'bridge',
trackProgressPosition: getSafePositionSec(obj),
deviceId: playerId || NO_DEVICE,
mediaPlayerName: obj.device_name ?? (obj.source === 'station-local' ? 'Yandex Station' : 'Yandex Music'),
mediaPlayerVersion: obj.platform ?? (obj.source === 'station-local' ? 'station-local' : 'bridge'),
comment: obj.track_id !== undefined ? `Yandex Track ${obj.track_id}` : undefined,
art: obj.cover !== undefined && obj.cover !== null && obj.cover !== '' ? { album: obj.cover } : undefined,
}