mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-04-28 03:39:54 +00:00
added multiPlatform
added multiPlatform and support for receiving data from Yandex smart speakers
This commit is contained in:
parent
6a81fc1be2
commit
eeb55aa02e
1 changed files with 237 additions and 115 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue