mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7a436758 | ||
|
|
92d246b45a | ||
|
|
0af480ac1a | ||
|
|
9f52c2ec67 | ||
|
|
cfb278087e | ||
|
|
402b0d2cc7 | ||
|
|
67c79f2eb7 | ||
|
|
d759b00c7e | ||
|
|
cac8163e58 | ||
|
|
11dbdaf6cc | ||
|
|
ac6b04f492 | ||
|
|
0790da2e0d | ||
|
|
dffb1c79ec | ||
|
|
46cc9f0b6e | ||
|
|
140e9f0b20 | ||
|
|
0fe5779c4b | ||
|
|
06b1475b63 | ||
|
|
6bc415120d | ||
|
|
7cee9f04cf |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!)
|
||||
|
||||
Generated
+13
-3
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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', '#'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}}`);
|
||||
}
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user