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 From ee753389f729307a3f6ade103a168924fdad401c Mon Sep 17 00:00:00 2001 From: Druidblack <70659424+Druidblack@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:36:13 +0300 Subject: [PATCH 2/9] Add files via upload --- config/ymbridge.json.example | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 config/ymbridge.json.example diff --git a/config/ymbridge.json.example b/config/ymbridge.json.example new file mode 100644 index 00000000..9a52c64c --- /dev/null +++ b/config/ymbridge.json.example @@ -0,0 +1,18 @@ +[ + { + "name": "Yandex Music", + "type": "ymbridge", + "data": { + "url": "http://192.168.1.161:9980", + "apiKey": "change-me", + "interval": 5, + "maxInterval": 20, + }, + "options": { + "scrobbleThresholds": { + "percent": 50, + "duration": 240 + } + } + } +] -- 2.51.2 From effe87010473fcfd9bfa95660b2bebb2b0d07bd7 Mon Sep 17 00:00:00 2001 From: Druidblack <70659424+Druidblack@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:56:18 +0300 Subject: [PATCH 3/9] fix time track fixed the calculation of the track playback if it has ended and the new ones have not started. --- .../sources/YandexMusicBridgeSource.ts | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/src/backend/sources/YandexMusicBridgeSource.ts b/src/backend/sources/YandexMusicBridgeSource.ts index 11388490..d71cf959 100644 --- a/src/backend/sources/YandexMusicBridgeSource.ts +++ b/src/backend/sources/YandexMusicBridgeSource.ts @@ -6,6 +6,7 @@ import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic. import { InternalConfig, PlayerStateData, + PlayerStateDataMaybePlay, REPORTED_PLAYER_STATUSES, } from "../common/infrastructure/Atomic.js"; import { YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/source/ymbridge.js"; @@ -41,6 +42,7 @@ interface SyntheticPlaybackState { lastSeenAtMs: number lastPositionSec: number durationSec?: number + reachedTrackEndAtMs?: number } export default class YandexMusicBridgeSource extends MemoryPositionalSource { @@ -54,6 +56,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { 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); @@ -146,10 +149,11 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { lastSeenAtMs: now, lastPositionSec: initialPosition, durationSec, + reachedTrackEndAtMs: durationSec !== undefined && durationSec > 0 && initialPosition >= durationSec ? now : undefined, }; return { status: REPORTED_PLAYER_STATUSES.playing, - position: initialPosition, + position: durationSec !== undefined && durationSec > 0 ? Math.min(initialPosition, durationSec) : initialPosition, }; } @@ -161,8 +165,14 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { let nextPosition = startingPoint + elapsedSec; if (durationSec !== undefined && durationSec > 0) { - const overrun = keepAlive ? this.keepAlivePaddingSec : 15; - nextPosition = Math.min(nextPosition, durationSec + overrun); + // 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; + } + } } this.syntheticPlayback.lastSeenAtMs = now; @@ -199,15 +209,55 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { return false; } + private shouldFinalizeSyntheticTrack(nowMs: number = Date.now()): boolean { + if (this.syntheticPlayback === undefined || this.lastPlay === undefined) { + return false; + } + const durationSec = this.syntheticPlayback.durationSec ?? this.lastPlay.data.duration; + if (durationSec === undefined || durationSec <= 0) { + return false; + } + if (this.syntheticPlayback.lastPositionSec < durationSec) { + return false; + } + if (this.syntheticPlayback.reachedTrackEndAtMs === undefined) { + this.syntheticPlayback.reachedTrackEndAtMs = nowMs; + return false; + } + const sinceEndSec = Math.max(0, (nowMs - this.syntheticPlayback.reachedTrackEndAtMs) / 1000); + return sinceEndSec >= this.postEndStopGraceSec; + } + + private buildStoppedState(): PlayerStateDataMaybePlay | undefined { + if (this.lastBridgeData === undefined) { + return undefined; + } + return { + platformId: [this.lastBridgeData.queue_id ?? 'YandexMusicBridge', 'SingleUser'], + sessionId: this.lastBridgeData.queue_id ?? this.lastBridgeData.track_id, + status: REPORTED_PLAYER_STATUSES.stopped, + }; + } + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { const payload = await this.callBridge(); const bridgeData = payload.data; + const nowMs = Date.now(); + 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(); + this.lastBridgeSeenAtMs = 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] : []); + } const playerState: PlayerStateData = { platformId: [bridgeData.queue_id ?? 'YandexMusicBridge', 'SingleUser'], @@ -220,11 +270,19 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { return await this.processRecentPlays([playerState]); } - if (this.shouldKeepAliveSynthetic()) { + if (this.shouldKeepAliveSynthetic(nowMs)) { const bridgeDataForKeepAlive = this.lastBridgeData!; const playForKeepAlive = this.lastPlay!; const playbackState = this.getPlaybackState(bridgeDataForKeepAlive, playForKeepAlive, { keepAlive: true }); + 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] : []); + } + 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 = { -- 2.51.2 From c39ef29597a428ca6b3f5c86157c75bbd354241a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 6 Apr 2026 13:48:49 +0000 Subject: [PATCH 4/9] fix: Add missing type to sourcestatusdata --- src/core/Atomic.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 816523cf..af4a7b6c 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -22,6 +22,7 @@ export interface SourceStatusData { | 'endpointlz' | 'endpointlfm' | 'ytmusic' + | 'ymbridge' | 'mpris' | 'mopidy' | 'musiccast' -- 2.51.2 From f16750609a40b79e53546399564052a53713dd61 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 6 Apr 2026 13:58:43 +0000 Subject: [PATCH 5/9] feat(yandex): Handle bridge errors as upstream with some response context --- .../sources/YandexMusicBridgeSource.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/backend/sources/YandexMusicBridgeSource.ts b/src/backend/sources/YandexMusicBridgeSource.ts index d71cf959..fa4a586a 100644 --- a/src/backend/sources/YandexMusicBridgeSource.ts +++ b/src/backend/sources/YandexMusicBridgeSource.ts @@ -5,6 +5,7 @@ import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; import { InternalConfig, + NO_USER, PlayerStateData, PlayerStateDataMaybePlay, REPORTED_PLAYER_STATUSES, @@ -12,6 +13,7 @@ import { import { YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/source/ymbridge.js"; import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; +import { UpstreamError } from "../common/errors/UpstreamError.js"; interface BridgeTrackData { title?: string @@ -88,10 +90,10 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { 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'); + 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 Error(`Could not connect to Yandex Music bridge${hint !== undefined ? ` (${hint})` : ''}`, { cause: e }); + throw new UpstreamError(`Could not connect to Yandex Music bridge${hint !== undefined ? ` (${hint})` : ''}`, { cause: e, responseBody: e?.response?.text, showStopper: true }); } } @@ -102,11 +104,15 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { 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'); + 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}); + } + 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 resp.body as BridgeNowPlayingResponse; } private resetSyntheticPlayback() { @@ -233,7 +239,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { return undefined; } return { - platformId: [this.lastBridgeData.queue_id ?? 'YandexMusicBridge', 'SingleUser'], + platformId: [this.lastBridgeData.queue_id ?? 'YandexMusicBridge', NO_USER], sessionId: this.lastBridgeData.queue_id ?? this.lastBridgeData.track_id, status: REPORTED_PLAYER_STATUSES.stopped, }; -- 2.51.2 From 0bef197521e783a8c3755d51af244e1eb5a3a72d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 6 Apr 2026 14:06:05 +0000 Subject: [PATCH 6/9] feat(yandex): Add env source config --- src/backend/sources/ScrobbleSources.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 2b956224..3712d3ce 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -33,7 +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 { YandexMusicBridgeData, 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"; @@ -793,7 +793,24 @@ export default class ScrobbleSources { options: transformPresetEnv('SONOS') }); } - } break; + } break; + case 'ymbridge': { + const yandex: YandexMusicBridgeData = { + url: process.env.YMBRIDGE_URL, + apiKey: process.env.YMBRIDGE_API_KEY, + } + if (!Object.values(yandex).every(x => x === undefined)) { + configs.push({ + type: 'ymbridge', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: yandex, + options: transformPresetEnv('YMBRIDGE') + }); + } + } break; default: break; } -- 2.51.2 From 7897dc95034534db3988df403a4d8bde3ede8f69 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 6 Apr 2026 15:04:56 +0000 Subject: [PATCH 7/9] docs: Add Yandex Music Source docs --- .../docs/configuration/sources/sources.mdx | 1 + .../configuration/sources/yandex-music.mdx | 79 +++++++++++++++++++ docsite/docs/index.mdx | 1 + 3 files changed, 81 insertions(+) create mode 100644 docsite/docs/configuration/sources/yandex-music.mdx diff --git a/docsite/docs/configuration/sources/sources.mdx b/docsite/docs/configuration/sources/sources.mdx index 8e85655e..80628b90 100644 --- a/docsite/docs/configuration/sources/sources.mdx +++ b/docsite/docs/configuration/sources/sources.mdx @@ -40,6 +40,7 @@ A **Source** is a data source that contains information about tracks you are pla | [WebScrobbler](/configuration/sources/webscrobbler) | [Ingress](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | | [VLC](/configuration/sources/vlc) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | | [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | ❌ | ✅ | ✅ | ❌ | +| [Yandex Music](/configuration/sources/yandex-music) | [Active](./?sourceComm=active#by-communication-method) | [Activity](./?sot=activity#by-data-source-of-truth) | | ✅ | ✅ | ❌ | | [Youtube Music](/configuration/sources/youtube-music) | [Active](./?sourceComm=active#by-communication-method) | [History](./?sot=history#by-data-source-of-truth) | ❌ | ✅ | ❌ | ❌ | ## Features diff --git a/docsite/docs/configuration/sources/yandex-music.mdx b/docsite/docs/configuration/sources/yandex-music.mdx new file mode 100644 index 00000000..b37d96ad --- /dev/null +++ b/docsite/docs/configuration/sources/yandex-music.mdx @@ -0,0 +1,79 @@ +--- +title: Yandex Music +toc_min_heading_level: 2 +toc_max_heading_level: 5 +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import CodeBlock from '@theme/CodeBlock'; +import JsonConfig from '!!raw-loader!@site/../config/ymbridge.json.example'; + +Monitor your [**Yandex Music**](https://music.yandex.com) listening activity in real-time. + +This Source requires a third party docker container to communicate with Yandex Music, [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge). + +
+ +Example Docker Compose with `yandex-music-bridge` + +```yaml title="~/msData/docker-compose.yml" +services: + multi-scrobbler: + image: foxxmd/multi-scrobbler + container_name: multi-scrobbler + environment: + - TZ=Etc/GMT + # ...your other sources/clients here + + # add for yandex music + // highlight-start + - YMBRIDGE_URL=http://yandex-music-bridge:9980 + - YMBRIDGE_API_KEY=change-me + // highlight-end + + volumes: + - "./config:/config" + ports: + - "9078:9078" + restart: unless-stopped + + // highlight-start + yandex-music-bridge: + image: ghcr.io/druidblack/yandex-music-bridge:latest + environment: + - TZ=Europe/Moscow + - YM_TOKEN=AgAAAAACO3_345345 + - YM_API_KEY=change-me + - YM_PORT=9980 + - YM_LANGUAGE=ru + - YM_ENABLE_YNISON=true + - YM_PUSH_TTL=45 + - YM_QUEUE_CACHE_TTL=15 + - YM_LOG_LEVEL=INFO + ports: + - "9980:9980" + restart: unless-stopped + // highlight-end +``` + +You will need to acquire your own token for `YM_TOKEN` used in [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge) using the instructions provided there. + +
+ + + +If your issue is specifically related to [`yandex-music-bridge`](https://github.com/Druidblack/yandex-music-bridge) (errors in the container, usage instructions, general Yandex setup like token etc...) please open an issue on **that repository** instead of Multi-Scrobbler's. + +If you have issues specifically with the MS Source please mention [**Druidblack**](https://github.com/Druidblack) (`@Druidblack`) when opening an [issue](https://github.com/FoxxMD/multi-scrobbler/issues/new/choose) or [discussion](https://github.com/FoxxMD/multi-scrobbler/discussions/new/choose). Yandex Music is [not available](https://en.wikipedia.org/wiki/Yandex_Music) in the country of the main Multi-Scrobbler developer (FoxxMD) so they can only help troubleshooting general MS issues related to Yandex Music. + + + +## Configuration + + + | Environmental Variable | Required? | Default | Description | + | :--------------------- | :-------- | ------- | :------------------------------------------------------------------------ | + | `YMBRIDGE_URL` | Yes | | `http://URL:PORT` for the `yandex-music-bridge` container | + | `YMBRIDGE_API_KEY` | No | | The same key used for `YM_API_KEY` on the `yandex-music-bridge` container | + \ No newline at end of file diff --git a/docsite/docs/index.mdx b/docsite/docs/index.mdx index 19006c42..a6d829ba 100644 --- a/docsite/docs/index.mdx +++ b/docsite/docs/index.mdx @@ -41,6 +41,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a * [WebScrobbler](/configuration/sources/webscrobbler) * [VLC](/configuration/sources/vlc) * [Yamaha MusicCast](/configuration/sources/yamaha-musiccast) + * [Yandex Music](/configuration/sources/yandex-music) * [Youtube Music](/configuration/sources/youtube-music) * Supports scrobbling to many [**Clients**](/configuration/clients) * [Discord](/configuration/clients/discord) (Now Playing) -- 2.51.2 From 5b16e8857e2cfe4a5bff7c17db31b0aa76d68dc4 Mon Sep 17 00:00:00 2001 From: Druidblack <70659424+Druidblack@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:57:36 +0300 Subject: [PATCH 8/9] Removed optional settings --- config/ymbridge.json.example | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/config/ymbridge.json.example b/config/ymbridge.json.example index 9a52c64c..72c02003 100644 --- a/config/ymbridge.json.example +++ b/config/ymbridge.json.example @@ -5,14 +5,7 @@ "data": { "url": "http://192.168.1.161:9980", "apiKey": "change-me", - "interval": 5, - "maxInterval": 20, - }, - "options": { - "scrobbleThresholds": { - "percent": 50, - "duration": 240 - } + "interval": 5 } } ] -- 2.51.2 From fd43ff86c8fdd4f7601e4cf2fad3349d5025cc59 Mon Sep 17 00:00:00 2001 From: Druidblack <70659424+Druidblack@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:36:21 +0300 Subject: [PATCH 9/9] added NO_DEVICE and NO_USER --- src/backend/sources/YandexMusicBridgeSource.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/backend/sources/YandexMusicBridgeSource.ts b/src/backend/sources/YandexMusicBridgeSource.ts index fa4a586a..db39a543 100644 --- a/src/backend/sources/YandexMusicBridgeSource.ts +++ b/src/backend/sources/YandexMusicBridgeSource.ts @@ -5,6 +5,7 @@ import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { PlayObject, PlayObjectLifecycleless, URLData } from "../../core/Atomic.js"; import { InternalConfig, + NO_DEVICE, NO_USER, PlayerStateData, PlayerStateDataMaybePlay, @@ -239,7 +240,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { return undefined; } return { - platformId: [this.lastBridgeData.queue_id ?? 'YandexMusicBridge', NO_USER], + platformId: [NO_DEVICE, NO_USER], sessionId: this.lastBridgeData.queue_id ?? this.lastBridgeData.track_id, status: REPORTED_PLAYER_STATUSES.stopped, }; @@ -266,7 +267,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { } const playerState: PlayerStateData = { - platformId: [bridgeData.queue_id ?? 'YandexMusicBridge', 'SingleUser'], + platformId: [NO_DEVICE, NO_USER], sessionId: bridgeData.queue_id ?? bridgeData.track_id, status: playbackState.status, play, @@ -292,7 +293,7 @@ export default class YandexMusicBridgeSource extends MemoryPositionalSource { 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'], + platformId: [NO_DEVICE, NO_USER], sessionId: bridgeDataForKeepAlive.queue_id ?? bridgeDataForKeepAlive.track_id, status: REPORTED_PLAYER_STATUSES.playing, play: playForKeepAlive, @@ -323,7 +324,7 @@ const formatPlayObj = (obj: BridgeTrackData): PlayObject => { }, meta: { trackProgressPosition: obj.progress_ms !== undefined && obj.progress_ms !== null ? obj.progress_ms / 1000 : undefined, - deviceId: obj.queue_id ?? 'YandexMusicBridge', + deviceId: NO_DEVICE, mediaPlayerName: 'Yandex Music', mediaPlayerVersion: 'bridge', comment: obj.track_id !== undefined ? `Yandex Track ${obj.track_id}` : undefined,