Compare commits

...
18 Commits
Author SHA1 Message Date
FoxxMD a68dea5193 chore: Bump version for release 2025-06-16 19:22:43 +00:00
FoxxMD b5d56b75d7 test(listenbrainz): Fix expected client count 2025-06-16 16:06:57 +00:00
FoxxMD c8daffb935 docs(listenbrainz): Add Koito compatibility and config example #300 2025-06-16 16:02:13 +00:00
FoxxMD 18a7f26c54 feat(listenbrainz): Implement limited support for Koito as Listenbrainz Source/Client #300
* Support Koito server detection via public route
  * Warns users of limited support on detection
* Implement play history retrival for Koito server
2025-06-16 15:49:39 +00:00
FoxxMD e7395e79a5 feat(listenbrainz): Detect /1/ version prefix on base URL
Remove /1/ from base URL to further normalize user URL and make config easier #300
2025-06-16 14:15:48 +00:00
FoxxMD 08ee6d379f fix(listenbrainz): Normalize URL and join parts correctly
* Normalize base URL to make user URL configuration easier/more robust
* Use joinPath for concetenating URL to avoid missing slashes #300
2025-06-16 14:04:15 +00:00
Matt Foxx 9c692539ce Merge pull request #298 from FoxxMD/jellyfin-musicbrainz
feat(jellyfin): Add musicbrainz metadata #297
2025-06-04 14:15:09 -04:00
FoxxMD 5cd5061f20 feat(jellyfin): Add musicbrainz metadata #297 2025-06-04 13:44:15 +00:00
FoxxMD 46bcf6d6a6 chore: Bump version for release 2025-05-29 13:20:34 +00:00
FoxxMD 4da931269c fix(plex): On Plex webhook routes filter plex sources to get webhook source only
#291
2025-05-27 17:31:34 +00:00
FoxxMD d19d40d479 test: Fix real time player not triggered during event loop
Running tests synchronously preventing rt player from advancing which cause each player update to be considered an overdrift
2025-05-27 17:07:30 +00:00
FoxxMD b3e1c7f660 chore: Update devcontainer to node 20 to match docker image 2025-05-27 17:05:50 +00:00
FoxxMD 1242a710df fix: Fix NaN and undefined initial states for rt player #292 2025-05-27 15:29:06 +00:00
FoxxMD b724eca9be docs: Add missing var for docs deploy 2025-04-23 15:04:20 +00:00
FoxxMD 376d4936cc docs: Add google site verification 2025-04-23 15:01:42 +00:00
FoxxMD a5da0573c5 docs: Remove flatpak installation method
See #287
2025-03-31 18:31:46 +00:00
FoxxMD 4694ce45a7 chore: Update flatpak description 2025-03-31 12:12:28 -04:00
FoxxMD 7c7d710cd3 fix(ui): Guard against null listenedDuration
Fixes #285
2025-03-28 08:21:04 -04:00
26 changed files with 283 additions and 82 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
{
"name": "Node.js",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
+1
View File
@@ -30,6 +30,7 @@ jobs:
env:
ANALYTICS: ${{ vars.ANALYTICS }}
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
GSITEVERIFICATION: ${{ vars.GSITEVERIFICATION }}
DOCS_BASE: '/multi-scrobbler'
run: npm run build
working-directory: ./docsite
+3 -1
View File
@@ -21,6 +21,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
@@ -38,6 +39,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
* [Koito](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz) using Listenbrainz Client
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webhook-configurations) or [healthcheck endpoint](https://foxxmd.github.io/multi-scrobbler/docs/configuration#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
@@ -45,7 +47,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/configuration/transforms)
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installation#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker) or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
+11 -1
View File
@@ -16,5 +16,15 @@
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
}
}
},
{
"name": "koitoClient",
"enable": true,
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD",
"url": "http://192.168.0.100:4110/apis/listenbrainz/1"
}
},
]
+6 -3
View File
@@ -765,17 +765,18 @@ http://localhost:9078/api/lastfm/mySlug
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
You will need to run your own Listenbrainz server, [Koito](https://koito.io) server, or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
#### Configuration
<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
:::note
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source.
You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the **File** or AOI config to setup Listenbrainz as a Source.
:::
</TabItem>
<TabItem value="file" label="File">
@@ -2267,10 +2268,12 @@ or replace `localhost:9078` with your own base URL
### [Listenbrainz](https://listenbrainz.org)
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
You will need to run your own Listenbrainz server, [Koito](https://koito.io) or have an account [on the official instance](https://listenbrainz.org/login/)
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
For Koito, [create an API Key from your Account page.](https://koito.io/guides/scrobbler/)
#### Configuration
<Tabs groupId="configType" queryString>
-6
View File
@@ -6,12 +6,6 @@ title: Flatpak
description: Building Flatpak App locally
---
:::note
These steps are for building the flatpak from source. If you want to install the application normally then [get it through flathub](../installation/installation.mdx#flatpak)
:::
The final build repo for the flathub version can be found at [flathub/io.github.foxxmd.multiscrobbler](https://github.com/flathub/io.github.foxxmd.multiscrobbler)
## Install Requirements
+3 -7
View File
@@ -105,12 +105,6 @@ The web UI and API is served on port `9078`. This can be modified using the `POR
### Flatpak
You must have [Flatpak](https://flatpak.org/) installed on your system.
```shell
flatpak install flathub io.github.foxxmd.multiscrobbler
```
:::warning
Flatpak users have experienced issues when using multi-scrobbler as a long-running process. Due to the relative difficulty in debugging issues with flatpak installations it is recommended:
@@ -118,7 +112,9 @@ Flatpak users have experienced issues when using multi-scrobbler as a long-runni
* to use a [Docker](#docker) installation if possible or
* only if you need access to host-level resources like dbus for [MPRIS](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris) and cannot run a [nodejs](#nodejs) installation
::::
:::
Flatpak/Flathub installs are no longer supported. You can still build MS as a [Flatpak app from source.](../development/flatpak) See more information about [Flatpak EOL.](https://github.com/FoxxMD/multi-scrobbler/issues/287)
#### Usage Examples
+2 -15
View File
@@ -31,27 +31,14 @@ After=network.target
[Service]
Type=simple
ExecStart=flatpak run io.github.multiscrobbler
WorkingDirectory=/path/to/multi-scrobbler/directory
ExecStart=npm run start
Restart=no
[Install]
WantedBy=default.target
```
The above assumes you [installed multi-scrobbler using flatpak](installation.mdx#flatpak)
### Node.js Installs
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.mdx#nodejs) you should modify the `[Service]`:
```ini
[Service]
Type=simple
WorkingDirectory=/path/to/multi-scrobbler/directory
ExecStart=node src/index.js
Restart=no
```
## Start the Service
Save the file then run:
+9
View File
@@ -173,6 +173,15 @@ const config: Config = {
runmeLinkLabel: 'Checkout via Runme'
}
} satisfies Preset.ThemeConfig,
headTags: [
{
tagName: 'meta',
attributes: {
name: 'google-site-verification',
content: process.env.GSITEVERIFICATION ?? 'none'
}
}
]
};
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
+3 -1
View File
@@ -22,6 +22,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Last.fm](docs/configuration#lastfm-source)
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
* [ListenBrainz](docs/configuration#listenbrainz-source)
* [Koito](docs/configuration#listenbrainz-source) using Listenbrainz Source
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
* [~~Deezer~~](docs/configuration#deezer)
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
@@ -39,6 +40,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Maloja](docs/configuration#maloja)
* [Last.fm](docs/configuration#lastfm)
* [ListenBrainz](docs/configuration#listenbrainz)
* [Koito](docs/configuration#listenbrainz) using Listenbrainz Client
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
@@ -46,7 +48,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* Smart handling of credentials (persistent, authorization through app)
* Easy configuration through ENVs or JSON
* Modify data before scrobbling with [regular expression or search patterns](docs/configuration/transforms)
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
* Install using [Docker images for x86/ARM](docs/installation#docker) or [locally with NodeJS](docs/installation#nodejs)
[**Quick Start Guide**](docs/quickstart)
@@ -33,8 +33,12 @@
<li>MPRIS (linux desktop)</li>
<li>Google Cast (Chromecast)</li>
<li>Musikcube</li>
<li>MPDF (Music Player Daemon)</li>
<li>MPD (Music Player Daemon)</li>
<li>VLC</li>
<li>Azuracast</li>
<li>Icecast</li>
<li>Yamaha MusicCast</li>
<li>Listenbrainz and Last.fm Endpoints</li>
</ul>
<p>and consolidate your listens (scrobbles) into one or more scrobbling servers/services:</p>
<ul>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.2",
"version": "0.9.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.2",
"version": "0.9.4",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.2",
"version": "0.9.4",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -0,0 +1,30 @@
export interface ListensResponse {
items: ListenObjectResponse[]
total_record_count: number
items_per_page: number
has_next_page: boolean
current_page: number
}
export interface ListenObjectResponse {
/** ISO8601 timestamp */
time: string
track: TrackResponse
}
export interface TrackResponse {
id: number
title: string
artists: ArtistResponse[]
musicbrainz_id: string | null
listen_count: number
duration: number
image: string | null
album_id: number
time_listened: number
}
export interface ArtistResponse {
id: number
name: string
}
+68 -8
View File
@@ -1,7 +1,7 @@
import { stringSameness } from '@foxxmd/string-sameness';
import dayjs from "dayjs";
import request, { Request, Response } from 'superagent';
import { PlayObject } from "../../../core/Atomic.js";
import { PlayObject, URLData } from "../../../core/Atomic.js";
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
import {
findDelimiters,
@@ -16,6 +16,10 @@ import { UpstreamError } from "../errors/UpstreamError.js";
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js";
import AbstractApiClient from "./AbstractApiClient.js";
import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js';
import { parseRegexSingleOrFail } from '../../utils.js';
import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js'
import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js';
export interface ArtistMBIDMapping {
@@ -112,17 +116,27 @@ export interface ListenResponse {
track_metadata: TrackResponse;
}
const LZ_VERSION_PATH: RegExp = new RegExp(/\/?1\/?$/);
export class ListenbrainzApiClient extends AbstractApiClient {
declare config: ListenBrainzClientData;
url: string;
url: URLData;
isKoito: boolean = false;
constructor(name: any, config: ListenBrainzClientData, options: AbstractApiOptions) {
super('ListenBrainz', name, config, options);
const {
url = 'https://api.listenbrainz.org/'
} = config;
this.url = url;
let cleanUrl = url;
if(parseRegexSingleOrFail(LZ_VERSION_PATH, cleanUrl)) {
this.logger.verbose(`LZ Server URL contained /1/, removing this because MS adds it automatically`);
cleanUrl = url.replace(LZ_VERSION_PATH, '');
}
this.url = normalizeWebAddress(cleanUrl);
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.url}'`)
}
@@ -178,7 +192,8 @@ export class ListenbrainzApiClient extends AbstractApiClient {
testConnection = async () => {
try {
const resp = await this.callApi(request.get(this.url))
await isPortReachableConnect(this.url.port, {host: this.url.url.hostname});
this.isKoito = await this.checkKoito();
return true;
} catch (e) {
if(e.status === 410 || e.message.includes('HTTP Status 410')) {
@@ -190,18 +205,30 @@ export class ListenbrainzApiClient extends AbstractApiClient {
testAuth = async () => {
try {
const resp = await this.callApi(request.get(`${this.url}1/validate-token`));
const resp = await this.callApi(request.get(`${joinedUrl(this.url.url,'1/validate-token')}`));
return true;
} catch (e) {
throw e;
}
}
async checkKoito(): Promise<boolean> {
try {
const resp = await this.callApi(request.get(`${joinedUrl(getBaseFromUrl(this.url.url), 'apis/web/v1/stats')}`));
this.logger.info('Listenbrainz Host looks like a Koito server, API client will now operate in Koito mode!');
this.logger.warn('Koito has limited support for the Listenbrainz API spec. It does not support Now Playing or retrieving full metabrainz data for a play.');
return true;
} catch (e) {
this.logger.verbose('Listenbrainz Host does not look like a Koito server.');
}
return false;
}
getUserListens = async (maxTracks: number, user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/listens`)
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'listens')}`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
@@ -217,11 +244,29 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
getUserListensKoito = async (maxTracks: number): Promise<KoitoListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${joinedUrl(getBaseFromUrl(this.url.url), '/apis/web/v1/listens')}`)
.query({
period: 'all_time',
page: 0,
limit: maxTracks
})
);
const { body } = resp as any;
return body as KoitoListensResponse;
} catch (e) {
throw e;
}
}
getPlayingNow = async (user?: string): Promise<ListensResponse> => {
try {
const resp = await this.callApi(request
.get(`${this.url}1/user/${user ?? this.config.username}/playing-now`)
.get(`${joinedUrl(this.url.url,'1/user',user ?? this.config.username, 'playing-now')}`)
// this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble
.timeout({
response: 15000, // wait 15 seconds before timeout if server doesn't response at all
@@ -238,6 +283,10 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => {
if(this.isKoito) {
return this.getRecentlyPlayedKoito(maxTracks)
}
try {
const resp = await this.getUserListens(maxTracks, user);
return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x));
@@ -247,6 +296,17 @@ export class ListenbrainzApiClient extends AbstractApiClient {
}
}
getRecentlyPlayedKoito = async (maxTracks: number): Promise<PlayObject[]> => {
try {
const resp = await this.getUserListensKoito(maxTracks);
return resp.items.map(x => listenObjectResponseToPlay(x));
} catch (e) {
this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`);
return [];
}
}
submitListen = async (play: PlayObject, log: boolean = false) => {
try {
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
@@ -257,7 +317,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
// so no useful information
// https://listenbrainz.readthedocs.io/en/latest/users/api-usage.html#submitting-listens
// TODO may we should make a call to recent-listens to get the parsed scrobble?
const resp = await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
const resp = await this.callApi(request.post(`${joinedUrl(this.url.url,'1/submit-listens')}`).type('json').send(listenPayload));
if(log) {
this.logger.debug(`Submit Response: ${resp.text}`)
}
+38
View File
@@ -0,0 +1,38 @@
import dayjs from "dayjs";
import { PlayObject } from "../../../../core/Atomic.js";
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { ListenObjectResponse } from "../../infrastructure/config/client/koito.js";
import AbstractApiClient from "../AbstractApiClient.js";
export class KoitoApiClient extends AbstractApiClient {
constructor(name: any, config: any, options: AbstractApiOptions) {
super('Koito', name, config, options);
}
}
export const listenObjectResponseToPlay = (obj: ListenObjectResponse, options: {newFromSource?: boolean} = {}): PlayObject => {
const play: PlayObject = {
data: {
track: obj.track.title,
artists: (obj.track.artists ?? []).map(x => x.name),
duration: obj.track.duration,
playDate: dayjs(obj.time)
},
meta: {
source: 'Koito',
newFromSource: options.newFromSource ?? false,
trackId: obj.track.id.toString()
}
}
if(obj.track.musicbrainz_id !== null) {
play.data.meta = {
brainz: {
track: obj.track.musicbrainz_id
}
}
}
return play;
}
@@ -17,6 +17,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
isKoito: boolean = false;
declare config: ListenBrainzClientConfig;
@@ -44,6 +45,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
protected async doCheckConnection(): Promise<true | string | undefined> {
await this.api.testConnection();
this.isKoito = this.api.isKoito;
return true;
}
+2 -2
View File
@@ -23,9 +23,9 @@ export const setupPlexRoutes = (app: ExpressWithAsync, logger: Logger, scrobbleS
if (payload !== undefined) {
const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true});
const pSources = scrobbleSources.getByType('plex') as PlexSource[];
const pSources = scrobbleSources.getByType('plex').filter(x => x instanceof PlexSource) as PlexSource[];
if (pSources.length === 0) {
plexLog.warn('Received valid Plex webhook payload but no Plex sources are configured');
plexLog.warn('Received valid Plex webhook payload but no Plex Webhook sources are configured');
}
for (const source of pSources) {
+25 -2
View File
@@ -46,7 +46,7 @@ import EventEmitter from "events";
import { nanoid } from "nanoid";
import pEvent from "p-event";
import { Simulate } from "react-dom/test-utils";
import { PlayObject } from "../../core/Atomic.js";
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
FormatPlayObjectOptions,
@@ -396,9 +396,28 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
RunTimeTicks,
Type, // should be BaseItemKind.Audio
UserData,
ProviderIds = {}
} = obj;
return {
const meta: BrainzMeta = {};
if(ProviderIds.MusicBrainzAlbum !== undefined) {
meta.album = ProviderIds.MusicBrainzAlbum;
}
if(ProviderIds.MusicBrainzTrack !== undefined) {
meta.track = ProviderIds.MusicBrainzTrack;
}
if(ProviderIds.MusicBrainzTrack !== undefined) {
meta.track = ProviderIds.MusicBrainzTrack;
}
if(ProviderIds.MusicBrainzArtist !== undefined) {
meta.artist = [ProviderIds.MusicBrainzArtist];
}
if(ProviderIds.MusicBrainzAlbumArtist !== undefined) {
meta.albumArtist = ProviderIds.MusicBrainzAlbumArtist;
}
const play: PlayObject = {
data: {
artists: Artists,
album: Album,
@@ -414,6 +433,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
source: 'Jellyfin',
}
}
if(Object.keys(meta).length > 0) {
play.data.meta = { brainz: meta };
}
return play;
}
getRecentlyPlayed = async (options = {}) => {
+9 -1
View File
@@ -7,12 +7,14 @@ import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source
import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { isPortReachableConnect } from "../utils/NetworkUtils.js";
export default class ListenbrainzSource extends MemorySource {
api: ListenbrainzApiClient;
requiresAuth = true;
requiresAuthInteraction = false;
isKoito: boolean = false;
declare config: ListenBrainzSourceConfig;
@@ -40,7 +42,10 @@ export default class ListenbrainzSource extends MemorySource {
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
await request.get(this.api.url);
await isPortReachableConnect(this.api.url.port, {host: this.api.url.url.hostname});
const isKoito = await this.api.checkKoito();
this.api.isKoito = isKoito;
this.isKoito = isKoito;
return true;
} catch (e) {
if(isNodeNetworkException(e)) {
@@ -67,6 +72,9 @@ export default class ListenbrainzSource extends MemorySource {
getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => {
const {limit = 20} = options;
if(this.isKoito) {
return await this.api.getRecentlyPlayedKoito(limit);
}
const now = await this.api.getPlayingNow();
this.processRecentPlays(now.listens.map(x => ListenbrainzSource.formatPlayObj(x)));
return await this.api.getRecentlyPlayed(limit);
@@ -2,7 +2,12 @@ import { childLogger, Logger } from "@foxxmd/logging";
import dayjs, { Dayjs } from "dayjs";
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
const RT_TICK = 500;
export const RT_TICK_DEFAULT = 500;
let RT_TICK = 500;
export const setRtTick = (tick: number) => {
RT_TICK = tick;
}
export abstract class RealtimePlayer {
@@ -13,7 +18,7 @@ export abstract class RealtimePlayer {
private clockTS: Dayjs = dayjs();
protected constructor(/* logger: Logger */) {
//this.logger = childLogger(logger, `RT`);
this.position = 0;
const job = new SimpleIntervalJob({
milliseconds: RT_TICK,
runImmediately: true
@@ -27,7 +32,6 @@ export abstract class RealtimePlayer {
}), { id: 'rt' });
this.scheduler.addSimpleIntervalJob(job);
this.scheduler.stop();
this.position = 0;
}
public play(position?: number) {
@@ -52,14 +56,18 @@ export abstract class RealtimePlayer {
}
public getPosition(asSeconds: boolean = false) {
if(this.position === 0 || this.position === undefined) {
return 0;
}
return !asSeconds ? this.position : this.position / 1000;
}
public setPosition(time?: number) {
if(time === undefined) {
this.position += Math.abs(dayjs().diff(this.clockTS, 'ms'));
} else {
this.position = time;
}
this.position = time;
this.clockTS = dayjs();
}
}
+2 -1
View File
@@ -92,7 +92,8 @@ describe('Sample Configs', function () {
await copyFile(samplePath(componentType), `${componentType}.json`);
const clients = new ScrobbleClients(emitter, new EventEmitter, new URL('http://example.com'), process.cwd(), loggerTest);
await clients.buildClientsFromConfig(new Notifiers(new EventEmitter, new EventEmitter, new EventEmitter, loggerTest));
expect(clients.clients).length(1);
const expectedCount = componentType === 'listenbrainz' ? 2 : 1;
expect(clients.clients).length(expectedCount);
});
}
});
+1 -2
View File
@@ -9,8 +9,7 @@ export class TestSource extends AbstractSource {
}
}
export class TestMemorySource extends MemoryPositionalSource {
export class TestMemorySource extends MemorySource {
}
export class TestMemoryPositionalSource extends MemoryPositionalSource {
+41 -20
View File
@@ -17,6 +17,8 @@ import { SourceConfig } from "../../common/infrastructure/config/source/sources.
import MemorySource from "../../sources/MemorySource.js";
import { timePassesScrobbleThreshold } from "../../utils/TimeUtils.js";
import { DEFAULT_SCROBBLE_DURATION_THRESHOLD, DEFAULT_SCROBBLE_PERCENT_THRESHOLD } from "../../common/infrastructure/Atomic.js";
import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js";
import { sleep } from "../../utils.js";
chai.use(asPromised);
@@ -186,9 +188,13 @@ describe('Player Cleanup', function () {
this.afterEach(() => {
MockDate.reset();
setRtTick(RT_TICK_DEFAULT);
});
this.beforeEach(() => {
setRtTick(1);
});
const cleanedUpDuration = (generateSource: (config: SourceConfig) => MemorySource) => {
const cleanedUpDuration = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, timestamp: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
@@ -202,6 +208,7 @@ describe('Player Cleanup', function () {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
@@ -210,25 +217,27 @@ describe('Player Cleanup', function () {
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
await sleep(1);
const discoveredPlays = source.processRecentPlays([]);
// cleanup should discover stale play
expect(discoveredPlays.length).to.be.eq(1);
expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
}
it('Discovers cleaned up Play with correct duration (Non Positional Source)', function () {
cleanedUpDuration(generateMemorySource);
it('Discovers cleaned up Play with correct duration (Non Positional Source)', async function () {
await cleanedUpDuration(generateMemorySource);
});
it('Discovers cleaned up Play with correct duration (Positional Source)', function () {
cleanedUpDuration(generateMemoryPositionalSource);
it('Discovers cleaned up Play with correct duration (Positional Source)', async function () {
await cleanedUpDuration(generateMemoryPositionalSource);
});
const noScrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
const noScrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
@@ -243,6 +252,7 @@ describe('Player Cleanup', function () {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
@@ -251,12 +261,14 @@ describe('Player Cleanup', function () {
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
timeSince += 2;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
const discoveredPlays = source.processRecentPlays([]);
// cleanup should discover stale play
expect(discoveredPlays.length).to.be.eq(1);
@@ -269,12 +281,14 @@ describe('Player Cleanup', function () {
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
// new Play
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
// should not return play because it has only been played for ~20 seconds, less than 50% of duration
@@ -283,15 +297,15 @@ describe('Player Cleanup', function () {
}
it('Does not discover same Play after becoming active again (Non Positional Source)', function () {
noScrobbleRediscoveryOnActive(generateMemorySource);
it('Does not discover same Play after becoming active again (Non Positional Source)', async function () {
await noScrobbleRediscoveryOnActive(generateMemorySource);
});
it('Does not discover same Play after becoming active again (Positional Source)', function () {
noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
it('Does not discover same Play after becoming active again (Positional Source)', async function () {
await noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
});
const noScrobbleStale = (generateSource: (config: SourceConfig) => MemorySource) => {
const noScrobbleStale = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
@@ -308,6 +322,7 @@ describe('Player Cleanup', function () {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
@@ -316,6 +331,7 @@ describe('Player Cleanup', function () {
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
@@ -326,15 +342,15 @@ describe('Player Cleanup', function () {
}
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', function () {
noScrobbleStale(generateMemorySource);
it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', async function () {
await noScrobbleStale(generateMemorySource);
});
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', function () {
noScrobbleStale(generateMemoryPositionalSource);
it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', async function () {
await noScrobbleStale(generateMemoryPositionalSource);
});
const scrobbleRediscoveryOnActive = (generateSource: (config: SourceConfig) => MemorySource) => {
const scrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => MemorySource) => {
const source = generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
const initialDate = dayjs();
@@ -351,6 +367,7 @@ describe('Player Cleanup', function () {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
@@ -359,12 +376,14 @@ describe('Player Cleanup', function () {
for(let i = 0; i < 2; i++) {
timeSince += 10;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
expect(source.processRecentPlays([]).length).to.be.eq(0);
}
timeSince += 2;
MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
await sleep(1);
const discoveredPlays = source.processRecentPlays([]);
// cleanup should not discover stale play
expect(discoveredPlays.length).to.be.eq(0);
@@ -378,12 +397,14 @@ describe('Player Cleanup', function () {
position += 10;
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
const advancedState = generatePlayerStateData({play: initialState.play, timestamp: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
expect(source.processRecentPlays([advancedState]).length).to.be.eq(0);
}
timeSince += 10;
MockDate.set(initialDate.add(position, 'seconds').toDate());
await sleep(1);
// new Play
const advancedState = generatePlayerStateData({timestamp: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
// should return discovered play with ~90 seconds of duration
@@ -393,12 +414,12 @@ describe('Player Cleanup', function () {
}
it('Does discover Play after becoming active again (Non Positional Source)', function () {
scrobbleRediscoveryOnActive(generateMemorySource);
it('Does discover Play after becoming active again (Non Positional Source)', async function () {
await scrobbleRediscoveryOnActive(generateMemorySource);
});
it('Does discover Play after becoming active again (Positional Source)', function () {
scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
it('Does discover Play after becoming active again (Positional Source)', async function () {
await scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
});
});
+3
View File
@@ -195,6 +195,9 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => {
finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
return finalUrl;
}
export const getBaseFromUrl = (url: URL): URL => new URL(`${url.protocol}//${url.host}`);
export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => {
const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
let v4: string,
+3 -3
View File
@@ -41,7 +41,7 @@ art = {},
} = {}
} = {},
play,
listenedDuration,
listenedDuration = 0,
status: {
calculated = '???',
reported,
@@ -52,7 +52,7 @@ art = {},
let durPer = null;
if(duration !== undefined && duration !== null && duration !== 0) {
if(listenedDuration === 0) {
if(listenedDuration === 0 || listenedDuration === null) {
durPer = ' (0%)';
} else {
durPer = ` (${((listenedDuration/duration) * 100).toFixed(0)}%)`;
@@ -97,7 +97,7 @@ art = {},
<PlayerTimestamp duration={duration} indeterminate={calculated === 'playing' && data.position === undefined} current={data.position || 0} />
<div className="flex">
<p className="stats flex-1 text-left">Status: {capitalize(calculated)}</p>
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' && listenedDuration !== null ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
</div>
</section>
<PlayerInfo data={data} isVisible={viewMode === 'playlist'} />