Compare commits

...
Author SHA1 Message Date
FoxxMD ae7a436758 chore: Bump version for release 2025-10-30 13:18:49 +00:00
FoxxMD 92d246b45a docs: Add rocksky to repo readme 2025-10-30 13:18:36 +00:00
Matt Foxx 0af480ac1a Merge pull request #377 from FoxxMD/GH-376/plexUrlParseFix
fix(plex): Use better url parsing method
2025-10-30 08:47:48 -04:00
Matt Foxx 9f52c2ec67 Merge pull request #369 from gabeklavans/fix/apprise-tags
fix: parse and use the tags in Apprise config
2025-10-29 11:33:05 -04:00
FoxxMD cfb278087e docs: Update listenbrainz json example to include url 2025-10-29 14:50:21 +00:00
FoxxMD 402b0d2cc7 docs: Add Rockysky setup instructions 2025-10-29 14:47:59 +00:00
FoxxMD 67c79f2eb7 fix(plex): Use better url parsing method
Fixes #376
2025-10-29 13:48:47 +00:00
FoxxMD d759b00c7e test: Add more tests for url parsing 2025-10-29 13:43:34 +00:00
Matt Foxx cac8163e58 Merge pull request #374 from wolffshots/feature/optionally-redirect-image-paths
feat(JellyfinApiSource): add option to override the Jellyfin base url
2025-10-24 09:16:21 -04:00
wolffshots 11dbdaf6cc chore(JellyfinApiSource): extract URL replacement to func and add tests 2025-10-23 17:53:51 +02:00
wolffshots ac6b04f492 feat(JellyfinApiSource): add option to override the Jellyfin base url used in the frontend 2025-10-23 17:22:57 +02:00
Matt Foxx 0790da2e0d feat(plex): Enable accepting self-signed certs #372 (#373)
* feat(plex): Enable accepting self-signed certs #372
* docs(plex): Add invalidcert param to plex example config
2025-10-21 09:10:38 -04:00
Matt Foxx dffb1c79ec Merge pull request #370 from h-wb/feat/add-source-as-lb-ms-name
feat(listenbrainz): add source as music service name
2025-10-08 08:27:01 -04:00
h-wb 46cc9f0b6e feat(listenbrainz): add source as music service name 2025-10-07 23:01:59 -04:00
gabe 140e9f0b20 fix(notification): parse and use the tags in Apprise config 2025-09-26 14:51:04 -04:00
FoxxMD 0fe5779c4b chore: Bump for release 2025-09-23 15:48:46 +00:00
FoxxMD 06b1475b63 chore: remove unused object-hash package 2025-09-23 15:48:21 +00:00
Matt Foxx 6bc415120d Merge pull request #359 from FoxxMD/objectHashReplace
fix: Update object hashing function
2025-09-23 11:43:13 -04:00
FoxxMD 7cee9f04cf fix: Update object hashing function
Might be a solution for #358
2025-09-15 17:37:30 +00:00
21 changed files with 424 additions and 139 deletions
+1
View File
@@ -41,6 +41,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)
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz)
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
* [Rocksky](https://foxxmd.github.io/multi-scrobbler/docs/configuration#rocksky)
* 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
+4 -2
View File
@@ -5,7 +5,8 @@
"configureAs": "client",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
"username": "FoxxMD",
"url": "https://api.listenbrainz.org"
}
},
{
@@ -14,7 +15,8 @@
"configureAs": "source",
"data": {
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
"username": "FoxxMD"
"username": "FoxxMD",
"url": "https://api.listenbrainz.org"
}
}
]
+2 -1
View File
@@ -15,7 +15,8 @@
},
"options": {
"logPayload": true,
"logFilterFailure": "debug"
"logFilterFailure": "debug",
"ignoreInvalidCert": "false"
}
}
]
@@ -2881,6 +2881,25 @@ Finally, add the Maloja server URL and API Key to the configuration type you cho
</TabItem>
</Tabs>
### [Rocksky](https://docs.rocksky.app/)
Rocksky is music tracking and discovery platform built on the AT Protocol (Bluesky). It is compatible with the Listenbrainz API and so can be as a multi-scrobbler [Listenbrainz Client](#listenbrainz) using a custom URL.
You will need a [Bluesky](https://bsky.app/) account to use Rockysky.
:::note
Usage is adapted from [the Listenbrainz to Rocksky docs](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) on [docs.rockysky.app](https://docs.rocksky.app)
:::
While logged in to Bluesky, navigate to [RockySky API Application](https://rocksky.app/apikeys) and obtain an API Key.
Then, follow the directions to create a normal multi-scrobbler [Listenbrainz Client](#listenbrainz) and use:
* **Listenbrainz Token** (`LZ_TOKEN`) - The API Key from RockySky
* **Listenbrainz URL** (`LZ_URL`) - `https://audioscrobbler.rocksky.app`
## Now Playing
Multi-scrobbler can report the currently **playing** tracks it is monitoring to some Scrobble Clients via their individual **Now Playing** functionality.
+1
View File
@@ -42,6 +42,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
* [Last.fm](docs/configuration#lastfm)
* [ListenBrainz](docs/configuration#listenbrainz)
* [Maloja](docs/configuration#maloja)
* [Rockysky](docs/configuration#rockysky)
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint)
* Supports [Now Playing](docs/configuration#now-playing) for scrobble Clients
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
+13 -3
View File
@@ -1,12 +1,12 @@
{
"name": "multi-scrobbler",
"version": "0.9.11",
"version": "0.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-scrobbler",
"version": "0.9.11",
"version": "0.10.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -68,9 +68,9 @@
"mopidy": "^1.3.0",
"mpd-api": "^1.1.2",
"nanoid": "^3.3.1",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.7.6",
"object-hash": "^3.0.0",
"p-event": "^4.2.0",
"passport": "^0.6.0",
"passport-deezer": "^0.2.0",
@@ -8052,6 +8052,16 @@
}
}
},
"node_modules/node-object-hash": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-object-hash/-/node-object-hash-3.1.1.tgz",
"integrity": "sha512-A32kRGjXtwQ+uSa3GrXiCl8HVFY0Jy6IiKFO7UjagAKSaOOrruxB2Qf/w7TP5QtNfB3uOiHTu3cjhp8k/C0PCg==",
"license": "MIT",
"engines": {
"node": ">=16",
"pnpm": ">=8"
}
},
"node_modules/node-preload": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "multi-scrobbler",
"version": "0.9.11",
"version": "0.10.1",
"type": "module",
"description": "scrobble plays from multiple sources to multiple clients",
"scripts": {
@@ -100,9 +100,9 @@
"mopidy": "^1.3.0",
"mpd-api": "^1.1.2",
"nanoid": "^3.3.1",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.7.6",
"object-hash": "^3.0.0",
"p-event": "^4.2.0",
"passport": "^0.6.0",
"passport-deezer": "^0.2.0",
@@ -87,6 +87,13 @@ export interface JellyApiData extends CommonSourceData {
* @default false
*/
allowUnknown?: boolean
/**
* HOST:PORT of the Jellyfin server that your browser will be able to access from the frontend (and thus load images and links from)
* If unspecified it will use the normal server HOST and PORT from the `url`
* Necessary if you are using a reverse proxy or other network configuration that prevents the frontend from accessing the server directly
* */
frontendUrlOverride?: string
}
export interface JellyApiOptions extends CommonSourceOptions {
@@ -74,6 +74,16 @@ export interface PlexApiData extends CommonSourceData, PollingOptions {
}
export interface PlexApiOptions extends CommonSourceOptions {
/**
* Ignore invalid cert errors when connecting to Plex
*
* Useful for Plex servers using "Required" Secure Connections with self-signed certificates
*
* Do not enable unless you know you need this.
*
* @default false
*/
ignoreInvalidCert?: boolean
}
export interface PlexApiSourceConfig extends CommonSourceConfig {
+2
View File
@@ -696,6 +696,7 @@ export const playToListenPayload = (play: PlayObject): ListenPayload => {
mediaPlayerName,
mediaPlayerVersion,
musicService,
source
}
} = play;
// using submit-listens exmaple from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens
@@ -714,6 +715,7 @@ export const playToListenPayload = (play: PlayObject): ListenPayload => {
media_player: mediaPlayerName ?? msAdditionalInfo.media_player,
media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version,
music_service: musicService !== undefined ? musicServiceToCononical(musicService) : msAdditionalInfo.music_service,
music_service_name: source,
spotify_id: spotify.track ?? msAdditionalInfo.spotify_id,
spotify_album_id: spotify.album ?? msAdditionalInfo.spotify_album_id,
spotify_artist_ids: spotify.artist ?? msAdditionalInfo.spotify_artist_ids
@@ -1,8 +1,8 @@
import { Media, MediaController, Result } from "@foxxmd/chromecast-client";
import objectHash from "object-hash";
import { PlayObject } from "../../../../core/Atomic.js";
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../infrastructure/Atomic.js";
import { PlatformApplication, PlatformType } from "./interfaces.js";
import { hashObject } from "../../../utils/StringUtils.js";
export const chromePlayerStateToReported = (state: string): ReportedPlayerStatus => {
switch (state) {
@@ -71,5 +71,5 @@ export const genPlayHash = (play: PlayObject) => {
}
} = play;
return objectHash.sha1({artists, track, album, albumArtists, mediaType});
return hashObject({artists, track, album, albumArtists, mediaType});
}
@@ -27,12 +27,14 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
urls: string[];
keys: string[];
tags: string[];
constructor(defaultName: string, config: AppriseConfig, logger: Logger) {
super('Apprise', defaultName, config, logger);
const {
urls = [],
keys = [],
tags = [],
host,
} = this.config;
if (host === undefined) {
@@ -40,6 +42,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
}
this.urls = Array.isArray(urls) ? urls : [urls];
this.keys = Array.isArray(keys) ? keys : [keys];
this.tags = Array.isArray(tags) ? tags : [tags];
if (this.urls.length === 0 && this.keys.length === 0) {
this.logger.warn(`No 'urls' or 'keys' were defined! Will assume stateless (POST ${host}/notify) and that you have the ENV 'APPRISE_STATELESS_URLS' set on your Apprise instance`);
@@ -85,6 +88,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
const body: Record<string, any> = {
title: payload.title,
body: payload.message,
tags: this.tags.join(','), // a comma 'OR's the tags https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-stateful-storage-solution
type: convertPriorityToType(payload.priority)
}
+16 -5
View File
@@ -1,6 +1,5 @@
import { Logger } from "@foxxmd/logging";
import { WS } from "iso-websocket";
import objectHash from 'object-hash';
// @ts-expect-error weird typings?
import { Api, Jellyfin } from "@jellyfin/sdk";
import {
@@ -58,7 +57,7 @@ import {
import { JellyApiSourceConfig } from "../common/infrastructure/config/source/jellyfin.js";
import { genGroupIdStr, getPlatformIdFromData, isDebugMode, parseBool, } from "../utils.js";
import { joinedUrl } from "../utils/NetworkUtils.js";
import { parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
import { FixedSizeList } from "fixed-size-list";
@@ -98,7 +97,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
this.canPoll = true;
this.multiPlatform = true;
this.requiresAuth = true;
this.deviceId = `${name}-ms${internal.version}-${truncateStringToLength(10, '')(objectHash.sha1(config))}`;
this.deviceId = `${name}-ms${internal.version}-${truncateStringToLength(10, '')(hashObject(config))}`;
this.client = new Jellyfin({
clientInfo: {
@@ -365,6 +364,18 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
return true;
}
replaceUrlIfNeeded = (url: string): string => {
if(
this.config.data.frontendUrlOverride !== undefined &&
this.config.data.frontendUrlOverride.length > 0 &&
url !== undefined &&
url.length > 0
) {
return url.replace(this.config.data.url, this.config.data.frontendUrlOverride);
}
return url;
}
formatPlayObjAware(obj: BaseItemDto, options: FormatPlayObjectOptions = {}): PlayObject {
const play = JellyfinApiSource.formatPlayObj(obj, options);
@@ -378,7 +389,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
if(AlbumId !== undefined && AlbumPrimaryImageTag !== undefined) {
const existingArt = play.meta?.art || {};
existingArt.album = this.imageApi.getItemImageUrlById(AlbumId, undefined, {maxHeight: 500});
existingArt.album = this.replaceUrlIfNeeded(this.imageApi.getItemImageUrlById(AlbumId, undefined, {maxHeight: 500}));
play.meta.art = existingArt;
}
if(ParentId !== undefined) {
@@ -387,7 +398,7 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
u.searchParams.append('serviceId', ServerId);
play.meta.url = {
...(play.meta?.url || {}),
web: u.toString().replace('%23', '#')
web: this.replaceUrlIfNeeded(u.toString().replace('%23', '#'))
}
}
+3 -3
View File
@@ -1,7 +1,6 @@
import { Logger } from "@foxxmd/logging";
import dayjs, { Dayjs } from "dayjs";
import { EventEmitter } from "events";
import objectHash from 'object-hash';
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../core/Atomic.js";
import { buildTrackString } from "../../core/StringUtils.js";
@@ -28,6 +27,7 @@ import { timePassesScrobbleThreshold, timeToHumanTimestamp } from "../utils/Time
import AbstractSource from "./AbstractSource.js";
import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js";
import { hashObject } from "../utils/StringUtils.js";
const EXPECTED_NON_DISCOVERED_REASON = 'not added because an identical play with the same timestamp was already discovered.';
@@ -86,7 +86,7 @@ export default class MemorySource extends AbstractSource {
} else if (isStale) {
label = 'Stale Player Cleanup';
const state = player.getApiState();
const stateHash = objectHash.sha1(state);
const stateHash = hashObject(state);
if(stateHash !== this.playerState.get(key)) {
this.playerState.set(key, stateHash);
this.emitEvent('playerUpdate', {
@@ -265,7 +265,7 @@ export default class MemorySource extends AbstractSource {
player.logSummary();
}
const apiState = player.getApiState();
this.playerState.set(key, objectHash.sha1(apiState))
this.playerState.set(key, hashObject(apiState))
this.emitEvent('playerUpdate', {
...apiState,
options: {
+39 -14
View File
@@ -1,6 +1,5 @@
import objectHash from 'object-hash';
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, URLData } from "../../core/Atomic.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
asPlayerStateDataMaybePlay,
@@ -12,12 +11,12 @@ import {
PlayPlatformId, REPORTED_PLAYER_STATUSES
} from "../common/infrastructure/Atomic.js";
import { genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, isDebugMode, parseBool, } from "../utils.js";
import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js";
import { PlexAPI } from "@lukehagar/plexjs";
import { PlexAPI, HTTPClient, Fetcher } from "@lukehagar/plexjs";
import { Agent } from 'undici';
import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js";
import { isPortReachable, joinedUrl } from '../utils/NetworkUtils.js';
import normalizeUrl from 'normalize-url';
import { isPortReachable, joinedUrl, normalizeWebAddress } from '../utils/NetworkUtils.js';
import { GetTokenDetailsResponse, GetTokenDetailsUserPlexAccount } from '@lukehagar/plexjs/sdk/models/operations/gettokendetails.js';
import { parseRegexSingle } from '@foxxmd/regex-buddy-core';
import { Readable } from 'node:stream';
@@ -42,7 +41,7 @@ export default class PlexApiSource extends MemoryPositionalSource {
deviceId: string;
address: URL;
address: URLData;
usersAllow: string[] = [];
usersBlock: string[] = [];
@@ -66,7 +65,7 @@ export default class PlexApiSource extends MemoryPositionalSource {
this.multiPlatform = true;
this.requiresAuth = true;
this.requiresAuthInteraction = false;
this.deviceId = `${name}-ms${internal.version}-${truncateStringToLength(10, '')(objectHash.sha1(config))}`;
this.deviceId = `${name}-ms${internal.version}-${truncateStringToLength(10, '')(hashObject(config))}`;
this.uniqueDropReasons = new FixedSizeList<string>(100);
this.mediaIdsSeen = new FixedSizeList<string>(100);
}
@@ -81,10 +80,11 @@ export default class PlexApiSource extends MemoryPositionalSource {
devicesAllow = [],
devicesBlock = [],
librariesAllow = [],
librariesBlock = [],
librariesBlock = []
} = {},
options: {
logFilterFailure = (isDebugMode() ? 'debug' : 'warn'),
ignoreInvalidCert = false
} = {}
} = this.config;
@@ -120,13 +120,38 @@ export default class PlexApiSource extends MemoryPositionalSource {
this.logger.warn(`When both 'librariesAllow' and 'librariesBlock' are specified only 'librariesAllow' is used.`);
}
const normal = normalizeUrl(this.config.data.url, {removeSingleSlash: true});
this.address = new URL(normal);
this.address = normalizeWebAddress(this.config.data.url);
this.logger.debug(`Config URL: ${this.config.data.url} | Normalized: ${this.address.toString()}`);
let httpClient: HTTPClient | undefined;
if(ignoreInvalidCert) {
this.logger.debug('Using http client that ignores self-signed certs');
// https://github.com/nodejs/undici/issues/1489#issuecomment-1543856261
const bypassAgent = new Agent({
connect: {
rejectUnauthorized: false,
},
});
const bypassFetcher: Fetcher = (input, init) => {
if (init == null) {
// @ts-ignore
return fetch(input, {dispatcher: bypassAgent});
} else {
// @ts-ignore
return fetch(input, {...init, dispatcher: bypassAgent});
}
};
httpClient = new HTTPClient({ fetcher: bypassFetcher });
}
this.plexApi = new PlexAPI({
serverURL: this.address.toString(),
accessToken: this.config.data.token
serverURL: this.address.url.toString(),
accessToken: this.config.data.token,
httpClient
});
return true;
@@ -134,7 +159,7 @@ export default class PlexApiSource extends MemoryPositionalSource {
protected async doCheckConnection(): Promise<true | string | undefined> {
try {
const reachable = await isPortReachable(parseInt(this.address.port ?? '80'), {host: this.address.hostname});
const reachable = await isPortReachable(this.address.port, {host: this.address.url.hostname});
if(!reachable) {
throw new Error(`Could not reach server at ${this.address}}`);
}
+116
View File
@@ -15,6 +15,8 @@ import {
// @ts-expect-error weird typings?
SessionInfo,
} from "@jellyfin/sdk/lib/generated-client/index.js";
// @ts-expect-error weird typings?
import { getImageApi } from "@jellyfin/sdk/lib/utils/api/index.js";
import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js";
const dataAsFixture = (data: any): TestFixture => {
@@ -132,6 +134,120 @@ describe("Jellyfin API Source", function() {
});
});
describe('Parses and replaces frontendUrlOverride correctly if set', function () {
const item = {
AlbumId: 123,
AlbumPrimaryImageTag: 'Primary',
ParentId: 456,
ServerId: 789,
};
const sourceUrl = 'http://192.168.10.11:8096';
const frontendUrlOverride = 'https://myjellyfin.com';
it('Should use default url if frontendUrlOverride unset', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl});
await jf.buildInitData();
jf.address = sourceUrl;
jf.api = jf.client.createApi(jf.address);
jf.imageApi = getImageApi(jf.api);
expect(jf.formatPlayObjAware(item).meta.art.album).to.be.eql(`${sourceUrl}/Items/123/Images/Primary?maxHeight=500`);
expect(jf.formatPlayObjAware(item).meta.url.web).to.be.eql(`${sourceUrl}/web/#/details?id=456&serviceId=789`);
await jf.destroy();
});
it('Should parse and replace frontendUrlOverride correctly', async function () {
const jf = createJfApi({...defaultJfApiCreds, frontendUrlOverride: frontendUrlOverride, url: sourceUrl});
await jf.buildInitData();
jf.address = sourceUrl;
jf.api = jf.client.createApi(jf.address);
jf.imageApi = getImageApi(jf.api);
expect(jf.formatPlayObjAware(item).meta.art.album).to.be.eql(`${frontendUrlOverride}/Items/123/Images/Primary?maxHeight=500`);
expect(jf.formatPlayObjAware(item).meta.url.web).to.be.eql(`${frontendUrlOverride}/web/#/details?id=456&serviceId=789`);
await jf.destroy();
});
});
describe('Correctly replaces URLs with frontendUrlOverride', function () {
const sourceUrl = 'http://192.168.10.11:8096';
const frontendUrlOverride = 'https://myjellyfin.com';
it('Should return original URL when frontendUrlOverride is not set', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl});
await jf.buildInitData();
const testUrl = `${sourceUrl}/Items/123/Images/Primary`;
expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(testUrl);
await jf.destroy();
});
it('Should return original URL when frontendUrlOverride is empty string', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: ''});
await jf.buildInitData();
const testUrl = `${sourceUrl}/Items/123/Images/Primary`;
expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(testUrl);
await jf.destroy();
});
it('Should replace source URL with frontendUrlOverride when set', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride});
await jf.buildInitData();
const testUrl = `${sourceUrl}/Items/123/Images/Primary`;
const expectedUrl = `${frontendUrlOverride}/Items/123/Images/Primary`;
expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(expectedUrl);
await jf.destroy();
});
it('Should return original URL when input URL is undefined', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride});
await jf.buildInitData();
expect(jf.replaceUrlIfNeeded(undefined)).to.be.undefined;
await jf.destroy();
});
it('Should return original URL when input URL is empty string', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride});
await jf.buildInitData();
expect(jf.replaceUrlIfNeeded('')).to.be.eql('');
await jf.destroy();
});
it('Should not replace URL when source URL is not present', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride});
await jf.buildInitData();
const testUrl = 'https://some-other-domain.com/Items/123/Images/Primary';
expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(testUrl);
await jf.destroy();
});
it('Should not replace multiple occurrences of source URL', async function () {
const jf = createJfApi({...defaultJfApiCreds, url: sourceUrl, frontendUrlOverride: frontendUrlOverride});
await jf.buildInitData();
const testUrl = `${sourceUrl}/redirect?url=${sourceUrl}/Items/123`;
const expectedUrl = `${frontendUrlOverride}/redirect?url=${sourceUrl}/Items/123`;
expect(jf.replaceUrlIfNeeded(testUrl)).to.be.eql(expectedUrl);
await jf.destroy();
});
});
describe('Correctly detects activity as valid/invalid', function() {
describe('Filters from Configuration', function() {
@@ -185,4 +185,13 @@ describe('Listenbrainz Endpoint Behavior', function() {
});
it('Should set music_service_name from source', function() {
const play = generatePlay({artists: ['Artist A'], albumArtists: []}, {source: 'Plex'});
const submitPayload = playToListenPayload(play);
expect(submitPayload.track_metadata.additional_info.music_service_name).to.be.eql('Plex')
});
});
@@ -0,0 +1,167 @@
import { assert, expect } from 'chai';
import { describe, it } from 'mocha';
import { generateBaseURL, joinedUrl, normalizeWebAddress } from "../../utils/NetworkUtils.js";
describe('URL Parsing', function () {
describe('Base URL', function () {
it('should return http://localhost:9078 if no url is specified', function () {
assert.equal(generateBaseURL(undefined, 9078).toString(), 'http://localhost:9078/');
});
it('should normalize URL without protocol to HTTP', function () {
assert.include(generateBaseURL('192.168.0.1', 9078).toString(), 'http://192.168.0.1');
assert.include(generateBaseURL('my.domain.local', 9078).toString(), 'http://my.domain.local');
});
it('should use 443 for port, instead of default port, if protocol is https and no port is specified', function () {
assert.include(generateBaseURL('https://192.168.0.1', 9078).toString(), 'https://192.168.0.1');
assert.include(generateBaseURL('https://my.domain.local', 9078).toString(), 'https://my.domain.local');
});
it('should preserve port if explicitly specified', function () {
assert.include(generateBaseURL('http://my.domain.local:80', 9078).toString(), 'http://my.domain.local');
assert.include(generateBaseURL('192.168.0.1:80', 9078).toString(), 'http://192.168.0.1');
assert.include(generateBaseURL('192.168.0.1:8000', 9078).toString(), 'http://192.168.0.1:8000');
assert.include(generateBaseURL('my.domain.local:9075', 9078).toString(), 'http://my.domain.local:9075');
assert.include(generateBaseURL('https://my.domain.local:9075', 9078).toString(), 'https://my.domain.local:9075');
});
it('should use default port if protocol is HTTP and port is not specified', function () {
assert.include(generateBaseURL('192.168.0.1', 9078).toString(), 'http://192.168.0.1:9078');
assert.include(generateBaseURL('http://my.domain.local', 9078).toString(), 'http://my.domain.local:9078');
});
it('should preserve pathname for subfolder usage', function () {
assert.include(generateBaseURL('192.168.0.1/my/subfolder', 9078).toString(), 'http://192.168.0.1:9078/my/subfolder');
assert.include(generateBaseURL('http://my.domain.local/my/subfolder', 9078).toString(), 'http://my.domain.local:9078/my/subfolder');
assert.include(generateBaseURL('http://my.domain.local:5000/my/subfolder', 9078).toString(), 'http://my.domain.local:5000/my/subfolder');
assert.include(generateBaseURL('https://my.domain.local/my/subfolder', 9078).toString(), 'https://my.domain.local/my/subfolder');
});
it('should should strip wrapping quotes', function () {
assert.equal(generateBaseURL(`"http://192.168.3.120:9078"`, 9078).toString(), 'http://192.168.3.120:9078/');
});
});
describe('URL Path Joining', function() {
it('should join a path to a base URL without erasing base pathname', function() {
const baseUrl = generateBaseURL('192.168.0.1/my/subfolder', 9078);
assert.equal(joinedUrl(baseUrl, 'lastfm/callback').toString(), 'http://192.168.0.1:9078/my/subfolder/lastfm/callback');
});
it('should join a path to a base URL while handling leading and trailing slashes', function() {
const baseUrl = generateBaseURL('192.168.0.1/my/subfolder', 9078);
assert.equal(joinedUrl(baseUrl, '/lastfm/callback').toString(), 'http://192.168.0.1:9078/my/subfolder/lastfm/callback');
assert.equal(joinedUrl(baseUrl, 'lastfm/callback/').toString(), 'http://192.168.0.1:9078/my/subfolder/lastfm/callback/');
const baseUrlNoSub = generateBaseURL('192.168.0.1', 9078);
assert.equal(joinedUrl(baseUrlNoSub, '/lastfm/callback').toString(), 'http://192.168.0.1:9078/lastfm/callback');
assert.equal(joinedUrl(baseUrlNoSub, 'lastfm/callback/').toString(), 'http://192.168.0.1:9078/lastfm/callback/');
});
});
describe('Normalizing', function() {
describe('IP Address', function () {
const anIP = '192.168.0.100';
it('Should unwrap a quoted value', function () {
expect(normalizeWebAddress(`"${anIP}"`).url.hostname).to.eq(anIP);
});
it('Should normalize an IP to HTTP protocol', function () {
expect(normalizeWebAddress(anIP).url.protocol).to.eq('http:');
});
it('Should normalize an IP without a port to port 80', function () {
expect(normalizeWebAddress(anIP).port).to.eq(80);
});
it('Should normalize an IP to an HTTP URL', function () {
expect(normalizeWebAddress(anIP).normal).to.eq(`http://${anIP}`);
});
it('Should normalize an IP with port 443 to an HTTPS URL', function () {
expect(normalizeWebAddress(`${anIP}:443`).url.protocol).to.eq(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).url.toString()).to.include(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).normal).to.include(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).port).to.eq(443);
});
it('Should not normalize an IP with port 443 if protocol is specified', function () {
expect(normalizeWebAddress(`http://${anIP}:443`).url.protocol).to.eq(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).url.toString()).to.include(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).normal).to.include(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).port).to.eq(443);
});
it('Should normalize an IP with a port and preserve port', function () {
expect(normalizeWebAddress(`${anIP}:5000`).port).to.eq(5000);
expect(normalizeWebAddress(`${anIP}:5000`).normal).to.eq(`http://${anIP}:5000`);
expect(normalizeWebAddress(`${anIP}:5000`).url.protocol).to.eq('http:');
expect(normalizeWebAddress(`${anIP}:5000`).url.port).to.eq('5000');
});
it('Should remove trailing slash', function () {
expect(normalizeWebAddress(`${anIP}:5000/`).normal).to.eq(`http://${anIP}:5000`);
});
});
describe('Domain', function () {
const domain = 'subdomain.mydomain.test';
describe('No Port', function () {
it('Should normalize an address without protocol to HTTP protocol', function () {
expect(normalizeWebAddress(domain).url.protocol).to.eq('http:');
});
it('Should normalize an address without protocol to port 80', function () {
expect(normalizeWebAddress(domain).port).to.eq(80);
});
it('Should normalize an address with http to HTTP protocol', function () {
expect(normalizeWebAddress(`http://${domain}`).url.protocol).to.eq('http:');
});
it('Should normalize an address with http to port 80', function () {
expect(normalizeWebAddress(`http://${domain}`).port).to.eq(80);
});
it('Should normalize an address with https to HTTPS protocol', function () {
expect(normalizeWebAddress(`https://${domain}`).url.protocol).to.eq('https:');
});
it('Should normalize an address with https to port 443', function () {
expect(normalizeWebAddress(`https://${domain}`).port).to.eq(443);
});
});
describe('With Port', function () {
it('Should normalize an address without protocol and with port to specified port and HTTP', function () {
expect(normalizeWebAddress(`${domain}:1068`).port).to.eq(1068);
expect(normalizeWebAddress(`${domain}:1068`).url.protocol).to.eq('http:');
});
it('Should normalize an address without protocol and with port 443 to specified port and HTTPS', function () {
expect(normalizeWebAddress(`${domain}:443`).port).to.eq(443);
expect(normalizeWebAddress(`${domain}:443`).url.protocol).to.eq('https:');
});
it('Should normalize an address with protocol and with port to specified port and protocol', function () {
expect(normalizeWebAddress(`https://${domain}:1055`).port).to.eq(1055);
expect(normalizeWebAddress(`https://${domain}:1055`).url.protocol).to.eq('https:');
});
});
});
});
});
-105
View File
@@ -1,7 +1,6 @@
import { assert, expect } from 'chai';
import { describe, it } from 'mocha';
import { intersect } from "../../utils.js";
import { generateBaseURL, joinedUrl, normalizeWebAddress } from "../../utils/NetworkUtils.js";
import {
compareNormalizedStrings,
normalizeStr,
@@ -146,111 +145,7 @@ describe('Play Strings',function () {
});
});
describe('URL Parsing', function () {
describe('Base URL', function () {
it('should return http://localhost:9078 if no url is specified', function () {
assert.equal(generateBaseURL(undefined, 9078).toString(), 'http://localhost:9078/');
});
it('should normalize URL without protocol to HTTP', function () {
assert.include(generateBaseURL('192.168.0.1', 9078).toString(), 'http://192.168.0.1');
assert.include(generateBaseURL('my.domain.local', 9078).toString(), 'http://my.domain.local');
});
it('should use 443 for port, instead of default port, if protocol is https and no port is specified', function () {
assert.include(generateBaseURL('https://192.168.0.1', 9078).toString(), 'https://192.168.0.1');
assert.include(generateBaseURL('https://my.domain.local', 9078).toString(), 'https://my.domain.local');
});
it('should preserve port if explicitly specified', function () {
assert.include(generateBaseURL('http://my.domain.local:80', 9078).toString(), 'http://my.domain.local');
assert.include(generateBaseURL('192.168.0.1:80', 9078).toString(), 'http://192.168.0.1');
assert.include(generateBaseURL('192.168.0.1:8000', 9078).toString(), 'http://192.168.0.1:8000');
assert.include(generateBaseURL('my.domain.local:9075', 9078).toString(), 'http://my.domain.local:9075');
assert.include(generateBaseURL('https://my.domain.local:9075', 9078).toString(), 'https://my.domain.local:9075');
});
it('should use default port if protocol is HTTP and port is not specified', function () {
assert.include(generateBaseURL('192.168.0.1', 9078).toString(), 'http://192.168.0.1:9078');
assert.include(generateBaseURL('http://my.domain.local', 9078).toString(), 'http://my.domain.local:9078');
});
it('should preserve pathname for subfolder usage', function () {
assert.include(generateBaseURL('192.168.0.1/my/subfolder', 9078).toString(), 'http://192.168.0.1:9078/my/subfolder');
assert.include(generateBaseURL('http://my.domain.local/my/subfolder', 9078).toString(), 'http://my.domain.local:9078/my/subfolder');
assert.include(generateBaseURL('http://my.domain.local:5000/my/subfolder', 9078).toString(), 'http://my.domain.local:5000/my/subfolder');
assert.include(generateBaseURL('https://my.domain.local/my/subfolder', 9078).toString(), 'https://my.domain.local/my/subfolder');
});
it('should should strip wrapping quotes', function () {
assert.equal(generateBaseURL(`"http://192.168.3.120:9078"`, 9078).toString(), 'http://192.168.3.120:9078/');
});
});
describe('URL Path Joining', function() {
it('should join a path to a base URL without erasing base pathname', function() {
const baseUrl = generateBaseURL('192.168.0.1/my/subfolder', 9078);
assert.equal(joinedUrl(baseUrl, 'lastfm/callback').toString(), 'http://192.168.0.1:9078/my/subfolder/lastfm/callback');
});
it('should join a path to a base URL while handling leading and trailing slashes', function() {
const baseUrl = generateBaseURL('192.168.0.1/my/subfolder', 9078);
assert.equal(joinedUrl(baseUrl, '/lastfm/callback').toString(), 'http://192.168.0.1:9078/my/subfolder/lastfm/callback');
assert.equal(joinedUrl(baseUrl, 'lastfm/callback/').toString(), 'http://192.168.0.1:9078/my/subfolder/lastfm/callback/');
const baseUrlNoSub = generateBaseURL('192.168.0.1', 9078);
assert.equal(joinedUrl(baseUrlNoSub, '/lastfm/callback').toString(), 'http://192.168.0.1:9078/lastfm/callback');
assert.equal(joinedUrl(baseUrlNoSub, 'lastfm/callback/').toString(), 'http://192.168.0.1:9078/lastfm/callback/');
});
});
describe('Normalizing', function() {
const anIP = '192.168.0.100';
it('Should unwrap a quoted value', function () {
expect(normalizeWebAddress(`"${anIP}"`).url.hostname).to.eq(anIP);
});
it('Should normalize an IP to HTTP protocol', function () {
expect(normalizeWebAddress(anIP).url.protocol).to.eq('http:');
});
it('Should normalize an IP without a port to port 80', function () {
expect(normalizeWebAddress(anIP).port).to.eq(80);
});
it('Should normalize an IP to an HTTP URL', function () {
expect(normalizeWebAddress(anIP).normal).to.eq(`http://${anIP}`);
});
it('Should normalize an IP with port 443 to an HTTPS URL', function () {
expect(normalizeWebAddress(`${anIP}:443`).url.protocol).to.eq(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).url.toString()).to.include(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).normal).to.include(`https:`);
expect(normalizeWebAddress(`${anIP}:443`).port).to.eq(443);
});
it('Should not normalize an IP with port 443 if protocol is specified', function () {
expect(normalizeWebAddress(`http://${anIP}:443`).url.protocol).to.eq(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).url.toString()).to.include(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).normal).to.include(`http:`);
expect(normalizeWebAddress(`http://${anIP}:443`).port).to.eq(443);
});
it('Should normalize an IP with a port and preserve port', function () {
expect(normalizeWebAddress(`${anIP}:5000`).port).to.eq(5000);
expect(normalizeWebAddress(`${anIP}:5000`).normal).to.eq(`http://${anIP}:5000`);
expect(normalizeWebAddress(`${anIP}:5000`).url.protocol).to.eq('http:');
expect(normalizeWebAddress(`${anIP}:5000`).url.port).to.eq('5000');
});
it('Should remove trailing slash', function () {
expect(normalizeWebAddress(`${anIP}:5000/`).normal).to.eq(`http://${anIP}:5000`);
});
});
});
describe('String Splitting', function() {
-1
View File
@@ -6,7 +6,6 @@ import isToday from 'dayjs/plugin/isToday.js';
import timezone from 'dayjs/plugin/timezone.js';
import utc from 'dayjs/plugin/utc.js';
import { AmbPlayObject, ListenRangeData, ListenRangeDataAmb, PlayObject, PlayProgress, PlayProgressAmb } from '../../core/Atomic.js';
import objectHash from 'object-hash';
dayjs.extend(utc)
dayjs.extend(isBetween);
+7 -1
View File
@@ -1,4 +1,5 @@
import { strategies, stringSameness, StringSamenessResult } from "@foxxmd/string-sameness";
import { hasher } from 'node-object-hash';
import { PlayObject } from "../../core/Atomic.js";
import { asPlayerStateData, DELIMITERS, PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.js";
import { genGroupIdStr, getPlatformIdFromData, intersect, parseRegexSingleOrFail } from "../utils.js";
@@ -419,4 +420,9 @@ export const normalizeListenbrainzUrl = (urlVal: string): string | undefined =>
return urlVal.replace(LZ_VERSION_PATH, '');
}
return undefined;
}
}
type HashFunction = (obj: object) => string;
const defaultHasher = hasher();
const defaultHashFunc: HashFunction = (obj) => defaultHasher.hash(obj);
export const hashObject = (obj: object, h: HashFunction = defaultHashFunc): string => h(obj);