Something went wrong. Try again.
[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
Something went wrong. Try again.
10 kB · 272 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273import MemorySource from "./MemorySource.ts";import type {RecentlyPlayedOptions} from "./AbstractSource.ts";import { childLogger } from "@foxxmd/logging";import type { EventEmitter } from "events";import type { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket';import { WS } from 'iso-websocket'import pEvent from 'p-event';import type {PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts";import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData,} from "../common/infrastructure/Atomic.ts";import { SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';import type {AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse} from "../common/infrastructure/config/source/azuracast.ts";import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { artistNamesToCredits } from "../../core/StringUtils.ts";
export class AzuracastSource extends MemorySource {
declare config: AzuracastSourceConfig;
urlData!: URLData;
wsNowPlaying: AzuraStationResponse wsCurrenTime: number = 0; client!: WS; override monitoringActivityDefault = false;
constructor(name: any, config: AzuracastSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('azuracast', name, config, internal, emitter);
this.requiresAuth = false; this.canPoll = true; }
protected async doBuildInitData(): Promise<true | string | undefined> { const { data: { url } = {} } = this.config; if (url === null || url === undefined || url === '') { throw new Error('url must be defined'); } this.urlData = normalizeWSAddress(url, { defaultPath: '/api/live/nowplaying/websocket' }); const normal = this.urlData.normal; this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) if (!normal.includes('ws://') && !normal.includes('wss://')) { throw new Error(`Server URL must be start with with ws:// or wss://`); } this.client = new WS(this.urlData.url.toString(), { automaticOpen: false, retry: { retries: 0 } }); const wsLogger = childLogger(this.logger, 'WS'); this.client.addEventListener('retry', (e) => { wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`); }); this.client.addEventListener('close', (e) => { wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`); if (e.reason.includes('unauthenticated')) { this.authed = false; } }); this.client.addEventListener('open', (e) => { wsLogger.verbose(`Connection was established.`); // if (this.authed) { // // was a reconnect, try auto authenticating // wsLogger.verbose('Resending auth message after (probably) reconnection...'); // this.client.send(JSON.stringify(this.getAuthPayload())); // } }); this.client.addEventListener('error', (e) => { if (e.message.includes('Connection failed after')) { this.connectionOK = false; //this.authed = false; } const hint = e.error?.cause?.message ?? undefined; wsLogger.error(new Error(`Communication with server failed${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error })); });
this.client.addEventListener('message', (e) => { this.parseWSData(getMessageData<any>(e)); // if (isAuthenticateResponse(data)) { // wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`); // } }); return true; }
private parseWSPayload(payload: any, useTime = true) { const jsonData = payload.data;
if (useTime && 'current_time' in jsonData) { this.wsCurrenTime = jsonData.current_time; }
this.wsNowPlaying = jsonData.np as AzuraStationResponse; }
private parseWSData(jsonData: any) {
if ('connect' in jsonData) { const connectData = jsonData.connect;
if ('data' in connectData) { // Legacy SSE data connectData.data.forEach( (initialRow) => this.parseWSPayload(initialRow) ); } else { // New Centrifugo time format if ('time' in connectData) { this.wsCurrenTime = Math.floor(connectData.time / 1000); }
// New Centrifugo cached NowPlaying initial push. for (const subName in connectData.subs) { const sub = connectData.subs[subName]; if ('publications' in sub && sub.publications.length > 0) { sub.publications.forEach((initialRow) => this.parseWSPayload(initialRow, false)); } } } } else if ('pub' in jsonData) { this.parseWSPayload(jsonData.pub); } }
protected async doCheckConnection(): Promise<true | string | undefined> { try { try { await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname }); this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); } catch (e) { throw e; }
this.client.open(); const opened = await pEvent(this.client, 'open'); return true; } catch (e) { this.client.close(); const hint = e.error?.cause?.message ?? undefined; throw new Error(`Could not connect to Azuracast server${hint !== undefined ? ` (${hint})` : ''}`, { cause: e.error ?? e }); } }
onPollPostAuthCheck = async (): Promise<boolean> => { this.logger.verbose(`Listening for activity on Station ${this.config.data.station}`); this.client.send(JSON.stringify({ subs: { [`station:${this.config.data.station}`]: { "recover": true } } })); return true; }
// TODO return based on user intervention protected isStationValidListen = () => { if(this.wsNowPlaying === undefined) { this.logger.debug({labels: `Station ${this.config.data.station}`}, `No data returned yet (check station name is correct?)`); return false; } if(!this.wsNowPlaying.is_online && this.config.data.monitorWhenLive) { this.logger.debug({labels: `Station ${this.config.data.station}`}, `Currently offline`); return false; } if(this.monitoringActivity !== undefined) { this.logger.debug({labels: `Station ${this.config.data.station}`}, `Using manual listening status ${this.monitoringActivity}`); return this.monitoringActivity; } if(this.config.data.monitorWhenListeners !== undefined) { if(this.config.data.monitorWhenListeners === true && this.wsNowPlaying.listeners.current === 0) { this.logger.debug({labels: `Station ${this.config.data.station}`}, `No listeners`); return false; } if(typeof this.config.data.monitorWhenListeners === 'number' && this.wsNowPlaying.listeners.current < this.config.data.monitorWhenListeners) { this.logger.debug({labels: `Station ${this.config.data.station}`}, `Requries ${this.config.data.monitorWhenListeners} listeners to be active but currently only ${this.wsNowPlaying.listeners.current}`); return false; } } return true; }
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { if (this.client.readyState !== this.client.OPEN) { throw new Error('WS connection is no longer open.'); }
let play: PlayObject | undefined; const online = this.isStationValidListen();
if(this.isStationValidListen() && this.wsNowPlaying.now_playing !== undefined) { play = formatPlayObj(this.wsNowPlaying.now_playing); }
const playerState: PlayerStateData = { platformId: SINGLE_USER_PLATFORM_ID, status: online ? REPORTED_PLAYER_STATUSES.playing : REPORTED_PLAYER_STATUSES.stopped, play, position: online && play !== undefined ? play.meta.trackProgressPosition : undefined }
return await this.processRecentPlays([playerState]); }
protected getSystemDefaultMonitoring = (): boolean => { const { monitorWhenLive, monitorWhenListeners } = this.config.data; return monitorWhenLive !== undefined || monitorWhenListeners !== undefined; }
}
const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
const { song, duration, elapsed, remaining, } = obj;
const { text, artist, title, album } = song;
const track: string = title ?? text;
const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(artist !== undefined && artist !== '' ? [artist] : []), album: album !== '' ? album : undefined, track, duration }, meta: { trackProgressPosition: elapsed, source: 'azuracast', mediaPlayerName: 'Azuracast' } } return baseFormatPlayObj(obj, play);}
const getMessageData = <T>(e: any): T => { return JSON.parse(e.data) as T;}
const isCloseEvent = (e: Event): e is CloseEvent => { return e.type === 'close';}const isErrorEvent = (e: Event): e is ErrorEvent => { return e.type === 'error';}const isRetryEvent = (e: Event): e is RetryEvent => { return e.type === 'retry';}