[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

fix time track

fixed the calculation of the track playback if it has ended and the new ones have not started.

authored by

Druidblack and committed by
GitHub
(Apr 5, 2026, 8:56 PM +0300) effe8701 ee753389

+63 -5
+63 -5
src/backend/sources/YandexMusicBridgeSource.ts
··· 6 6 import { 7 7 InternalConfig, 8 8 PlayerStateData, 9 + PlayerStateDataMaybePlay, 9 10 REPORTED_PLAYER_STATUSES, 10 11 } from "../common/infrastructure/Atomic.js"; 11 12 import { YandexMusicBridgeSourceConfig } from "../common/infrastructure/config/source/ymbridge.js"; ··· 41 42 lastSeenAtMs: number 42 43 lastPositionSec: number 43 44 durationSec?: number 45 + reachedTrackEndAtMs?: number 44 46 } 45 47 46 48 export default class YandexMusicBridgeSource extends MemoryPositionalSource { ··· 54 56 private keepAliveMinSec = 90; 55 57 private keepAlivePaddingSec = 120; 56 58 private keepAliveHardCapSec = 600; 59 + private postEndStopGraceSec = 20; 57 60 58 61 constructor(name: string, config: YandexMusicBridgeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 59 62 super('ymbridge', name, config, internal, emitter); ··· 146 149 lastSeenAtMs: now, 147 150 lastPositionSec: initialPosition, 148 151 durationSec, 152 + reachedTrackEndAtMs: durationSec !== undefined && durationSec > 0 && initialPosition >= durationSec ? now : undefined, 149 153 }; 150 154 return { 151 155 status: REPORTED_PLAYER_STATUSES.playing, 152 - position: initialPosition, 156 + position: durationSec !== undefined && durationSec > 0 ? Math.min(initialPosition, durationSec) : initialPosition, 153 157 }; 154 158 } 155 159 ··· 161 165 let nextPosition = startingPoint + elapsedSec; 162 166 163 167 if (durationSec !== undefined && durationSec > 0) { 164 - const overrun = keepAlive ? this.keepAlivePaddingSec : 15; 165 - nextPosition = Math.min(nextPosition, durationSec + overrun); 168 + // Never let synthetic time run past track duration. Once it reaches the end 169 + // we will keep the player alive briefly and then emit a synthetic STOP. 170 + if (nextPosition >= durationSec) { 171 + nextPosition = durationSec; 172 + if (this.syntheticPlayback.reachedTrackEndAtMs === undefined) { 173 + this.syntheticPlayback.reachedTrackEndAtMs = now; 174 + } 175 + } 166 176 } 167 177 168 178 this.syntheticPlayback.lastSeenAtMs = now; ··· 199 209 return false; 200 210 } 201 211 212 + private shouldFinalizeSyntheticTrack(nowMs: number = Date.now()): boolean { 213 + if (this.syntheticPlayback === undefined || this.lastPlay === undefined) { 214 + return false; 215 + } 216 + const durationSec = this.syntheticPlayback.durationSec ?? this.lastPlay.data.duration; 217 + if (durationSec === undefined || durationSec <= 0) { 218 + return false; 219 + } 220 + if (this.syntheticPlayback.lastPositionSec < durationSec) { 221 + return false; 222 + } 223 + if (this.syntheticPlayback.reachedTrackEndAtMs === undefined) { 224 + this.syntheticPlayback.reachedTrackEndAtMs = nowMs; 225 + return false; 226 + } 227 + const sinceEndSec = Math.max(0, (nowMs - this.syntheticPlayback.reachedTrackEndAtMs) / 1000); 228 + return sinceEndSec >= this.postEndStopGraceSec; 229 + } 230 + 231 + private buildStoppedState(): PlayerStateDataMaybePlay | undefined { 232 + if (this.lastBridgeData === undefined) { 233 + return undefined; 234 + } 235 + return { 236 + platformId: [this.lastBridgeData.queue_id ?? 'YandexMusicBridge', 'SingleUser'], 237 + sessionId: this.lastBridgeData.queue_id ?? this.lastBridgeData.track_id, 238 + status: REPORTED_PLAYER_STATUSES.stopped, 239 + }; 240 + } 241 + 202 242 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 203 243 const payload = await this.callBridge(); 204 244 const bridgeData = payload.data; 245 + const nowMs = Date.now(); 246 + 205 247 if (payload.ok && bridgeData !== undefined && bridgeData !== null && bridgeData.title) { 206 248 const play = formatPlayObj(bridgeData); 207 249 const playbackState = this.getPlaybackState(bridgeData, play); 208 250 this.lastBridgeData = bridgeData; 209 251 this.lastPlay = play; 210 - this.lastBridgeSeenAtMs = Date.now(); 252 + this.lastBridgeSeenAtMs = nowMs; 253 + 254 + if (this.shouldFinalizeSyntheticTrack(nowMs)) { 255 + const durationSec = this.syntheticPlayback?.durationSec ?? play.data.duration ?? 0; 256 + 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.`); 257 + const stoppedState = this.buildStoppedState(); 258 + this.resetSyntheticPlayback(); 259 + return await this.processRecentPlays(stoppedState !== undefined ? [stoppedState] : []); 260 + } 211 261 212 262 const playerState: PlayerStateData = { 213 263 platformId: [bridgeData.queue_id ?? 'YandexMusicBridge', 'SingleUser'], ··· 220 270 return await this.processRecentPlays([playerState]); 221 271 } 222 272 223 - if (this.shouldKeepAliveSynthetic()) { 273 + if (this.shouldKeepAliveSynthetic(nowMs)) { 224 274 const bridgeDataForKeepAlive = this.lastBridgeData!; 225 275 const playForKeepAlive = this.lastPlay!; 226 276 const playbackState = this.getPlaybackState(bridgeDataForKeepAlive, playForKeepAlive, { keepAlive: true }); 277 + 278 + if (this.shouldFinalizeSyntheticTrack(nowMs)) { 279 + const durationSec = this.syntheticPlayback?.durationSec ?? playForKeepAlive.data.duration ?? 0; 280 + 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.`); 281 + const stoppedState = this.buildStoppedState(); 282 + this.resetSyntheticPlayback(); 283 + return await this.processRecentPlays(stoppedState !== undefined ? [stoppedState] : []); 284 + } 227 285 228 286 this.logger.trace(`Bridge returned no current track; keeping synthetic playback alive for ${playForKeepAlive.data.artists?.join(', ') ?? 'Unknown'} - ${playForKeepAlive.data.track ?? 'Unknown'}`); 229 287