Merge pull request #278 from FoxxMD/GH-263/azuracast

feat: Add Azuracast Source
This commit is contained in:
Matt Foxx
2025-03-19 09:28:27 -04:00
committed by GitHub
19 changed files with 731 additions and 42 deletions
+1
View File
@@ -31,6 +31,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
* Supports scrobbling to many **Clients**
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
+13
View File
@@ -0,0 +1,13 @@
[
{
"type": "azuracast",
"enable": true,
"name": "azura",
"data": {
"url": "ws://192.168.0.101",
"station": "my-station-name",
"monitorWhenLive": true,
"monitorWhenListeners": 1
}
}
]
@@ -10,6 +10,7 @@ import SchemaLink from "../../src/components/SchemaLink";
import AIOExample from "../../src/components/AIOExample";
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
import AzuracastConfig from '!!raw-loader!../../../config/azuracast.json.example';
import ChromecastConfig from '!!raw-loader!../../../config/chromecast.json.example';
import DeezerConfig from '!!raw-loader!../../../config/chromecast.json.example';
import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example';
@@ -1900,6 +1901,67 @@ If you find that VLC is incorrectly reporting track information (in its interfac
]
```
### [Azuracast](https://www.azuracast.com/)
The Azuracast server should have **Use High-Performance Now Playing Updates** enabled in _Administration -> System Settings_
##### URL
The URL used by MS to connect to Azuracast has the syntax:
```
[ws|wss]://HOST:[PORT]
```
MS will automatically add the path required for websockets, [`/api/live/nowplaying/websocket`](https://www.azuracast.com/docs/developers/now-playing-data/#websockets), to your URL if none is provided. If you use a reverse proxy with a path-based URL or otherwise need a custom path to access the websockets path correctly then explicitly provide it. Examples:
```
URL From Config => MS Uses
'ws://192.168.0.101' => ws://192.168.0.101/api/live/nowplaying/websocket
'ws://azura.mydomain.com' => ws://azura.mydomain.com.com/api/live/nowplaying/websocket
'wss://mydomain.com/custom/azura/ws' => wss://mydomain.com/custom/azura/ws
```
##### Manual Listening
A user can manually toggle scrobbling for Azuracast as long as the station is online. On the MS Dashboard use the **Manual Listening** link below the Source status to toggle scrobbling. This will override any automatic scrobbling based on current listeners.
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
| `AZ_URL` | Yes | | Azuracast URL *without station name* |
| `AZ_STATION` | Yes | | The station name shown on the public page |
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
</TabItem>
<TabItem value="file" label="File">
<details>
<summary>Example</summary>
<CodeBlock title="CONFIG_DIR/azuracast.json" language="json5">{AzuracastConfig}</CodeBlock>
</details>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
<TabItem value="aio" label="AIO">
<details>
<summary>Example</summary>
<AIOExample data={AzuracastConfig} name="azuracast"/>
</details>
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
</TabItem>
</Tabs>
### [Yamaha MusicCast](https://usa.yamaha.com/products/contents/audio_visual/musiccast/index.html)
Monitor Musiccast device/receivers for music played on Network/USB/CD inputs.
@@ -1941,6 +2003,7 @@ The data source of the music being played on the Musiccast device may not report
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | :----------------------- |
| `MCAST_URL` | Yes | | The Musiccast device URL |
+1
View File
@@ -32,6 +32,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Musikcube](docs/configuration#musikcube)
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
* [VLC](docs/configuration#vlc)
* [Azuracast](docs/configuration#azuracast)
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
* Supports scrobbling to many **Clients**
* [Maloja](docs/configuration#maloja)
+3 -3
View File
@@ -11762,9 +11762,9 @@
"peer": true
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"engines": {
"node": ">=10.0.0"
},
+4 -2
View File
@@ -28,7 +28,8 @@ export type SourceType =
| 'chromecast'
| 'musikcube'
| 'mpd'
| 'vlc';
| 'vlc'
| 'azuracast';
export const sourceTypes: SourceType[] = [
'spotify',
@@ -51,7 +52,8 @@ export const sourceTypes: SourceType[] = [
'chromecast',
'musikcube',
'mpd',
'vlc'
'vlc',
'azuracast'
];
export const isSourceType = (data: string): data is SourceType => {
@@ -0,0 +1,102 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
export interface AzuraStationInfoResponse {
id: string
name: string
shortcode: string
is_public: boolean
}
export interface AzuraListenersResponse {
total: number
unique: number
current: number
}
export interface AzuraSongResponse {
id: string
text: string
artist: string
title: string
album: string
genre: string
isrc: string
}
export interface AzuraNowPlayingResponse {
sh_id: number
played_at: number
duration: number
streamer: string
elapsed: number
remaining: number
song: AzuraSongResponse
}
export interface AzuraLiveResponse {
is_live: boolean
streamer_name: string
broadcast_start: number | null
}
export interface AzuraStationResponse {
is_online: boolean
station: AzuraStationInfoResponse
listeners: AzuraListenersResponse
now_playing: AzuraNowPlayingResponse
}
export interface AzuracastData extends CommonSourceData {
/**
* Base URL of the Azuracast instance
*
* This does NOT include the station. If a station is included it will be ignored. Use `station` field to specify station, if necessary
*
*
* @examples ["https://radio.mydomain.tld", "http://localhost:80"]
* */
url: string
/**
* The specific station to monitor
*
* Scrobbling will only occur if any of the monitor conditions are met AND the station is ONLINE.
*
* To monitor multiple stations create a Source for each station.
*
* @examples ["my-station-1"]
* */
station: string
/**
* Only activate scrobble monitoring if station
*
* * `true` => has any current listeners
* * `number` => has EQUAL TO or MORE THAN X number of listeners
*
*/
monitorWhenListeners?: boolean | number
/**
* Only activate scrobble monitoring if station has a live DJ/Streamer
*
* @default true
*/
monitorWhenLive?: boolean
/**
* API Key used to access data about private streams
*
* https://www.azuracast.com/docs/developers/apis/#api-authentication
* */
apiKey?: string
}
export interface AzuracastSourceConfig extends CommonSourceConfig {
data: AzuracastData
}
export interface AzuracastSourceAIOConfig extends AzuracastSourceConfig {
type: 'azuracast'
}
@@ -1,3 +1,4 @@
import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js";
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
@@ -44,7 +45,8 @@ export type SourceConfig =
| MusikcubeSourceConfig
| MusicCastSourceConfig
| MPDSourceConfig
| VLCSourceConfig;
| VLCSourceConfig
| AzuracastSourceConfig;
export type SourceAIOConfig =
SpotifySourceAIOConfig
@@ -69,4 +71,5 @@ export type SourceAIOConfig =
| MusikcubeSourceAIOConfig
| MusicCastSourceAIOConfig
| MPDSourceAIOConfig
| VLCSourceAIOConfig;
| VLCSourceAIOConfig
| AzuracastSourceAIOConfig;
@@ -0,0 +1,85 @@
import { childLogger } from "@foxxmd/logging";
import { URLData } from "../../../../core/Atomic.js";
import { joinedUrl, normalizeWSAddress } from "../../../utils/NetworkUtils.js";
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { AzuracastData, AzuraStationResponse } from "../../infrastructure/config/source/azuracast.js";
import AbstractApiClient from "../AbstractApiClient.js";
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
export class AzuracastApiClient extends AbstractApiClient {
declare config: AzuracastData
urlData: URLData;
wsNowPlaying: AzuraStationResponse
wsCurrenTime: number = 0;
socket!: WS;
constructor(name: any, config: AzuracastData, options: AbstractApiOptions) {
super('Azuracast API', name, config, options);
this.urlData = normalizeWSAddress(config.url);
}
connectWS() {
const url = joinedUrl(this.urlData.url, '/api/live/nowplaying/websocket');
const socket = new WebSocket(url);
socket.onopen = (e) => {
socket.send(JSON.stringify({
subs: {
[`station:${this.config.station}`]: {"recover": true}
}
}));
};
socket.onerror = (e) => {
this.logger.error(e);
}
// Handle a now-playing event from a station. Update your now-playing data accordingly.
function handleSseData(ssePayload, useTime = true) {
const jsonData = ssePayload.data;
if (useTime && 'current_time' in jsonData) {
this.wsCurrenTime = jsonData.current_time;
}
this.wsNowPlaying = jsonData.np as AzuraStationResponse;
}
socket.onmessage = (e) => {
const jsonData = JSON.parse(e.data as string);
if ('connect' in jsonData) {
const connectData = jsonData.connect;
if ('data' in connectData) {
// Legacy SSE data
connectData.data.forEach(
(initialRow) => handleSseData(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) => handleSseData(initialRow, false));
}
}
}
} else if ('pub' in jsonData) {
handleSseData(jsonData.pub);
}
};
}
}
+30 -1
View File
@@ -30,7 +30,6 @@ import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js";
import { setupPlexRoutes } from "./plexRoutes.js";
import { setupTautulliRoutes } from "./tautulliRoutes.js";
import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js";
import { Readable } from 'node:stream';
const maxBufferSize = 300;
const output: Record<number, FixedSizeList<LogDataPretty>> = {};
@@ -196,6 +195,8 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
players: 'players' in x ? (x as MemorySource).playersToObject() : {},
sot: ('playerSourceOfTruth' in x) ? x.playerSourceOfTruth : SOURCE_SOT.HISTORY,
supportsUpstreamRecentlyPlayed: x.supportsUpstreamRecentlyPlayed,
supportsManualListening: x.supportsManualListening,
manualListening: x.manualListening,
...x.additionalApiData()
};
if(!x.isReady()) {
@@ -449,6 +450,34 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream:
}
});
app.use('/api/source/listen', sourceRequiredMiddle);
app.postAsync('/api/source/listen', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
const source = req.scrobbleSource as AbstractSource;
const {
query: {
listening: listeningQ
}
} = req;
if(!source.supportsManualListening)
{
source.logger.warn('This source does not support manual listening');
res.status(400).send();
return;
}
let listening: boolean | undefined;
if(listeningQ !== undefined) {
listening = parseBool(listeningQ)
}
source.logger.verbose(`User requested listening status ${listening === undefined ? 'system' : listening}`);
source.manualListening = listening;
res.status(200).json({listening});
});
app.use('/api/client/init', clientRequiredMiddle);
app.postAsync('/api/client/init', async (req, res) => {
// @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message
+3
View File
@@ -74,6 +74,9 @@ export default abstract class AbstractSource extends AbstractComponent implement
supportsUpstreamRecentlyPlayed: boolean = false;
supportsUpstreamNowPlaying: boolean = false;
supportsManualListening: boolean = false;
manualListening?: boolean
emitter: EventEmitter;
+274
View File
@@ -0,0 +1,274 @@
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
import { sleep } from "../utils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import { childLogger, Logger } from "@foxxmd/logging";
import { EventEmitter } from "events";
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
import pEvent from 'p-event';
import { PlayObject, URLData } from "../../core/Atomic.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import {
FormatPlayObjectOptions,
InternalConfig,
PlayerStateData,
PlayPlatformId,
REPORTED_PLAYER_STATUSES,
SINGLE_USER_PLATFORM_ID,
} from "../common/infrastructure/Atomic.js";
import { AzuracastSourceConfig, AzuraNowPlayingResponse, AzuraStationResponse } from "../common/infrastructure/config/source/azuracast.js";
import { isPortReachable, normalizeWSAddress } from "../utils/NetworkUtils.js";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { AzuracastPlayerState } from "./PlayerState/AzuracastPlayerState.js";
export class AzuracastSource extends MemoryPositionalSource {
declare config: AzuracastSourceConfig;
urlData!: URLData;
wsNowPlaying: AzuraStationResponse
wsCurrenTime: number = 0;
client!: WS;
constructor(name: any, config: AzuracastSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
const {
data = {}
} = config;
const {
...rest
} = data;
super('azuracast', name, { ...config, data: { ...rest } }, internal, emitter);
const {
data: {
url,
} = {}
} = config;
this.requiresAuth = false;
this.canPoll = true;
this.supportsManualListening = 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}`, { labels: 'WS' });
});
this.client.addEventListener('close', (e) => {
wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`, { labels: 'WS' });
if (e.reason.includes('unauthenticated')) {
this.authed = false;
}
});
this.client.addEventListener('open', (e) => {
wsLogger.verbose(`Connection was established.`, { labels: 'WS' });
// 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.manualListening !== undefined) {
this.logger.debug({labels: `Station ${this.config.data.station}`}, `Using manual listening status ${this.manualListening}`);
return this.manualListening;
}
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 this.processRecentPlays([playerState]);
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new AzuracastPlayerState(logger, id, opts);
}
const formatPlayObj = (obj: AzuraNowPlayingResponse, options: FormatPlayObjectOptions = {}): PlayObject => {
const {
song,
duration,
elapsed,
remaining,
} = obj;
const {
text,
artist,
title,
album
} = song;
const track: string = title ?? text;
return {
data: {
artists: artist !== undefined && artist !== '' ? [artist] : [],
album: album !== '' ? album : undefined,
track,
duration
},
meta: {
trackProgressPosition: elapsed
}
}
}
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';
}
+7 -29
View File
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
import normalizeUrl from 'normalize-url';
import pEvent from 'p-event';
import { URL } from "url";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, URLData } from "../../core/Atomic.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
import {
FormatPlayObjectOptions,
@@ -23,6 +23,7 @@ import {
import { sleep } from "../utils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
import { normalizeWSAddress } from "../utils/NetworkUtils.js";
const CLIENT_STATE = {
0: 'connecting',
@@ -34,7 +35,7 @@ const CLIENT_STATE = {
export class MusikcubeSource extends MemoryPositionalSource {
declare config: MusikcubeSourceConfig;
url: URL;
url: URLData;
client!: WS;
@@ -56,46 +57,23 @@ export class MusikcubeSource extends MemoryPositionalSource {
} = {}
} = config;
this.deviceId = device_id ?? name;
this.url = MusikcubeSource.parseConnectionUrl(url);
this.url = normalizeWSAddress(url, {defaultPort: 7905});
this.requiresAuth = true;
this.canPoll = true;
}
static parseConnectionUrl(valRaw: string) {
let val = valRaw.trim();
if(!val.match(/^(?:wss?|https?):/i)) {
val = `ws://${val}`;
}
const normal = normalizeUrl(val, {removeTrailingSlash: false})
const url = new URL(normal);
// default WS
if (url.protocol === 'https:') {
url.protocol = 'wss:';
} else if (url.protocol === 'http:') {
url.protocol = 'ws:';
} else {
url.protocol = 'ws:'
}
if (url.port === null || url.port === '') {
url.port = '7905';
}
return url;
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
url
} = {}
} = this.config;
const normal = this.url.toString();
const normal = this.url.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://`);
throw new Error(`Server URL must start with ws:// or wss://`);
}
this.client = new WS(this.url.toString(), {
this.client = new WS(this.url.url.toString(), {
automaticOpen: false,
retry: {
retries: 0
@@ -0,0 +1,16 @@
import { Logger } from "@foxxmd/logging";
import { PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js";
import { GenericPlayerState } from "./GenericPlayerState.js";
import { PositionalPlayerState } from "./PositionalPlayerState.js";
export class AzuracastPlayerState extends PositionalPlayerState {
constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
super(logger, platformId, {allowedDrift: 17000, rtTruth: true, ...(opts || {})});
this.gracefulEndBuffer = this.allowedDrift / 1000;
}
protected isSessionStillPlaying(position: number): boolean {
return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing;
}
}
+27
View File
@@ -3,6 +3,7 @@ import { childLogger, Logger } from '@foxxmd/logging';
import EventEmitter from "events";
import { ConfigMeta, InternalConfig, isSourceType, SourceType, sourceTypes } from "../common/infrastructure/Atomic.js";
import { AIOConfig, SourceDefaults } from "../common/infrastructure/config/aioConfig.js";
import { AzuracastData, AzuracastSourceConfig } from "../common/infrastructure/config/source/azuracast.js";
import { ChromecastSourceConfig } from "../common/infrastructure/config/source/chromecast.js";
import { DeezerData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { ListenbrainzEndpointSourceConfig, ListenbrainzEndpointData } from "../common/infrastructure/config/source/endpointlz.js";
@@ -34,6 +35,7 @@ import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { parseBool, readJson } from "../utils.js";
import { validateJson } from "../utils/ValidationUtils.js";
import AbstractSource from "./AbstractSource.js";
import { AzuracastSource } from "./AzuracastSource.js";
import { ChromecastSource } from "./ChromecastSource.js";
import DeezerSource from "./DeezerSource.js";
import { EndpointListenbrainzSource } from "./EndpointListenbrainzSource.js";
@@ -188,6 +190,9 @@ export default class ScrobbleSources {
case 'vlc':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("VLCSourceConfig");
break;
case 'azuracast':
this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("AzuracastSourceConfig");
break;
}
}
return this.schemaDefinitions[type];
@@ -609,6 +614,25 @@ export default class ScrobbleSources {
data: ytm as YTMusicData
});
}
break;
case 'azuracast':
const azura = {
station: process.env.AZURA_STATION,
url: process.env.AZURA_URL,
monitorWhenListeners: process.env.AZURA_LISTENERS_NUM,
monitorWhenLive: process.env.AZURA_LIVE,
apiKey: process.env.AZURA_KEY
}
if (!Object.values(azura).every(x => x === undefined)) {
configs.push({
type: 'azuracast',
name: 'unnamed',
source: 'ENV',
mode: 'single',
configureAs: defaultConfigureAs,
data: azura as unknown as AzuracastData
});
}
break;
default:
break;
@@ -798,6 +822,9 @@ export default class ScrobbleSources {
case 'vlc':
newSource = await new VLCSource(name, compositeConfig as VLCSourceConfig, this.internalConfig, this.emitter);
break;
case 'azuracast':
newSource = await new AzuracastSource(name, compositeConfig as AzuracastSourceConfig, this.internalConfig, this.emitter);
break;
default:
break;
}
+44
View File
@@ -125,6 +125,50 @@ export const normalizeWebAddress = (val: string, options: {defaultPath?: string}
}
}
export const normalizeWSAddress = (val: string, options: {defaultPort?: number | string, defaultPath?: string} = {}): URLData => {
let cleanUserUrl = val.trim();
const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val);
if (results !== undefined && results.groups && results.groups.length > 0) {
cleanUserUrl = results.groups[0];
}
if(!cleanUserUrl.match(/^(?:wss?|https?):/i)) {
cleanUserUrl = `ws://${cleanUserUrl}`;
}
const normal = normalizeUrl(val, {removeTrailingSlash: false})
const url = new URL(normal);
// default WS
if (url.protocol === 'https:') {
url.protocol = 'wss:';
} else if (url.protocol === 'http:') {
url.protocol = 'ws:';
} else if(url.protocol === '') {
url.protocol = 'ws:'
}
const {defaultPort, defaultPath} = options;
let port: number;
if(url.port === null || url.port === '') {
if(defaultPort !== undefined) {
url.port = defaultPort.toString();
port = parseInt(url.port);
} else {
port = url.protocol === 'ws:' ? 80 : 443;
}
}
if(url.pathname === '/' && defaultPath !== undefined) {
url.pathname = defaultPath;
}
return {
url,
normal: url.toString(),
port
}
}
export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => {
const urlStr = userUrl ?? `http://localhost:${defaultPort}`;
let cleanUserUrl = urlStr.trim();
@@ -1,4 +1,4 @@
import React, {Fragment, useCallback} from 'react';
import React, {Fragment, useCallback, useMemo} from 'react';
import StatusCardSkeleton, {StatusCardSkeletonData} from "./StatusCardSkeleton";
import SkeletonParagraph from "../skeleton/SkeletonParagraph";
import {Link} from "react-router-dom";
@@ -6,7 +6,7 @@ import {sourceAdapter} from "../../status/ducks";
import {RootState} from "../../store";
import {connect, ConnectedProps} from "react-redux";
import Player from "../player/Player";
import {useStartSourceMutation} from "./sourceDucks";
import {useStartSourceMutation, useListenSourceMutation} from "./sourceDucks";
import './statusCard.scss';
export interface SourceStatusCardData extends StatusCardSkeletonData, PropsFromRedux {
@@ -39,9 +39,13 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
let body = <SkeletonParagraph/>;
const [startPut, startResult] = useStartSourceMutation();
const [listenPut, listenResult] = useListenSourceMutation();
const tryStart = useCallback((name: string, type: string, force?: boolean) => startPut({name, type, force}), [startPut]);
const tryListen = useCallback((name: string, type: string, listening?: boolean) => listenPut({name, type, listening}), [listenPut]);
let startSourceElement = null;
let manualListenElement = null;
let subtitleElement = null;
if(data !== undefined)
{
@@ -57,7 +61,9 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
type,
players = {},
sot,
supportsUpstreamRecentlyPlayed
supportsUpstreamRecentlyPlayed,
supportsManualListening,
manualListening
} = data;
if(type === 'listenbrainz' || type === 'lastfm') {
header = `${display} (Source)`;
@@ -70,6 +76,25 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
startText = status === 'Running' ? 'Reinit' : 'Init'
}
const ml = useMemo(() => {
if(listenResult.status !== 'fulfilled' || listenResult.data === undefined) {
return manualListening;
}
return (listenResult.data as any).listening;
}, [manualListening, listenResult]);
if(supportsManualListening) {
manualListenElement = (<Fragment>
<span>Manual Listening:</span>
<div onClick={() => tryListen(name, type, ml === undefined ? true : !ml)}
className="capitalize underline cursor-pointer inline mr-1 ml-1">{ml === undefined ? 'System' : (ml ? 'Yes' : 'No')}
</div>
(<div onClick={() => tryListen(name, type, undefined)}
className="capitalize underline cursor-pointer inline">Clear
</div>)
</Fragment>);
}
startSourceElement = (<Fragment>
<div onClick={() => tryStart(name, type)}
className="capitalize underline cursor-pointer inline mr-1">{startText}
@@ -79,6 +104,12 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
</div>)
</Fragment>);
if(manualListenElement !== null) {
subtitleElement = <Fragment>{manualListenElement} | {startSourceElement}</Fragment>
} else {
subtitleElement = startSourceElement;
}
const platformIds = Object.keys(players);
const discovered = (!hasAuth || authed) ? <Link to={`/recent?type=${type}&name=${name}`}>Tracks Discovered</Link> : <span>Tracks Discovered</span>;
@@ -103,7 +134,7 @@ const SourceStatusCard = (props: SourceStatusCardData) => {
title={header}
subtitle={name}
status={status}
subtitleRight={startSourceElement}
subtitleRight={subtitleElement}
statusType={statusToStatusType(status)}>
{body}
</StatusCardSkeleton>
@@ -18,8 +18,23 @@ export const sourceApi = createApi({
force: params.force
}
})
}),
listenSource: builder.mutation<undefined, {
name: string,
type: string,
listening?: boolean
}>({
query: (params) => ({
url: '/source/listen',
method: 'POST',
params: {
name: params.name,
type: params.type,
listening: params.listening
}
})
})
})
});
export const {useStartSourceMutation} = sourceApi;
export const {useStartSourceMutation, useListenSourceMutation} = sourceApi;
+2
View File
@@ -15,6 +15,8 @@ export interface SourceStatusData {
players: Record<string, SourcePlayerJson>
sot: SOURCE_SOT_TYPES
supportsUpstreamRecentlyPlayed: boolean;
supportsManualListening: boolean;
manualListening?: boolean
}
export interface ClientStatusData {