From 5f4a5cb815b2aa74a9c373f9fea0dd9030718dbf Mon Sep 17 00:00:00 2001 From: Druidblack <70659424+Druidblack@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:35:00 +0300 Subject: [PATCH] add yandex music --- .../infrastructure/config/source/sources.ts | 7 + .../infrastructure/config/source/ymbridge.ts | 17 ++ src/backend/sources/ScrobbleSources.ts | 7 + .../sources/YandexMusicBridgeSource.ts | 271 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 src/backend/common/infrastructure/config/source/ymbridge.ts create mode 100644 src/backend/sources/YandexMusicBridgeSource.ts diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 522f5ab4..b0dd6218 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -19,6 +19,7 @@ import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js"; import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js"; import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js"; import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js"; +import { YandexMusicBridgeSourceAIOConfig, YandexMusicBridgeSourceConfig } from "./ymbridge.js"; import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js"; import { KoitoSourceAIOConfig, KoitoSourceConfig } from "./koito.js"; import { MalojaSourceAIOConfig, MalojaSourceConfig } from "./maloja.js"; @@ -40,6 +41,7 @@ export type SourceConfig = | LastfmSourceConfig | LibrefmSourceConfig | YTMusicSourceConfig + | YandexMusicBridgeSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig @@ -71,6 +73,7 @@ export type SourceAIOConfig = | LastFmSouceAIOConfig | LibrefmSouceAIOConfig | YTMusicSourceAIOConfig + | YandexMusicBridgeSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig @@ -108,6 +111,7 @@ export type JellyApiSourceConfigs = JellyApiSourceConfig[]; export type LastfmSourceConfigs = LastfmSourceConfig[]; export type LibrefmSourceConfigs = LibrefmSourceConfig[]; export type YTMusicSourceConfigs = YTMusicSourceConfig[]; +export type YandexMusicBridgeSourceConfigs = YandexMusicBridgeSourceConfig[]; export type MPRISSourceConfigs = MPRISSourceConfig[]; export type MopidySourceConfigs = MopidySourceConfig[]; export type ListenBrainzSourceConfigs = ListenBrainzSourceConfig[]; @@ -139,6 +143,7 @@ export type SourceType = | 'endpointlz' | 'endpointlfm' | 'ytmusic' + | 'ymbridge' | 'mpris' | 'mopidy' | 'musiccast' @@ -169,6 +174,7 @@ export const sourceTypes: SourceType[] = [ 'endpointlz', 'endpointlfm', 'ytmusic', + 'ymbridge', 'mpris', 'mopidy', 'musiccast', @@ -201,6 +207,7 @@ export const atomicSourceInterfaces = [ 'LastfmSourceConfig', 'LibrefmSourceConfig', 'YTMusicSourceConfig', + 'YandexMusicBridgeSourceConfig', 'MalojaSourceConfig', 'MPRISSourceConfig', 'MopidySourceConfig', diff --git a/src/backend/common/infrastructure/config/source/ymbridge.ts b/src/backend/common/infrastructure/config/source/ymbridge.ts new file mode 100644 index 00000000..a5d3e733 --- /dev/null +++ b/src/backend/common/infrastructure/config/source/ymbridge.ts @@ -0,0 +1,17 @@ +import { PollingOptions } from "../common.js"; +import { CommonSourceConfig, CommonSourceData } from "./index.js"; + +export interface YandexMusicBridgeData extends CommonSourceData, PollingOptions { + /** URL of the local Python bridge, for example http://yandex-music-bridge:9980 */ + url: string + /** Optional API key sent as X-API-Key to the bridge */ + apiKey?: string +} + +export interface YandexMusicBridgeSourceConfig extends CommonSourceConfig { + data?: YandexMusicBridgeData +} + +export interface YandexMusicBridgeSourceAIOConfig extends YandexMusicBridgeSourceConfig { + type: 'ymbridge' +} diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 7b013b19..2b956224 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -33,6 +33,7 @@ import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/con import { VLCData, VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js"; import { YTMusicData, YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; +import { YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/source/ymbridge.js"; import { SonosData, SonosSourceConfig } from "../common/infrastructure/config/source/sonos.js"; import { WildcardEmitter } from "../common/WildcardEmitter.js"; import { parseBool } from "../utils.js"; @@ -128,6 +129,8 @@ export default class ScrobbleSources { return "LibrefmSourceConfig"; case 'ytmusic': return "YTMusicSourceConfig"; + case 'ymbridge': + return "YandexMusicBridgeSourceConfig"; case 'maloja': return "MalojaSourceConfig"; case 'mpris': @@ -944,6 +947,10 @@ export default class ScrobbleSources { const YTMusicSource = (await import('./YTMusicSource.js')).default; newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, this.internalConfig, this.emitter); break; + case 'ymbridge': + const YandexMusicBridgeSource = (await import('./YandexMusicBridgeSource.js')).default; + newSource = await new YandexMusicBridgeSource(name, compositeConfig as YandexMusicBridgeSourceConfig, this.internalConfig, this.emitter); + break; case 'mpris': const {MPRISSource} = (await import('./MPRISSource.js')); newSource = await new MPRISSource(name, compositeConfig as MPRISSourceConfig, this.internalConfig, this.emitter); diff --git a/src/backend/sources/YandexMusicBridgeSource.ts b/src/backend/sources/YandexMusicBridgeSource.ts new file mode 100644 index 00000000..11388490 --- /dev/null +++ b/src/backend/sources/YandexMusicBridgeSource.ts @@ -0,0 +1,271 @@ +import { EventEmitter } from "events"; +import request from 'superagent'; +import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; +import { + InternalConfig, + PlayerStateData, + REPORTED_PLAYER_STATUSES, +} from "../common/infrastructure/Atomic.js"; +import { YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/source/ymbridge.js"; +import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; +import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; + +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 + source?: string + timestamp?: number +} + +interface BridgeNowPlayingResponse { + ok: boolean + source?: string + fresh?: boolean + data?: BridgeTrackData | null +} + +interface SyntheticPlaybackState { + key: string + lastSeenAtMs: number + lastPositionSec: number + durationSec?: number +} + +export default class YandexMusicBridgeSource extends MemoryPositionalSource { + + declare config: YandexMusicBridgeSourceConfig; + urlData!: URLData; + private syntheticPlayback?: SyntheticPlaybackState; + private lastBridgeData?: BridgeTrackData; + private lastPlay?: PlayObject; + private lastBridgeSeenAtMs?: number; + private keepAliveMinSec = 90; + private keepAlivePaddingSec = 120; + private keepAliveHardCapSec = 600; + + constructor(name: string, config: YandexMusicBridgeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('ymbridge', name, config, internal, emitter); + this.requiresAuth = false; + this.canPoll = 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 Error('Bridge health endpoint did not return JSON'); + } catch (e: any) { + const hint = e?.response?.text ?? e?.message ?? undefined; + throw new Error(`Could not connect to Yandex Music bridge${hint !== undefined ? ` (${hint})` : ''}`, { cause: e }); + } + } + + private async callBridge(): Promise { + const bridgeUrl = joinedUrl(this.urlData.url, 'now-playing').toString(); + const req = request.get(bridgeUrl).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') { + throw new Error('Bridge returned no JSON payload'); + } + return resp.body as BridgeNowPlayingResponse; + } + + private resetSyntheticPlayback() { + this.syntheticPlayback = undefined; + this.lastBridgeData = undefined; + this.lastPlay = undefined; + this.lastBridgeSeenAtMs = undefined; + } + + 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 [ + bridgeData.queue_id ?? '', + bridgeData.track_id ?? '', + bridgeData.title ?? play.data.track ?? '', + artists, + bridgeData.album ?? play.data.album ?? '', + ].join('::'); + } + + private getPlaybackState( + bridgeData: BridgeTrackData, + play: PlayObject, + options: { keepAlive?: boolean } = {}, + ): { status: typeof REPORTED_PLAYER_STATUSES.playing, position: number } { + const { keepAlive = false } = options; + 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) { + const initialPosition = reportedPositionSec ?? play.meta.trackProgressPosition ?? 0; + this.syntheticPlayback = { + key, + lastSeenAtMs: now, + lastPositionSec: initialPosition, + durationSec, + }; + return { + status: REPORTED_PLAYER_STATUSES.playing, + position: initialPosition, + }; + } + + const elapsedSec = Math.max(0, (now - this.syntheticPlayback.lastSeenAtMs) / 1000); + const startingPoint = reportedPositionSec !== undefined + ? Math.max(reportedPositionSec, this.syntheticPlayback.lastPositionSec) + : this.syntheticPlayback.lastPositionSec; + + let nextPosition = startingPoint + elapsedSec; + + if (durationSec !== undefined && durationSec > 0) { + const overrun = keepAlive ? this.keepAlivePaddingSec : 15; + nextPosition = Math.min(nextPosition, durationSec + overrun); + } + + this.syntheticPlayback.lastSeenAtMs = now; + this.syntheticPlayback.lastPositionSec = nextPosition; + this.syntheticPlayback.durationSec = durationSec; + + return { + status: REPORTED_PLAYER_STATUSES.playing, + position: nextPosition, + }; + } + + private shouldKeepAliveSynthetic(nowMs: number = Date.now()): boolean { + if (this.syntheticPlayback === undefined || this.lastPlay === undefined || this.lastBridgeData === undefined || this.lastBridgeSeenAtMs === undefined) { + 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; + + 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 after ${silenceSec.toFixed(0)}s without bridge data (allowed ${allowedSilenceSec.toFixed(0)}s)`); + return false; + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + const payload = await this.callBridge(); + const bridgeData = payload.data; + 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 = Date.now(); + + const playerState: PlayerStateData = { + platformId: [bridgeData.queue_id ?? 'YandexMusicBridge', 'SingleUser'], + sessionId: bridgeData.queue_id ?? bridgeData.track_id, + status: playbackState.status, + play, + position: playbackState.position, + }; + + return await this.processRecentPlays([playerState]); + } + + if (this.shouldKeepAliveSynthetic()) { + const bridgeDataForKeepAlive = this.lastBridgeData!; + const playForKeepAlive = this.lastPlay!; + const playbackState = this.getPlaybackState(bridgeDataForKeepAlive, playForKeepAlive, { keepAlive: true }); + + 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: [bridgeDataForKeepAlive.queue_id ?? 'YandexMusicBridge', 'SingleUser'], + sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id, + status: REPORTED_PLAYER_STATUSES.playing, + play: playForKeepAlive, + position: playbackState.position, + }; + + return await this.processRecentPlays([playerState]); + } + + this.resetSyntheticPlayback(); + return await this.processRecentPlays([]); + } +} + +const formatPlayObj = (obj: BridgeTrackData): 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: PlayObjectLifecycleless = { + data: { + 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: obj.progress_ms !== undefined && obj.progress_ms !== null ? obj.progress_ms / 1000 : undefined, + deviceId: obj.queue_id ?? 'YandexMusicBridge', + mediaPlayerName: 'Yandex Music', + mediaPlayerVersion: '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); +} -- 2.51.2