import type { EventEmitter } from "events"; import request from 'superagent'; import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; import type {PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts"; import { type InternalConfig, type PlayerStateDataMaybePlay, } from "../common/infrastructure/Atomic.ts"; import { NO_USER } from '../../core/Atomic.ts'; import { NO_DEVICE } from '../../core/Atomic.ts'; import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts'; import type {YandexMusicBridgeSourceConfig} from "../common/infrastructure/config/source/ymbridge.ts"; import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.ts"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts"; import { UpstreamError } from "../common/errors/UpstreamError.ts"; import { artistNamesToCredits } from "../../core/StringUtils.ts"; interface BridgeTrackData { title?: string artists?: string artists_list?: string[] album?: string track_id?: string cover?: string duration_ms?: number progress_ms?: number paused?: boolean explicit?: boolean context_type?: string queue_id?: string player_id?: string device_id?: string device_name?: string platform?: string source?: string timestamp?: number } interface BridgePlayersResponse { ok: boolean source?: string fresh?: boolean players_count?: number primary?: BridgeTrackData | null data?: BridgeTrackData[] | BridgeTrackData | null } interface SyntheticPlaybackState { key: string lastSeenAtMs: number lastPositionSec: number durationSec?: number 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 syntheticPlaybackByPlayer = new Map(); private lastBridgeDataByPlayer = new Map(); private lastPlayByPlayer = new Map(); private lastBridgeSeenAtMsByPlayer = new Map(); private keepAliveMinSec = 90; private keepAlivePaddingSec = 120; private keepAliveHardCapSec = 600; private postEndStopGraceSec = 20; constructor(name: string, config: YandexMusicBridgeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('ymbridge', name, config, internal, emitter); this.requiresAuth = false; this.canPoll = true; this.multiPlatform = true; } protected async doBuildInitData(): Promise { const { data: { url } = {} } = this.config; if (url === null || url === undefined || url === '') { throw new Error('data.url must be defined'); } this.urlData = normalizeWebAddress(url, { defaultPath: '/' }); this.logger.verbose(`Config URL: '${url}' => Normalized: '${this.urlData.normal}'`); return true; } protected async doCheckConnection(): Promise { try { await isPortReachableConnect(this.urlData.port, { host: this.urlData.url.hostname }); const healthUrl = joinedUrl(this.urlData.url, 'health').toString(); const req = request.get(healthUrl).timeout({ response: 5000, deadline: 10000 }); const apiKey = this.config.data?.apiKey; if (apiKey !== undefined && apiKey.trim() !== '') { req.set('X-API-Key', apiKey); } const resp = await req; if (resp.body !== undefined && typeof resp.body === 'object') { 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 }); } 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 { const apiKey = this.config.data?.apiKey; 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 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 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 { const artists = Array.isArray(bridgeData.artists_list) && bridgeData.artists_list.length > 0 ? bridgeData.artists_list.join(',') : (bridgeData.artists ?? play.data.artists?.join(',') ?? ''); return [ this.getPlayerId(bridgeData), bridgeData.track_id ?? '', bridgeData.title ?? play.data.track ?? '', artists, bridgeData.album ?? play.data.album ?? '', ].join('::'); } 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; 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.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 - current.lastSeenAtMs) / 1000); const startingPoint = reportedPositionSec !== undefined ? Math.max(reportedPositionSec, current.lastPositionSec) : current.lastPositionSec; let nextPosition = startingPoint + elapsedSec; if (durationSec !== undefined && durationSec > 0 && nextPosition >= durationSec) { nextPosition = durationSec; if (current.reachedTrackEndAtMs === undefined) { current.reachedTrackEndAtMs = now; } } current.lastSeenAtMs = now; current.lastPositionSec = nextPosition; current.durationSec = durationSec; this.syntheticPlaybackByPlayer.set(playerId, current); return { status: REPORTED_PLAYER_STATUSES.playing, position: nextPosition, }; } private shouldKeepAliveSynthetic(playerId: string, nowMs: number = Date.now()): boolean { const lastBridgeData = this.lastBridgeDataByPlayer.get(playerId); if (lastBridgeData !== undefined && this.isStationLocal(lastBridgeData)) { return false; } 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) { const remainingSec = Math.max(0, durationSec - currentPosSec); allowedSilenceSec = Math.max(this.keepAliveMinSec, remainingSec + this.keepAlivePaddingSec); } allowedSilenceSec = Math.min(allowedSilenceSec, this.keepAliveHardCapSec); if (silenceSec <= allowedSilenceSec) { return true; } 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(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 = synthetic.durationSec ?? lastPlay.data.duration; if (durationSec === undefined || durationSec <= 0) { return false; } if (synthetic.lastPositionSec < durationSec) { return false; } if (synthetic.reachedTrackEndAtMs === undefined) { synthetic.reachedTrackEndAtMs = nowMs; this.syntheticPlaybackByPlayer.set(playerId, synthetic); return false; } const sinceEndSec = Math.max(0, (nowMs - synthetic.reachedTrackEndAtMs) / 1000); return sinceEndSec >= this.postEndStopGraceSec; } private buildStoppedState(playerId: string): PlayerStateDataMaybePlay | undefined { const bridgeData = this.lastBridgeDataByPlayer.get(playerId); if (bridgeData === undefined) { return undefined; } return { 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 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(); const states: PlayerStateDataMaybePlay[] = []; 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.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 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, }); } 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(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.removePlayerState(playerId); } return await this.processRecentPlays(states); } } 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() !== '') : []); const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(artists), album: obj.album ?? undefined, track: obj.title ?? undefined, duration: obj.duration_ms !== undefined && obj.duration_ms !== null ? obj.duration_ms / 1000 : undefined, }, meta: { 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, } } return baseFormatPlayObj(obj, play); }