import 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 { 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(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 { 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 => { 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 = (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'; }