mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Merge pull request #278 from FoxxMD/GH-263/azuracast
feat: Add Azuracast Source
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+3
-3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SourceStatusData {
|
||||
players: Record<string, SourcePlayerJson>
|
||||
sot: SOURCE_SOT_TYPES
|
||||
supportsUpstreamRecentlyPlayed: boolean;
|
||||
supportsManualListening: boolean;
|
||||
manualListening?: boolean
|
||||
}
|
||||
|
||||
export interface ClientStatusData {
|
||||
|
||||
Reference in New Issue
Block a user