mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fdd346c00 | ||
|
|
4f26b63ab1 | ||
|
|
0fed91302c | ||
|
|
831cefd9a7 | ||
|
|
114a3dda8b | ||
|
|
55ddd2c5a0 | ||
|
|
1d276186f8 | ||
|
|
fb3886b981 | ||
|
|
2bed7be654 | ||
|
|
5c460ad747 | ||
|
|
32220d7509 | ||
|
|
b0265001f8 | ||
|
|
9ede4634cb | ||
|
|
0e008ecd5c | ||
|
|
2ae82fdd9e | ||
|
|
3b430b95ed | ||
|
|
e907892c4a | ||
|
|
d9cd3764b9 | ||
|
|
9c2bf05a24 | ||
|
|
91e52c4c5d | ||
|
|
b41d7f539f | ||
|
|
9735b980d7 | ||
|
|
706483ed06 | ||
|
|
7760776a5f | ||
|
|
8b9a7893d3 | ||
|
|
729250a2cc | ||
|
|
f14203f85e | ||
|
|
60e880f7f2 | ||
|
|
16cef0ae65 | ||
|
|
12ff1f421e | ||
|
|
61c33da2b0 | ||
|
|
8b0c7205b7 | ||
|
|
b02c96ada8 | ||
|
|
52b20e4c8e | ||
|
|
a94ac8284f | ||
|
|
ee399136a9 | ||
|
|
ab54422acf | ||
|
|
d56c5720eb | ||
|
|
040de4b000 | ||
|
|
815a774e3d | ||
|
|
b2e7f58e14 | ||
|
|
8d124a20ac | ||
|
|
d1a4a54d2a | ||
|
|
049bb06762 | ||
|
|
e5dd473981 | ||
|
|
ca5cc3bb30 | ||
|
|
444d75c803 | ||
|
|
ccda3a6098 | ||
|
|
ce1c70a4e1 | ||
|
|
5fcfa979c5 | ||
|
|
ec4d609ba1 | ||
|
|
b42278a372 |
+1
-1
@@ -15,7 +15,7 @@ config/*.p8
|
||||
/docs
|
||||
/logs
|
||||
.flatpak-builder
|
||||
flatpak/generated-sources.json
|
||||
**/generated-sources.*
|
||||
flatpak/.flatpak-builder
|
||||
docsite/build
|
||||
docsite/node_modules
|
||||
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
env:
|
||||
ANALYTICS: ${{ vars.ANALYTICS }}
|
||||
ANALYTICS_DOMAIN: ${{ vars.ANALYTICS_DOMAIN }}
|
||||
DOCS_BASE: '/multi-scrobbler'
|
||||
run: npm run build
|
||||
working-directory: ./docsite
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Delete Untagged Packages
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
- cron: '30 1 * * 0'
|
||||
workflow_run:
|
||||
workflows: ["Publish Docker image to Dockerhub"]
|
||||
types:
|
||||
|
||||
@@ -10,8 +10,9 @@ on:
|
||||
- '*.*.*'
|
||||
# don't trigger if just updating docs
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'README.md'
|
||||
- '.github/**'
|
||||
- 'flatpak/**'
|
||||
# use release instead of tags once version is correctly parsed
|
||||
# https://github.com/docker/metadata-action/issues/422
|
||||
# https://github.com/docker/metadata-action/issues/240
|
||||
|
||||
@@ -132,3 +132,4 @@ flatpak/generated-sources.json
|
||||
|
||||
build
|
||||
!setupProxy.js
|
||||
**/generated-sources.*
|
||||
|
||||
+2
-1
@@ -84,7 +84,7 @@ COPY --chown=abc:abc . /app
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build && rm -rf node_modules
|
||||
RUN npm run docs:install && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base as app
|
||||
|
||||
@@ -92,6 +92,7 @@ COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
|
||||
COPY --from=base /usr/bin /usr/bin
|
||||
COPY --from=base /usr/lib /usr/lib
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
[](https://github.com/FoxxMD/multi-scrobbler/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
[](https://foxxmd.github.io/multi-scrobbler/)
|
||||
|
||||
|
||||
<img src="/assets/icon.png" align="right"
|
||||
alt="multi-scrobbler logo" width="180" height="180">
|
||||
@@ -10,32 +12,38 @@ alt="multi-scrobbler logo" width="180" height="180">
|
||||
A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org)
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](/docsite/docs/configuration/configuration.md#spotify)
|
||||
* [Plex](/docsite/docs/configuration/configuration.md#plex) or [Tautulli](/docsite/docs/configuration/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docsite/docs/configuration/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](/docsite/docs/configuration/configuration.md#jellyfin)
|
||||
* [WebScrobbler](/docsite/docs/configuration/configuration.md#webscrobbler)
|
||||
* [Youtube Music](/docsite/docs/configuration/configuration.md#youtube-music)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm-source)
|
||||
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz--source-)
|
||||
* [Deezer](/docsite/docs/configuration/configuration.md#deezer)
|
||||
* [MPRIS (Linux Desktop)](/docsite/docs/configuration/configuration.md#mpris)
|
||||
* [Mopidy](/docsite/docs/configuration/configuration.md#mopidy)
|
||||
* [JRiver](/docsite/docs/configuration/configuration.md#jriver)
|
||||
* [Kodi](/docsite/docs/configuration/configuration.md#kodi)
|
||||
* [Google Cast (Chromecast)](/docsite/docs/configuration/configuration.md#google-cast--chromecast-)
|
||||
* [Musikcube](/docsite/docs/configuration/configuration.md#muikcube)
|
||||
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [Tautulli](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
|
||||
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
|
||||
* [JRiver](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jriver)
|
||||
* [Kodi](https://foxxmd.github.io/multi-scrobbler/docs/configuration#kodi)
|
||||
* [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast)
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
|
||||
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](/docsite/docs/configuration/configuration.md#maloja)
|
||||
* [Last.fm](/docsite/docs/configuration/configuration.md#lastfm)
|
||||
* [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz)
|
||||
* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint)
|
||||
* [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)
|
||||
* 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
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Install using [Docker images for x86/ARM](/docsite/docs/installation/installation.md#docker), [flatpak](/docsite/docs/installation/installation.md#flatpak), or [locally with NodeJS](/docsite/docs/installation/installation.md#nodejs)
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/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/installationr#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
|
||||
<img src="/assets/status-ui.png" width="800">
|
||||
|
||||
**Why should I use this over a browser extension and/or mobile app scrobbler?**
|
||||
|
||||
@@ -46,9 +54,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](/docsite/docs/configuration/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docsite/docs/configuration/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
<img src="/assets/status-ui.png" width="800">
|
||||
Yes! You can use [Last.fm as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source) or [Listenbrainz as a **Source**](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
@@ -73,13 +79,17 @@ Client configurations consist of:
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Client.
|
||||
|
||||
## Quick Start
|
||||
|
||||
[See the **Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](/docsite/docs/installation/installation.md)
|
||||
[See the **Installation** documentation](https://foxxmd.github.io/multi-scrobbler/docs/installation)
|
||||
|
||||
## Configuration
|
||||
|
||||
[See the **Configuration** documentation](/docsite/docs/configuration/configuration.md)
|
||||
[See the **Configuration** documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -94,7 +104,11 @@ On first startup you may need to authorize Spotify and/or Last.fm by visiting th
|
||||
|
||||
## Help/FAQ
|
||||
|
||||
Having issues with connections or configuration? Check the [FAQ](/docsite/docs/FAQ.md) before creating an issue!
|
||||
Having issues with connections or configuration? Check the [FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ) before creating an issue!
|
||||
|
||||
## Development
|
||||
|
||||
[Detailed architecture and development guides for Sources/Clients](https://foxxmd.github.io/multi-scrobbler/docs/development/dev-common)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+2
-1
@@ -40,7 +40,7 @@ COPY --chown=abc:abc . /app
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run build && rm -rf node_modules
|
||||
RUN npm run docs:install && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base as app
|
||||
|
||||
@@ -49,6 +49,7 @@ COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
COPY --from=build --chown=abc:abc /app/docsite /app/docsite
|
||||
COPY --from=base /usr/local/bin /usr/local/bin
|
||||
COPY --from=base /usr/local/lib /usr/local/lib
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import {API, Collection, FileInfo, Options} from 'jscodeshift';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export type TransformFrom = 'none' | 'any' | string;
|
||||
export type TransformTo = 'none' | 'any' | string;
|
||||
export type ImportTypes = 'any' | 'relative' | 'alias';
|
||||
|
||||
const base = process.cwd();
|
||||
export default function transformer(
|
||||
file: FileInfo,
|
||||
{ jscodeshift: j }: API,
|
||||
options: Options,
|
||||
) {
|
||||
|
||||
const tf = options.transformFrom as string | undefined;
|
||||
if(tf === undefined) {
|
||||
throw new Error(`arg '--transformFrom' must be defined.`);
|
||||
}
|
||||
const transformFrom = tf.split(',').map(x => x.toLocaleLowerCase()) as TransformFrom[];
|
||||
const tfIsAny = transformFrom.includes('any');
|
||||
|
||||
const tt = options.transformTo as string | undefined;
|
||||
if(tt === undefined) {
|
||||
throw new Error(`arg '--transformTo' must be defined.`);
|
||||
}
|
||||
const transformTo = tt.split(',').map(x => x.toLocaleLowerCase()) as TransformTo[];
|
||||
|
||||
if(transformTo.length > 1 && transformFrom.length !== transformTo.length) {
|
||||
throw new Error('When more than one Transform To is specified then number of Transform From arguments must match');
|
||||
}
|
||||
|
||||
const itypes = options.importTypes as string | undefined;
|
||||
if(itypes === undefined) {
|
||||
throw new Error(`arg '--importTypes' must be defined.`);
|
||||
}
|
||||
const importTypes = itypes.split(',').map(x => x.toLocaleLowerCase()) as ImportTypes[];
|
||||
const importsIsAny = importTypes.includes('any');
|
||||
|
||||
const fileDir = path.dirname(path.join(base, file.path));
|
||||
const processingFilePath = file.path;
|
||||
console.log(`Processing => ${processingFilePath}`);
|
||||
let source: Collection<any>;
|
||||
try {
|
||||
source = j(file.source);
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${processingFilePath}, will skip`);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const imports = source.find(j.ImportDeclaration)
|
||||
imports.forEach((x) => {
|
||||
const importPath = x.value.source.value as string;
|
||||
const importPathPrefix = `${importPath.padEnd(60, ' ')} =>`;
|
||||
|
||||
const pathInfo = path.parse(importPath);
|
||||
const hasNoExt = pathInfo.ext === '';
|
||||
let filenameFromDir: string;
|
||||
if(tfIsAny || (hasNoExt && transformFrom.includes('none')) || (transformFrom.includes(pathInfo.ext.replace('.', '').toLocaleLowerCase()))) {
|
||||
const pathFull = path.join(fileDir, importPath);
|
||||
const dir = path.dirname(pathFull);
|
||||
const normalExt = pathInfo.ext.replace('.', '');
|
||||
let isRelative: boolean | undefined;
|
||||
let dirExists: boolean | undefined;
|
||||
let fileExists: boolean | undefined;
|
||||
try {
|
||||
// is dir path real?
|
||||
fs.realpathSync(dir);
|
||||
dirExists = true;
|
||||
} catch (e) {
|
||||
isRelative = false;
|
||||
dirExists = false;
|
||||
}
|
||||
|
||||
if(isRelative === undefined) {
|
||||
// if extension is none we need to check for any file in dir with this name
|
||||
if(hasNoExt) {
|
||||
const dirFiles = fs.readdirSync(dir)
|
||||
filenameFromDir = dirFiles.find(x => path.parse(x).name === pathInfo.name);
|
||||
if(filenameFromDir === undefined) {
|
||||
fileExists = false;
|
||||
isRelative = false;
|
||||
} else {
|
||||
fileExists = true;
|
||||
isRelative = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// does a file exist?
|
||||
fs.realpathSync(pathFull);
|
||||
isRelative = true;
|
||||
} catch (e) {
|
||||
isRelative = false;
|
||||
fileExists = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!importsIsAny) {
|
||||
if(!isRelative && !importTypes.includes('alias')) {
|
||||
console.log(`${importPathPrefix} Import looks like an alias ${dirExists ? '(dir exists, file does not)' : '(dir does not exist)'} but import types does specify alias`);
|
||||
return;
|
||||
} else if(isRelative && !importTypes.includes('relative')) {
|
||||
console.log(`${pathFull} => Import is relative but import types does not specify relative`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// determine transformTo
|
||||
|
||||
// if there is only one TO then use it
|
||||
let derivedTT: undefined | string = transformTo.length === 1 ? transformTo[0] : undefined;
|
||||
if(derivedTT === undefined) {
|
||||
// otherwise we find the TO by using the same index as the matching FROM extension type
|
||||
const tfIndex = transformFrom.findIndex((x) => {
|
||||
if(x === 'any') {
|
||||
return true;
|
||||
}
|
||||
if(x === 'none' && hasNoExt) {
|
||||
return true;
|
||||
}
|
||||
if(x === normalExt) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if(tfIndex === -1) {
|
||||
console.warn(`${importPathPrefix} did not match a Transform From type. Will not transform`);
|
||||
return;
|
||||
}
|
||||
derivedTT = transformTo[tfIndex];
|
||||
}
|
||||
|
||||
let transformPrefix = ` --> ${hasNoExt ? '(None)' : normalExt} TO ${derivedTT} <--`;
|
||||
|
||||
let transformedImport: string;
|
||||
|
||||
switch(derivedTT) {
|
||||
case 'none':
|
||||
if(hasNoExt) {
|
||||
console.log(`${importPathPrefix} ${transformPrefix} => Import already has no extension, nothing to do`);
|
||||
return;
|
||||
}
|
||||
transformedImport = combineDirFile(pathInfo.dir, pathInfo.name); // path.join(pathInfo.root, pathInfo.dir, pathInfo.name);
|
||||
break;
|
||||
case 'any':
|
||||
if(hasNoExt && filenameFromDir) {
|
||||
transformedImport = filenameFromDir;
|
||||
} else {
|
||||
const otherFilename = fs.readdirSync(dir).find(x => {
|
||||
const pinfo = path.parse(x);
|
||||
return pinfo.name === pathInfo.name && pinfo.ext !== pathInfo.ext;
|
||||
});
|
||||
if(otherFilename === undefined) {
|
||||
console.warn(`${importPathPrefix} ${transformPrefix} => Could not find another file in directory that had same name but different extension. Will not transform.`);
|
||||
return;
|
||||
}
|
||||
transformedImport = combineDirFile(pathInfo.dir, otherFilename); // path.join(pathInfo.root, dir, otherFilename);
|
||||
transformPrefix = `${transformPrefix} (${path.parse(otherFilename).ext})`
|
||||
}
|
||||
break;
|
||||
default:
|
||||
transformedImport = combineDirFile(pathInfo.dir, `${path.parse(filenameFromDir).name}.${derivedTT}`); // path.join(pathInfo.root, dir, pathInfo.name, `.${derivedTT}`);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`${importPathPrefix} ${transformPrefix} => Replacing with ${transformedImport}`);
|
||||
j(x).replaceWith(
|
||||
j.importDeclaration(
|
||||
x.node.specifiers,
|
||||
j.stringLiteral(transformedImport)
|
||||
)
|
||||
);
|
||||
|
||||
} else {
|
||||
console.log(`${importPathPrefix} Import did not match transformFrom ('${tf}')`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Early exit condition
|
||||
* -----
|
||||
* It is often good practice to exit early and return the original source file
|
||||
* if it does not contain code relevant to the codemod.
|
||||
* See this page for more information:
|
||||
* https://codeshiftcommunity.github.io/CodeshiftCommunity/docs/your-first-codemod#output
|
||||
*/
|
||||
// if (/* Some condition here */ true) {
|
||||
// return file.source;
|
||||
// }
|
||||
|
||||
/**
|
||||
* Codemod logic goes here 👇
|
||||
* -----
|
||||
* This is where the core logic for your codemod will go,
|
||||
* consider grouping specific actions into 'motions' and running them in sequence
|
||||
*
|
||||
* See this page for more information:
|
||||
* https://codeshiftcommunity.github.io/CodeshiftCommunity/docs/authoring#motions
|
||||
*/
|
||||
//source.findVariableDeclarators('foo').renameTo('bar');
|
||||
|
||||
/**
|
||||
* Return your modified AST here 👇
|
||||
* -----
|
||||
* This is where your modified AST will be transformed back into a string
|
||||
* and written back to the file.
|
||||
*/
|
||||
return source.toSource(options.printOptions);
|
||||
}
|
||||
|
||||
const combineDirFile = (dir: string, file: string) => {
|
||||
if(dir === '') {
|
||||
return file;
|
||||
}
|
||||
return `${dir}${path.sep}${file}`;
|
||||
}
|
||||
+1
-1
@@ -8,5 +8,5 @@ These are **NOT** exhaustive examples. You should consult the **configuration**
|
||||
|
||||
Documentation at
|
||||
|
||||
* [internal docs](../docsite/docs/configuration/configuration.md)
|
||||
* [internal docs](../docsite/docs/configuration/configuration.mdx)
|
||||
* External Link: https://foxxmd.github.io/multi-scrobbler/docs/configuration
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"enable": true,
|
||||
"name": "MyMPD",
|
||||
"data": {
|
||||
"url": "192.168.0.100:6600",
|
||||
"password": "MY_PASSWORD"
|
||||
},
|
||||
"options": {
|
||||
"disableDiscovery": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -4,6 +4,7 @@
|
||||
"enable": true,
|
||||
"name": "musikcube",
|
||||
"data": {
|
||||
"url": "ws://localhost:7905",
|
||||
"password": "MY_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
+45
-47
@@ -1,25 +1,15 @@
|
||||
<!-- TOC -->
|
||||
* [Connection Issues](#connection-issues)
|
||||
* [Plex/Tautulli/Jellyfin don't connect](#plextautullijellyfin-dont-connect)
|
||||
* [Jellyfin has warnings about undefined or missing data](#jellyfin-has-warnings-about-undefined-or-missing-data)
|
||||
* [Jellyfin has warnings about missing headers](#jellyfin-has-warnings-about-missing-headers)
|
||||
* [Spotify/Deezer/LastFM won't authenticate](#spotifydeezerlastfm-wont-authenticate)
|
||||
* [Configuration Issues](#configuration-issues)
|
||||
* [Config could not be parsed](#config-could-not-be-parsed)
|
||||
* [Scrobbling Issues](#scrobbling-issues)
|
||||
* [Last.fm does not scrobble tracks with multiple artists correctly](#lastfm-does-not-scrobble-tracks-with-multiple-artists-correctly)
|
||||
* [Jellyfin does not scrobble tracks with multiple artists correctly](#jellyfin-does-not-scrobble-tracks-with-multiple-artists-correctly)
|
||||
* [Google Cast track information is missing/incorrect or MS player has weird times](#google-cast-track-information-is-missingincorrect-or-ms-player-has-weird-times)
|
||||
* [Google Cast device does not track media](#google-cast-device-does-not-track-media)
|
||||
<!-- TOC -->
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
---
|
||||
|
||||
# Connection Issues
|
||||
## Connection Issues
|
||||
|
||||
## Plex/Tautulli/Jellyfin don't connect
|
||||
### Plex/Tautulli/Jellyfin/Webscrobbler don't connect
|
||||
|
||||
These three [sources](/#source) are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server to contact multi-scrobbler, as opposed to multi-scrobbler contacting the server.
|
||||
These sources are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server (or Webscrobbler extension) to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
|
||||
|
||||
multi-scrobbler will log information about any server that connects to it for these three services. In the logs it looks something like this:
|
||||
multi-scrobbler will log information about any server that connects to it for these services. In the logs it looks something like this:
|
||||
|
||||
```
|
||||
2023-02-22T10:55:56-05:00 info : [Ingress - Plex ] Received request from a new remote address: ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59)
|
||||
@@ -30,15 +20,15 @@ It also logs if a server tries to connect to a URL that it does not recognize:
|
||||
```
|
||||
2023-02-22T11:16:12-05:00 debug : [App ] Server received POST request from ::ffff:192.168.0.140 (UA: PlexMediaServer/1.24.5.5173-8dcc73a59) to unknown route: /plkex
|
||||
```
|
||||
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin is not able to connect to your multi-scrobbler instance at all.**
|
||||
**So, if you do not see either of these in your logs then Plex/Tautulli/Jellyfin/Webscrobbler is not able to connect to your multi-scrobbler instance at all.**
|
||||
|
||||
This is not something multi-scrobbler can fix and means you have an issue in your network.
|
||||
|
||||
### Troubleshooting
|
||||
#### Troubleshooting
|
||||
|
||||
Check or try all these steps before submitting an issue:
|
||||
|
||||
#### Turn on Debug Logging
|
||||
##### Turn on Debug Logging
|
||||
|
||||
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `LOG_LEVEL=debug`:
|
||||
|
||||
@@ -47,36 +37,40 @@ First, turn on **debug** logging for multi-scrobbler by setting the environmenta
|
||||
|
||||
Check the output for any additional information.
|
||||
|
||||
#### Check Host name and URL
|
||||
##### Check Host name and URL
|
||||
|
||||
The URLs examples in the [configuration](configuration/configuration.md) documentation assume you are running Plex/Tautulli/Jellyfin on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
The URLs examples in the [configuration](configuration/configuration.mdx) documentation assume you are running Plex/Tautulli/Jellyfin/Webscrobbler on the same server as multi-scrobbler. If these are not the same machine then you need to determine the IP address or domain name that multi-scrobbler is reachable at and use that instead of `localhost` when configuring these sources. **This is likely the same host name that you would use to access the web interface for multi-scrobbler.**
|
||||
|
||||
EX `http://localhost:9078/plex` -> `http://192.168.0.140:9078/plex`
|
||||
|
||||
#### Check Firewall and Port Forwarding
|
||||
##### Check Firewall and Port Forwarding
|
||||
|
||||
If the machine multi-scrobbler is running on has a firewall ensure that port **9078** is open. Or if it is in another network entirely make sure your router is forwarding this port and it is open to the correct machine.
|
||||
|
||||
#### Check Source Service Logs
|
||||
##### Check Source Service Logs
|
||||
|
||||
Plex/Tautulli/Jellyfin all have logs that will log if they cannot connect to multi-scrobbler. Check these for further information.
|
||||
|
||||
##### Plex
|
||||
###### Plex
|
||||
|
||||
Settings -> Manage -> Console
|
||||
|
||||
##### Tautulli
|
||||
###### Tautulli
|
||||
|
||||
Check the command-line output of the application or docker logs.
|
||||
|
||||
##### Jellyfin
|
||||
###### Jellyfin
|
||||
|
||||
Administration -> Dashboard -> Advanced -> Logs
|
||||
|
||||
## Jellyfin has warnings about undefined or missing data
|
||||
###### Webscrobbler
|
||||
|
||||
See [Debugging the extension](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension) to get logs which should have information about failed requests.
|
||||
|
||||
### Jellyfin has warnings about undefined or missing data
|
||||
|
||||
Make sure you have
|
||||
* [Configured the webhook plugin correctly](configuration/configuration.md#jellyfin)
|
||||
* [Configured the webhook plugin correctly](configuration/configuration.mdx#jellyfin)
|
||||
* Checked the **Send All Properties(ignores template)** option in the webhook settings and **Saved**
|
||||
|
||||
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
|
||||
@@ -89,10 +83,10 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
|
||||
"name": "MyJellyfin",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"],
|
||||
"options": {
|
||||
"logPayload": true
|
||||
}
|
||||
"users": ["FoxxMD"]
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -100,7 +94,7 @@ You can verify the payload sent from the webhook by modifying your jellyfin conf
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
## Jellyfin has warnings about missing headers
|
||||
### Jellyfin has warnings about missing headers
|
||||
|
||||
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
|
||||
|
||||
@@ -111,13 +105,13 @@ If you experience issues trying to scrobble with Jellyfin and find this in your
|
||||
A workaround that may fix this:
|
||||
|
||||
* In Webhook settings:
|
||||
* [In the webhook you have already configured...](configuration/configuration.md#jellyfin)
|
||||
* [In the webhook you have already configured...](configuration/configuration.mdx#jellyfin)
|
||||
* Add Request Header...
|
||||
* **Key:** `Content-Type`
|
||||
* **Value:** `application/json`
|
||||
* Then Save
|
||||
|
||||
## Spotify/Deezer/LastFM won't authenticate
|
||||
### Spotify/Deezer/LastFM won't authenticate
|
||||
|
||||
Ensure any **client id** or **secrets** are correct in your configuration.
|
||||
|
||||
@@ -130,9 +124,13 @@ If multi-scrobbler is not running on the same machine your browser is on then th
|
||||
|
||||
EX `http://localhost:9078/lastfm/callback` -> `http://192.168.0.220:9078/lastfm/callback`
|
||||
|
||||
# Configuration Issues
|
||||
### Deezer is not working
|
||||
|
||||
## Config could not be parsed
|
||||
Deezer has discontinued support for their API and the Deezer Source is now [**deprecated.**](configuration/configuration.mdx#deezer) See [this issue for more discussion.](https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625)
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
### Config could not be parsed
|
||||
|
||||
If you see something like this in your logs:
|
||||
|
||||
@@ -144,25 +142,25 @@ If you see something like this in your logs:
|
||||
|
||||
It means the JSON in your configuration file is not valid. Copy and paste your configuration into a site like [JSONLint](https://jsonlint.com/) to find out where errors you have and fix them.
|
||||
|
||||
# Scrobbling Issues
|
||||
## Scrobbling Issues
|
||||
|
||||
## Last.fm does not scrobble tracks with multiple artists correctly
|
||||
### Last.fm does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scrobble) where the **artist** field is only one string and Last.fm does not recognize (play well) with "combined" artists.
|
||||
|
||||
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
|
||||
|
||||
## Jellyfin does not scrobble tracks with multiple artists correctly
|
||||
### Jellyfin does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
|
||||
|
||||
## Google Cast track information is missing/incorrect or MS player has weird times
|
||||
### Google Cast track information is missing/incorrect or MS player has weird times
|
||||
|
||||
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
|
||||
|
||||
To diagnose these issues you [**must enable payload logging**](configuration/configuration.md#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
|
||||
To diagnose these issues you [**must enable payload logging**](configuration/configuration.mdx#cast-troubleshooting) for your google cast Source, run MS, and then include logs with this output from that run. Without the raw data logged from your cast device it will be nearly impossible to resolve your issue.
|
||||
|
||||
## Google Cast device does not track media
|
||||
### Google Cast device does not track media
|
||||
|
||||
It is likely the app playing on the cast device is incorrectly reporting the media type as **not music**.
|
||||
|
||||
@@ -172,10 +170,10 @@ MS logs will tell you what type the media is reported as with lines like:
|
||||
My Artist - Example Track has 'unknown' media type and allowUnknownMedia=false, will not track
|
||||
```
|
||||
|
||||
Refer to [Allow Unknown Media Type](configuration/configuration.md#allow-unknown-media-type) section to fix this
|
||||
Refer to [Allow Unknown Media Type](configuration/configuration.mdx#allow-unknown-media-type) section to fix this
|
||||
|
||||
```
|
||||
My Artist - Example Track has 'movie' media type so will not track
|
||||
```
|
||||
|
||||
Refer to [Force Media Tracking](configuration/configuration.md#forcing-media-tracking) section to fix this
|
||||
Refer to [Force Media Tracking](configuration/configuration.mdx#forcing-media-tracking) section to fix this
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Configuration",
|
||||
"position": 2,
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Configuring Multi-Scrobbler and examples"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,9 +21,7 @@ Scenario:
|
||||
|
||||
### All-in-one Config
|
||||
|
||||
Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
|
||||
@@ -160,9 +158,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
|
||||
### Separate JSON files
|
||||
|
||||
In `CONFIG_DIR/spotify.json`:
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/spotify.json"
|
||||
[
|
||||
{
|
||||
// may omit 'type' property since app knows this is file is for spotify configs
|
||||
@@ -193,9 +189,7 @@ In `CONFIG_DIR/spotify.json`:
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/plex.json`
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/plex.json"
|
||||
[
|
||||
{
|
||||
"name": "fredPlex",
|
||||
@@ -221,9 +215,7 @@ In `CONFIG_DIR/plex.json`
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/jellyfin.json`
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/jellyfin.json"
|
||||
[
|
||||
{
|
||||
"name": "FredJelly",
|
||||
@@ -234,9 +226,7 @@ In `CONFIG_DIR/jellyfin.json`
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/ytmusic.json`
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/ytmusic.json"
|
||||
[
|
||||
{
|
||||
"type": "ytmusic",
|
||||
@@ -250,9 +240,7 @@ In `CONFIG_DIR/ytmusic.json`
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/maloja.json`:
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/maloja.json"
|
||||
[
|
||||
{
|
||||
"name": "foxxMaloja",
|
||||
@@ -278,9 +266,7 @@ In `CONFIG_DIR/maloja.json`:
|
||||
]
|
||||
```
|
||||
|
||||
In `CONFIG_DIR/lastfm.json`:
|
||||
|
||||
```json5
|
||||
```json5 title="CONFIG_DIR/lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "maryLFM",
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Scrobble Modification
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
Multi-scrobbler configs support the ability to modify scrobble data in an automated fashion by matching and replacing strings in **title, artists, and album** at many different times in multi-scrobbler's lifecycle.
|
||||
|
||||
### Why?
|
||||
|
||||
You may need to "clean up" data from a Source or before sending to a scrobble Client due to any number of reasons:
|
||||
|
||||
* ID3 tags in your music collection are dirty or have repeating garbage IE `[YourMusicSource.com] My Artist - My Title`
|
||||
* A Source's service often incorrectly adds data to some field IE `My Artist - My Title (Album Version)` when the title should just be `My Title`
|
||||
* An Artist you listen to often is spelled different between a Source and a Client which causes duplicate scrobbles
|
||||
|
||||
In any scenario where a repeating pattern can be found in the data it would be nice to be able to fix it before the data gets downstream or to help prevent duplicate scrobbling. Multi-scrobbler can help you do this.
|
||||
|
||||
## Overview
|
||||
|
||||
### Journey of a Scrobble
|
||||
|
||||
First, let's recap the lifecycle of a scrobble in multi-scrobbler:
|
||||
|
||||
**Sources** are the beginning of the journey for a **Play** (song you've listened to long enough to be scrobblable)
|
||||
|
||||
* A Source finds a new valid **Play**
|
||||
* The Source **compares** this new Play to all the other Plays it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Source **discovers** the Play, adds it to Plays it has seen already, and broadcasts the Play should be scrobbled to all Clients
|
||||
|
||||
Scrobble **Clients** listen for discovered Plays from Sources, then...
|
||||
|
||||
* A Client receives a **Play** from a Source
|
||||
* The Client **compares** this Play to all the other scrobbles it has already seen, if the Play is unique (title/artist/album/listened datetime) then...
|
||||
* The Client **scrobbles** the Play downstream to the scrobble service and adds it as a Scrobble it has seen already
|
||||
|
||||
### Lifecyle Hooks
|
||||
|
||||
You'll notice there is a pattern above that looks like this:
|
||||
|
||||
* **Before** data is compared
|
||||
* Data is **compared**
|
||||
* **After** data is compared
|
||||
|
||||
These points, during both Source and Client processes, are when you can hook into the scrobble lifecycle and modify it.
|
||||
|
||||
#### TLDR
|
||||
|
||||
In more concrete terms this is the structure of hooks within a configuration (can be used in any **Source** or **Client**):
|
||||
|
||||
```json5 title="lastfm.json" {10-14}
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {/* ... */},
|
||||
"compare": {/* ... */},
|
||||
"postCompare": {/* ... */}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
##### Hook
|
||||
|
||||
For **Sources**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if Play was already discovered
|
||||
* `postCompare` - modify Play data before sending to scrobble **Clients**
|
||||
|
||||
For **Clients**:
|
||||
|
||||
* `preCompare` - modify Play data immediately when received
|
||||
* `compare` - temporarily modify Play data when it is being compared to see if it was already scrobbled
|
||||
* `postCompare` - modify Play data before scrobbling it to downstream service and adding to already seen scrobbles
|
||||
|
||||
:::tip
|
||||
|
||||
Keep in mind that modifying Scrobble/Play data earlier in the lifecycle will affect that data at all times later in the lifecycle.
|
||||
|
||||
For example, to modify the track so it's the same anywhere it is processed in multi-scrobbler you only need to modify it in the **Source's** `preCompare` hook because all later processes will receive the data with the modified track.
|
||||
|
||||
:::
|
||||
|
||||
### Modification Parts
|
||||
|
||||
|
||||
Each [**hook**](#hook) (`preCompare` etc...) is an object that specifies what part of the **Play** to modify:
|
||||
|
||||
```json5
|
||||
{
|
||||
"title": [/* ... */],
|
||||
"artists": [/* ... */],
|
||||
"album": [/* ... */]
|
||||
}
|
||||
```
|
||||
|
||||
##### Expression
|
||||
|
||||
and then a **list** what pattern/replacements (expressions) to use for the modification by using either simple strings or `search-replace` objects:
|
||||
|
||||
```json5
|
||||
[
|
||||
"badTerm", // remove all instances of 'badTerm'
|
||||
{
|
||||
"search": "anotherBadTerm", // and also match all instances of 'anotherBadTerm'
|
||||
"replace": "goodTerm" // replace with the string 'goodTerm'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Putting it all together:
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
[
|
||||
"badTerm",
|
||||
{
|
||||
"search": "badTerm",
|
||||
"replace": "goodTerm"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
If the value of the field (title, an artist, album) is an empty string after transforming then the field is **removed.**
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
|
||||
Modifications can also be applied to **all Sources** or **all Clients** when using the [AIO Config](./configuration.mdx?configType=aio#configuration-types) `config.json` by setting `playTransform` in `sourceDefaults` or `clientDefaults`:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": { // will apply playTransform to all sources
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources": [/* ... */],
|
||||
"clients": [/* ... */]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
:::
|
||||
|
||||
#### Compare Hook
|
||||
|
||||
The `compare` [hook](#hook) is slightly different than `preCompare` and `postCompare`. It consists of an object where you define which side(s) of the comparison should be modified. It also **does not modify downstream data!** Instead, the modifications are made only for use in the comparison.
|
||||
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"compare": {
|
||||
"candidate": {/* ... */}, // modify the "new" Play being compared
|
||||
"existing": {/* ... */}, // modify all "existing" Play/Scrobbles the new Play is being compared against
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Regular Expressions
|
||||
|
||||
In addition to plain strings [expressions](#expression) that are matched and removed you can also use Regular Expressions. Write your regex like you normally would, but as a string, and it'll automatically be parsed:
|
||||
|
||||
```json5
|
||||
[
|
||||
"/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)' and removes it
|
||||
{
|
||||
"search": "/^\(\w+.com)/i", // matches any string that starts with '(YourMusic.com)'
|
||||
"replace": "[MySite.com]" // replace with the string '[MySite.com]'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `replace` property uses javascript's [`replace()` function and so can use any special string characters.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement)
|
||||
|
||||
## Examples
|
||||
|
||||
### Remove phrase from Title in all new Plays
|
||||
|
||||
Removes the phrase `(Album Version)` from the Title of a Play
|
||||
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
"(Album Version)"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove all parenthesized content from the end of a title
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="lastfm.json"
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
// ...
|
||||
"options": {
|
||||
"playTransform": {
|
||||
"compare": {
|
||||
"candidate": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
"existing": {
|
||||
"title": [
|
||||
"/(\(.+\))\s*$/"
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
### Rename misspelled artist in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"artists": [
|
||||
{
|
||||
"search": "Boz Skaggs",
|
||||
"replace": "Boz Scaggs"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Remove "Various Artists" albums in all new Plays
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"album": [
|
||||
{
|
||||
"search": "Various Artists",
|
||||
"replace": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Development",
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Developing for Multi-Scrobbler and tutorials"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 3
|
||||
title: Client Development/Tutorial
|
||||
---
|
||||
|
||||
To do...
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 1
|
||||
title: Common Development
|
||||
description: Start here for MS development
|
||||
---
|
||||
|
||||
# Development
|
||||
|
||||
## Architecture
|
||||
|
||||
Multi-scrobbler is written entirely in [Typescript](https://www.typescriptlang.org/). It consists of a backend and frontend. The backend handles all Source/Client logic, mounts web server endpoints that listen for Auth callbacks and Source ingress using [expressjs](https://expressjs.com/), and serves the frontend. The frontend is a standalone [Vitejs](https://vitejs.dev/) app that communicates via API to the backend in order to render the dashboard.
|
||||
|
||||
## Project Setup
|
||||
|
||||
Development requires [Node v18.19.1](https://nodejs.org/en) or higher is installed on your system. It is recommended to use [nvm](https://github.com/nvm-sh/nvm) to manage the installed node version.
|
||||
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
git checkout --track origin/develop
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
Use the [`develop`](https://github.com/FoxxMD/multi-scrobbler/tree/develop) branch as the target for any Pull Requests. The `master` branch is reserved for releases and minor documentation updates only.
|
||||
|
||||
## Common Development
|
||||
|
||||
:::info
|
||||
|
||||
In this document, when referring to aspects of Sources and Clients that are shared between both, the Source/Client will be referred to as a **Component.**
|
||||
|
||||
:::
|
||||
|
||||
A Component is composed of two parts:
|
||||
|
||||
* Typescript interfaces describing structure of configuration for that Component
|
||||
* A concrete class inheriting from a common "startup" abstract class that enforces how the Component is built and operates
|
||||
|
||||
In both parts Source/Clients share some common properties/behavior before diverging in how they operate.
|
||||
|
||||
### Config
|
||||
|
||||
The configuration for a Component should always have this minimum shape, enforced respectively by the interfaces [CommonSourceConfig](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/infrastructure/config/source/index.ts#L105) and [CommonClientConfig](https://github.com/FoxxMD/multi-scrobbler/blob/ce1c70a4e1e87fb5bea7cca960eaafbd15881a1f/src/backend/common/infrastructure/config/client/index.ts#L68):
|
||||
|
||||
```ts
|
||||
interface MyConfig {
|
||||
name: string
|
||||
data?: object
|
||||
options?: object
|
||||
}
|
||||
```
|
||||
|
||||
* `data` contains data that is required for a Component to operate such as credentials, callback urls, api keys, endpoints, etc...
|
||||
* `options` are **optional** settings that can be used to fine-tune the usage of the Component but are not required or do not majorly affect behavior. EX additional logging toggles
|
||||
|
||||
### Concrete Class
|
||||
|
||||
Components inherit from an abstract base class, [`AbstractComponent`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts), that defines different "stages" of how a Component is built and initialized when MS first starts as well as when restarting the Component in the event it stops due to an error/network failure/etc...
|
||||
|
||||
#### Stages
|
||||
|
||||
Stages below are invoked in the order listed. All stages are asynchronous to allow fetching network requests or reading files.
|
||||
|
||||
The stage function (described in each stage below) should return a value or throw:
|
||||
|
||||
* return `null` if the stage is not required
|
||||
* return `true` if the stage succeeded
|
||||
* return a `string` if the stage succeeded and you wish to append a result to the log output for this stage
|
||||
* throw an `Exception` if the stage failed for any reason and the Component should not continue to run/start up
|
||||
|
||||
##### Stage: Build Data
|
||||
|
||||
This stage should be used to validate user configuration, parse any additional data from async sources (file, network), and finalize the shape of any configuration/data needed for the Component to operate.
|
||||
|
||||
:::info
|
||||
|
||||
Implement [`doBuildInitData`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts#L71) in your child class to invoke this stage.
|
||||
|
||||
::::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
* Parse a full URL like `http://SOME_IP:7000/subfolder/api` from user config containing a base url like `data.baseUrl: 'SOME_IP'` and then store this in the class config
|
||||
* Validate that config `data` contains required properties `user` `password` `salt`
|
||||
* Read stored credentials from `${this.configDir}/currentCreds-MySource-${name}.json`;
|
||||
|
||||
</details>
|
||||
|
||||
##### Stage: Check Connection
|
||||
|
||||
This stage is used to validate that MS can communicate with the service the Component is interacting with. This stage is invoked on MS startup as well as any time the Component tries to restart after a failure.
|
||||
|
||||
If the Component depends on **ingress** (like Jellyfin/Plex webhook) this stage is not necessary.
|
||||
|
||||
:::info
|
||||
|
||||
Implement [`doCheckConnection`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts#L103) in your child class to invoke this stage.
|
||||
|
||||
::::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
* Make a [`request`](https://nodejs.org/docs/latest-v18.x/api/http.html#httprequesturl-options-callback) to the service's server to ensure it is accessible
|
||||
* Open a websocket connection and check for a ping-pong
|
||||
|
||||
</details>
|
||||
|
||||
##### Stage: Test Auth
|
||||
|
||||
MS determines if Auth is required for a Component based on two class properties. You should set these properties during `constructor` initialization for your Component class:
|
||||
|
||||
* `requiresAuth` - (default `false`) Set to `true` if MS should check/test Auth for this Component
|
||||
* `requiresAuthInteraction` - (default `false`) Set to `true` if user interaction is required to complete auth IE user needs to visit a callback URL
|
||||
|
||||
If the Component requires authentication in order to communicate with a service then any required data should be built in this stage and a request made to the service to ensure the authentication data is valid.
|
||||
|
||||
This stage should return:
|
||||
|
||||
* `true` if auth succeeded
|
||||
* `false` if auth failed without unexpected errors
|
||||
* IE the authentication data is not valid and requires user interaction to resolve the failure
|
||||
* throw an exception if network failure or unexpected error occurred
|
||||
|
||||
You _should_ attempt to re-authenticate, if possible. Only throw an exception or return `false` if there is no way to recover from an authentication failure.
|
||||
|
||||
:::info
|
||||
|
||||
Implement [`doAuthentication`](https://github.com/FoxxMD/multi-scrobbler/blob/develop/src/backend/common/AbstractComponent.ts#L111) in your child class to invoke this stage.
|
||||
|
||||
::::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
* Generate a Bearer Token for Basic Auth from user/password given in config and store in class properties
|
||||
* Make a request to a known endpoint with Authorization token from read credentials file to see if succeeds or returns 403
|
||||
* Catch a 403 and attempt to reauthenticate at an auth endpoint with user/password given in config
|
||||
|
||||
</details>
|
||||
|
||||
### Play Object
|
||||
|
||||
The **PlayObject** is the standard data structure MS uses to store listen (track) information and data required for scrobbling. It consists of:
|
||||
|
||||
* Track Data -- a standard format for storing track, artists, album, track duration, the date the track was played at, etc...
|
||||
* Listen Metadata -- Optional but useful data related to the specific play or specifics about the Source/Client context for this play such as
|
||||
* Platform specific ID, web URL to track, device/user ID that played this track, etc...
|
||||
|
||||
Both Sources and Clients use the **PlayObject** interface. When a Component receives track info from its corresponding service it must transform this data into a PlayObject before it can be interacted with.
|
||||
|
||||
For more refer to the TS documentation for `PlayObject` or [`AmbPlayObject`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/core/Atomic.ts#L141) in your project
|
||||
|
||||
## Creating Clients and Sources
|
||||
|
||||
* [Source Development and Tutorial](dev-source.md)
|
||||
* [Client Development and Tutorial](dev-client.md)
|
||||
@@ -0,0 +1,552 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 2
|
||||
title: Source Development/Tutorial
|
||||
---
|
||||
|
||||
This document will provide a step-by-step guide for creating a (trivial) new Source in MS alongside describing what aspects of the Source need to be implemented based on the service you use. Before using this document you should review [Common Development](dev-common.md#common-development).
|
||||
|
||||
## Scenario
|
||||
|
||||
You are the developer of a fancy, new self-hosted web-based media player called **Cool Player.** Cool Player has a slick interface and many bells and whistles, but most importantly it has an API. The API:
|
||||
|
||||
* Has an unauthenticated health endpoint at `/api/health` that returns `200` if the service is running properly
|
||||
* Has authenticated endpoints that require a user-generated token in the header `Authorization MY_TOKEN`
|
||||
* Has a `/api/recent` endpoint that lists recently played tracks with a timestamp
|
||||
* Has a `/api/now-playing` endpoint that returns information about the state of the player like current track, player position in the track, etc...
|
||||
* Cool Player is by default accessed on port `6969`
|
||||
* Your personal instance of Cool Player is hosted at `http://192.168.0.100:6969` and the api is accessed at `http://192.168.0.100:6969/api`
|
||||
|
||||
Because there is an API that MS can actively read this will be a **polling** Source where MS sends requests to Cool Player to get scrobble information -- as opposed to an **ingress** Source like Jellyfin/Plex that uses webhooks from the service to send data to MS.
|
||||
|
||||
## Minimal Implementation
|
||||
|
||||
### Define and Implement Config
|
||||
|
||||
We will create a new config interface for Cool Player using the [Common Config](dev-common.md#config) and tell MS it is a valid config that can be used.
|
||||
|
||||
Create a new file for your config:
|
||||
|
||||
```ts title="/src/backend/common/infrastructure/config/source/coolplayer.ts"
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
// all of the required data for the Build Data and Test Auth stages (from Common Development docs)
|
||||
// should go here
|
||||
export interface CoolPlayerSourceData extends CommonSourceData, PollingOptions {
|
||||
// remember to annotation your properties!
|
||||
|
||||
/**
|
||||
* The user-generated token for Cool Player auth created in Cool Player -> Settings -> User -> Tokens
|
||||
*
|
||||
* @example f243331e-cf5b-49d7-846b-0845bdc965b4
|
||||
* */
|
||||
token: string
|
||||
/**
|
||||
* The host and port where Cool Player is hosted
|
||||
*
|
||||
* @example http://192.168.0.100:6969
|
||||
* */
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export interface CoolPlayerSourceConfig extends CommonSourceConfig {
|
||||
data: CoolPlayerSourceData
|
||||
}
|
||||
|
||||
export interface CoolPlayerSourceAIOConfig extends CoolPlayerSourceConfig {
|
||||
// when using the all-in-one 'config.json' this is how users will identify this source
|
||||
type: 'coolplayer'
|
||||
}
|
||||
```
|
||||
|
||||
Add the new interface to the list of valid interfaces for sources:
|
||||
|
||||
```ts title="src/backend/common/infrastructure/config/source/sources.ts"
|
||||
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
|
||||
// ...
|
||||
// highlight-next-line
|
||||
import { CoolPlayerSourceAIOConfig, CoolPlayerSourceConfig } from "./coolplayer.js";
|
||||
|
||||
export type SourceConfig =
|
||||
SpotifySourceConfig
|
||||
// ...
|
||||
// highlight-next-line
|
||||
| CoolPlayerSourceConfig;
|
||||
|
||||
export type SourceAIOConfig =
|
||||
SpotifySourceAIOConfig
|
||||
// ...
|
||||
// highlight-next-line
|
||||
| CoolPlayerSourceAIOConfig;
|
||||
```
|
||||
|
||||
Finally, add the source type identifier to the list of valid identifiers
|
||||
|
||||
```ts title="src/backend/common/infrastructure/Atomic.ts"
|
||||
export type SourceType =
|
||||
'spotify'
|
||||
// ...
|
||||
// highlight-next-line
|
||||
| 'coolplayer';
|
||||
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
// ...
|
||||
// highlight-next-line
|
||||
'coolplayer'
|
||||
];
|
||||
```
|
||||
|
||||
Now we will create a new Source inheriting from [`AbstractComponent`](dev-common.md#concrete-class) that:
|
||||
|
||||
* accepts our config interface
|
||||
* implements a function to transform CoolPlayer's track data into a [**PlayObject**](dev-common.md#play-object)
|
||||
* implements required [stages](dev-common.md#stages)
|
||||
* implements required methods to current player state and/or now playing track
|
||||
|
||||
### Create CoolPlayer Source
|
||||
|
||||
First we create a new Source called `CoolPlayerSource` and setup our constructor to accept the config and [specify Auth behavior.](dev-common.md#stage-test-auth)
|
||||
|
||||
```ts title="src/backend/sources/SpotifySource.ts"
|
||||
import { CoolPlayerSourceConfig } from "../common/infrastructure/config/source/coolplayer.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import {
|
||||
InternalConfig,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
|
||||
// MemorySource is the base class used for polling-type Sources
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// type hints for TS to know what the base class config looks like
|
||||
declare config: CoolPlayerSourceConfig;
|
||||
|
||||
constructor(name: any, config: CoolPlayerSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super('coolplayer', name, config, internal, emitter);
|
||||
|
||||
// Cool Player required authentication
|
||||
this.requiresAuth = true;
|
||||
// but does not require user interaction for auth to work
|
||||
this.requiresAuthInteraction = false;
|
||||
// tells MS this is a Source that can be activity monitored through API
|
||||
this.canPoll = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Initialize Source from Config
|
||||
|
||||
When MS starts it reads all configs and determines which Source to build based on the configs found. We need to tell it to build a `CoolPlayerSource` when a `coolplayer` config type is found.
|
||||
|
||||
We modify `ScrobbleSources.ts` to add `CoolPlayerSource` as an option:
|
||||
|
||||
```ts title="src/backend/sources/ScrobbleSources.ts"
|
||||
// ...
|
||||
import { CoolPlayerSource, CoolPlayerData } from "./CoolPlayerSource.js";
|
||||
|
||||
export default class ScrobbleSources {
|
||||
// ...
|
||||
buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => {
|
||||
// ...
|
||||
|
||||
// if CoolPlayerSource should be able to be built from ENVs only
|
||||
// then add it as a case statement here
|
||||
for (const sourceType of sourceTypes) {
|
||||
switch (sourceType) {
|
||||
// ...
|
||||
case 'musikcube':
|
||||
// ...
|
||||
break;
|
||||
// highlight-start
|
||||
case 'coolplayer':
|
||||
const cp = {
|
||||
baseUrl: process.env.COOL_URL,
|
||||
token: process.env.COOL_TOKEN
|
||||
}
|
||||
if (!Object.values(cp).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'coolplayer',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
configureAs: defaultConfigureAs,
|
||||
data: cp as CoolPlayerData
|
||||
});
|
||||
}
|
||||
break;
|
||||
// highlight-end
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
// (required) create new CoolPlayerSource if source config type is 'coolplayer'
|
||||
addSource = async (clientConfig: ParsedConfig, defaults: SourceDefaults = {}) => {
|
||||
// ...
|
||||
let newSource: AbstractSource;
|
||||
switch (type) {
|
||||
// ...
|
||||
case 'musikcube':
|
||||
// ...
|
||||
break;
|
||||
// highlight-start
|
||||
case 'coolplayer':
|
||||
newSource = await new CoolPlayerSource(name, compositeConfig as CoolPlayerSourceConfig, internal, this.emitter);
|
||||
break;
|
||||
// highlight-end
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implement Play Object Transform
|
||||
|
||||
Now we will create a static function that is used to take the track data returned from Cool Player's API and return a standard [`PlayObject`.](dev-common.md#play-object)
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
// ...
|
||||
|
||||
// 'obj' should ideally be a real TS interface
|
||||
// if CoolPlayer has a ts/js client we would use that otherwise
|
||||
// we should build our own interfaces to represent track data from Cool Player's API
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
const {
|
||||
trackName,
|
||||
artistName,
|
||||
albumName,
|
||||
duration,
|
||||
playedAt,
|
||||
} = obj;
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists: [artistName],
|
||||
album: albumName,
|
||||
track: trackName,
|
||||
// assuming seconds
|
||||
duration,
|
||||
// assuming playedAt is an ISO8601 timestamp
|
||||
playDate: dayjs(playedAt)
|
||||
},
|
||||
meta: {
|
||||
source: 'CoolPlayer'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implement Stages
|
||||
|
||||
Next we will implement the [Stages](dev-common.md#stages) required to get CoolPlayerSource running.
|
||||
|
||||
#### Build Data
|
||||
|
||||
First we implement the [Build Data Stage](dev-common.md#stage-build-data). We will check that the `token` and `baseUrl` properties have been provided by the user. Additionally, we will parse the baseUrl and add default ports/prefix.
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import { URL } from "url";
|
||||
// ...
|
||||
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
baseUrl!: URL;
|
||||
|
||||
// ...
|
||||
|
||||
static parseConnectionUrl(val: string) {
|
||||
const normal = normalizeUrl(val, {removeTrailingSlash: false, normalizeProtocol: true})
|
||||
const url = new URL(normal);
|
||||
|
||||
if (url.port === null || url.port === '') {
|
||||
url.port = '6969';
|
||||
}
|
||||
if (url.pathname === '/') {
|
||||
url.pathname = '/api/';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
token,
|
||||
baseUrl
|
||||
} = this.config;
|
||||
if (token === null || token === undefined || (typeof token === 'string' && token.trim() === '')) {
|
||||
throw new Error(`'token' must be defined`);
|
||||
}
|
||||
|
||||
if (baseUrl === null || baseUrl === undefined || (typeof baseUrl === 'string' && baseUrl.trim() === '')) {
|
||||
throw new Error(`'baseUrl' must be defined`);
|
||||
}
|
||||
try {
|
||||
this.baseUrl = CoolPlayerSource.parseConnectionUrl(baseUrl);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not parse baseUrl: ${baseUrl}`, {cause: e});
|
||||
}
|
||||
|
||||
this.logger.verbose(`Config URL: ${baseUrl} => Normalized: '${this.url.toString()}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Check Connection
|
||||
|
||||
Second we will implement the [Check Connection Stage](dev-common.md#stage-check-connection):
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// ...
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
const resp = await request.get(`${this.baseUrl}/health`);
|
||||
return true;
|
||||
// if /health returned version info we could instead read response and return a string with version info for visibility to the user
|
||||
// return `Cool Player Version: ${resp.body.version}`;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Test Auth
|
||||
|
||||
Finally, we will implement [Auth Test Stage](dev-common.md#stage-test-auth):
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import { UpstreamError } from "../common/errors/UpstreamError.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// ...
|
||||
|
||||
doAuthentication = async () => {
|
||||
try {
|
||||
const resp = await request
|
||||
.get(`${this.baseUrl}/recent`)
|
||||
.set('Authorization', `Token ${this.config.token}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// if Cool Player returned an error as json we could parse it from error body and throw new Error with the message
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Implement Polling
|
||||
|
||||
The majority of Sources MS monitors primarily operate as a source of truth for a **music player** rather than a **played music history.** Only Listenbrainz and Last.fm operate as a source of music history.
|
||||
|
||||
To this end, MS implements a [state machine](https://www.freecodecamp.org/news/state-machines-basics-of-computer-science-d42855debc66/) that emulates the behavior of a music player in order to keep track of when a song you are listening to should be scrobbled. It does this by monitoring the "currently playing" track reported by a Source's service, with varying degrees of accuracy depending on what information is returned from the service. The state machine is implemented in `MemorySource` which our `CoolPlayerSource` inherits from.
|
||||
|
||||
For a polling Source to work properly we need to implement a function, [`getRecentlyPlayed`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/AbstractSource.ts#L92), that returns PlayObjects that are "newly" played. These are then checked against previously "discovered" plays and their timestamp to determine if they should be surfaced to Clients to scrobble.
|
||||
|
||||
To take advantage of the `MemorySource` state machine we will additionally use [`processRecentPlays`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/MemorySource.ts#L113) from `MemorySource` inside `getRecentlyPlayed`. We pass track and/or player state returned from the Source service to `processRecentPlayers`. It then takes care of deriving Source player state based on how this data changes over time. The advantage to using `processRecentPlays` is that our Source service does not necessarily need to pass any player information -- as long as the track info has a **duration** we can more-or-less determine if it has been played long enough to scrobble.
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import {
|
||||
SourceData,
|
||||
PlayerStateData,
|
||||
SINGLE_USER_PLATFORM_ID
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
// ...
|
||||
|
||||
protected async getRecentlyPlayed(options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> {
|
||||
const plays: SourceData[] = [];
|
||||
try {
|
||||
// currently playing tracks/player state data
|
||||
const resp = await request
|
||||
.get(`${this.baseUrl}/now-playing`)
|
||||
.set('Authorization', `Token ${this.config.token}`);
|
||||
const {
|
||||
body: {
|
||||
playerState, // 'playing' or 'stopped' or 'paused'...
|
||||
position, // number of seconds into the track IE at position 48 -> ( 0:48/3:56 in player UI )
|
||||
play: { /* track data */}
|
||||
}
|
||||
} = resp;
|
||||
|
||||
// transform into standard player state data
|
||||
const playerData: PlayerStateData = {
|
||||
platformId: SINGLE_USER_PLATFORM_ID,
|
||||
play: CoolPlayerSource.formatPlayObj(play),
|
||||
position
|
||||
};
|
||||
|
||||
// if Cool Player does return player state we can also push a regular PlayObject to this list
|
||||
plays.push(playerData);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// process player state through state machine
|
||||
// if the track changes or player state changes
|
||||
// and currently played track has been listened to long enough to be scrobbled it will return in newPlays
|
||||
const newPlays = this.processRecentPlays(plays);
|
||||
|
||||
// finally, we return new plays and MS checks to see if they have been previously seen
|
||||
// before signalling to Clients that they can be scrobbled
|
||||
return newPlays;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Congratulations! Your `CoolPlayerSource` has been minimally implemented and can now be used in multi-scrobbler.
|
||||
|
||||
## Further Implementation
|
||||
|
||||
### Backlog
|
||||
|
||||
To have your Source try to scrobble "missed" tracks when MS starts up the Source's service must be able to provide:
|
||||
|
||||
* track information
|
||||
* timestamp of when the track was played
|
||||
|
||||
In your Source implement [`getBackloggedPlays`](https://github.com/FoxxMD/multi-scrobbler/blob/master/src/backend/sources/AbstractSource.ts#L235) and set setting in constructor indicating it has backlogging capabilities:
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import request from 'superagent';
|
||||
import {
|
||||
PlayObject,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
constructor(/* ... */) {
|
||||
super(/* ... */);
|
||||
// ...
|
||||
|
||||
// tell MS it should try to get backlogged tracks on startup
|
||||
this.canBacklog = true;
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
protected getBackloggedPlays = async (options: RecentlyPlayedOptions): Promise<PlayObject[]> => {
|
||||
try {
|
||||
const resp = await request
|
||||
.get(`${this.baseUrl}/recent`)
|
||||
.set('Authorization', `Token ${this.config.token}`);
|
||||
|
||||
// assuming list from body looks like track info returned in
|
||||
// "Implement Play Object Transform" section
|
||||
const {
|
||||
body = []
|
||||
} = resp;
|
||||
|
||||
return body.map(x => CoolPlayerSource.formatPlayObj(x));
|
||||
} catch (e) {
|
||||
throw new Error('Error occurred while getting recently played', {cause: e});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Other Source Types
|
||||
|
||||
There are some scenarios where polling and/or state machine is not the right tool to handle determining if incoming data should be scrobbled:
|
||||
|
||||
* The Source service handles scrobble threshold internally, the data being received should always be scrobbled (WebScrobbler, Plex, Tautulli, Listenbrainz, Last.fm)
|
||||
* You prefer to handle the scrobble determination yourself
|
||||
|
||||
#### Music History Source
|
||||
|
||||
If the Source is still polling but the track returned should always be scrobbled if not already seen IE the Source service is a **music history source** (Listenbrainz, Last.fm), rather than a music player, then simply indicate to MS the source of truth type by setting it in the constructor. The state machine will always return a track if it is new and not seen, regardless of how recently it was seen:
|
||||
|
||||
|
||||
```ts title="src/backend/sources/CoolPlayerSource.ts"
|
||||
import { SOURCE_SOT } from "../../core/Atomic.js";
|
||||
// ...
|
||||
export default class CoolPlayerSource extends MemorySource {
|
||||
|
||||
constructor(/* ... */) {
|
||||
super(/* ... */);
|
||||
// ...
|
||||
|
||||
// tell MS it should immediately scrobble any new, unseen tracks from the upstream service
|
||||
this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Non-Polling Source
|
||||
|
||||
**Ingress** Sources (like Plex, Tautulli, Webscrobbler, Jellyfin) do not having a polling mechanism because the upstream service contacts MS when there is an event, rather than MS contacting the upstream service.
|
||||
|
||||
For these Sources you will need to implement endpoints in `src/service/api.ts` and corresponding files. See the existing Sources in the project as references for how to do this.
|
||||
|
||||
You may still wish to use the state machine `MemorySource` (like Jellyfin) if the events received are not "scrobble" events but instead of implementing `getRecentlyPlayed` you will implement your own function in your Source class, like `handle()`, that receives data and then uses `processRecentPlays`.
|
||||
|
||||
After new plays have been determined see the next section for how to scrobble...
|
||||
|
||||
#### Basic Source
|
||||
|
||||
At the core of a Source that implements `AbstractSource`'s functionality is the ability to **Discover** and **Scrobble** plays.
|
||||
|
||||
These functions are not seen in the MVP `CoolPlayerSource` because they are automatically done by the polling functionality after being returned from `getRecentlyPlayed`.
|
||||
|
||||
##### Discovery
|
||||
|
||||
A Source keeps track of all the "plays" that are determined to be valid for scrobbling. When a play is valid it is checked to see if it has already been "discovered" by comparing the track info and timestamp of the play against already discovered plays. This prevents duplicate scrobbling by using the Source's own data and simplifies scrobbling for Sources by allowing your implementation to "always" ingest track data without having to worry about whether its new or not -- `AbstractSource` and `discover()` will take care of that for you.
|
||||
|
||||
```ts title="src/backend/sources/MyBasicSource.ts"
|
||||
export default class MyBasicSource extends AbstractSource {
|
||||
handle(somePlay: PlayObject) {
|
||||
// if the track is "new" and not seen before it is returned in the discovered list
|
||||
// we then know it is OK to be sent to Clients for scrobbling
|
||||
const discovered: PlayObject[] = this.discover([somePlay]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This additionally will be surfaced to the user in the Dashboard in the "Tracks Discovered" page.
|
||||
|
||||
##### Scrobbling
|
||||
|
||||
After a play is verified to be discovered we can then scrobble it. This will emit the plays to the ScrobbleClients service which then disseminates the play to all Clients that were configured to listen in the Source's config.
|
||||
|
||||
```ts title="src/backend/sources/MyBasicSource.ts"
|
||||
export default class MyBasicSourceSource extends AbstractSource {
|
||||
handle(somePlay: PlayObject) {
|
||||
const discovered: PlayObject[] = this.discover([somePlay]);
|
||||
// emit plays that can be scrobbled by clients
|
||||
this.scrobble(discovered);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If your service only emits an event when a play is scrobbled you can _technically_ skip using `discover()` but it is good practice to use it unless you have a very good reason not to.
|
||||
|
||||
:::note
|
||||
|
||||
Using `scrobble()` does not guarantee a track is actually scrobbled! The Scrobble Clients also check the play against their own "recently scrobbled" list to prevent duplicates.
|
||||
|
||||
:::
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 5
|
||||
sidebar_position: 4
|
||||
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
|
||||
|
||||
### Flatpak and flatpak-builder
|
||||
|
||||
Install [Flatpak](https://flatpak.org/setup/)
|
||||
|
||||
Install [flatpak-builder](https://docs.flatpak.org/en/latest/first-build.html#building-your-first-flatpak)
|
||||
|
||||
#### [flatpak-node-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/node)
|
||||
|
||||
Requires python 3.7+, [pip](https://pip.pypa.io/en/stable/)/[pipx](https://pypa.github.io/pipx/)
|
||||
|
||||
## Update Project source
|
||||
|
||||
Set the `branch` `tag` or `commit` to use for MS in the `source` section of [`io.github.foxxmd.multiscrobbler.yml`](https://github.com/FoxxMD/multi-scrobbler/blob/master/flatpak/io.github.foxxmd.multiscrobbler.yml)
|
||||
|
||||
## Generate Sources and Build
|
||||
|
||||
### Use Setup Script
|
||||
|
||||
A convenience bash script is provided that automates generating offline sources and building the flatpak app for you. This is the recommend method to use.
|
||||
|
||||
Located in the project at [`flatpak/setup.sh`](https://github.com/FoxxMD/multi-scrobbler/blob/master/flatpak/setup.sh), run it from the `flatpak` directory with this syntax:
|
||||
|
||||
```shell
|
||||
./setup.sh -o -b /path/to/flatpak/build/dir
|
||||
```
|
||||
|
||||
```
|
||||
Args:
|
||||
|
||||
-o => Delete and overwrite any existing generated sources
|
||||
-b => The absolute path to the directory that should be used for flatpak build/artifacts. If not defined will use `CWD/build`
|
||||
-y => Proceed without confirming settings
|
||||
```
|
||||
|
||||
### Manual Setup
|
||||
|
||||
If you cannot use `setup.sh` follow the below to manually generate sources and build the flatpak app:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Instructions</summary>
|
||||
|
||||
#### Use `flatpak-node-generator` to generate sources
|
||||
|
||||
First, [make sure `node_modules` is deleted or empty](https://github.com/flatpak/flatpak-builder-tools/issues/354#issuecomment-1478518442) for both the project and `docsite` directories.
|
||||
|
||||
Then, from MS project root:
|
||||
|
||||
```shell title="PROJECT_DIR"
|
||||
flatpak-node-generator npm package-lock.json
|
||||
```
|
||||
|
||||
Move `generated-sources.json` into `PROJECT_DIR/flatpak` and rename `generated-sources.0.json`
|
||||
|
||||
Then, generate `docsite` sources:
|
||||
|
||||
```shell title="PROJECT_DIR"
|
||||
flatpak-node-generator npm docsite/package-lock.json
|
||||
```
|
||||
|
||||
Move `generated-sources.json` into `PROJECT_DIR/flatpak` and rename `generated-sources.1.json`
|
||||
|
||||
#### Build flatpak
|
||||
|
||||
In the below command replace `/home/yourUser/multi-scrobbler-flatpak` with a directory to use for storing flatpak build/artifacts.
|
||||
|
||||
```shell title="PROJECT_DIR/flatpak"
|
||||
flatpak-builder --repo=/home/yourUser/multi-scrobbler-flatpak/repo --state-dir=/home/yourUser/multi-scrobbler-flatpak/state /home/yourUser/multi-scrobbler-flatpak/build io.github.foxxmd.multiscrobbler.yml --force-clean
|
||||
```
|
||||
|
||||
:::info
|
||||
|
||||
Append `--install --user` to the above command to have the app installed immediately.
|
||||
|
||||
::::
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
# Run App
|
||||
|
||||
If built with `--install --user` (default when using `setup.sh`) you can now run MS using the command
|
||||
|
||||
```shell
|
||||
flatpak run -u io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 1,
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Way to install Multi-Scrobbler"
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 'Overview'
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
# Local
|
||||
|
||||
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
|
||||
|
||||
## Nodejs
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Rollup build error
|
||||
|
||||
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
|
||||
|
||||
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
|
||||
|
||||
```json
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
"vite": {
|
||||
"rollup": "npm:@rollup/wasm-node@^4.9.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
* The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
|
||||
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
|
||||
|
||||
```shell
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```shell
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
|
||||
```
|
||||
|
||||
## Flatpak
|
||||
|
||||
You must have [Flatpak](https://flatpak.org/) installed on your system.
|
||||
|
||||
```shell
|
||||
flatpak install flathub io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
**Note:** 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:
|
||||
|
||||
* 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
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
|
||||
|
||||
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
|
||||
|
||||
```shell
|
||||
flatpak run io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
#### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
|
||||
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
|
||||
|
||||
```shell
|
||||
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
|
||||
|
||||
[Dockerhub](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
```
|
||||
docker.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
|
||||
[Github Packages](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
|
||||
```
|
||||
ghcr.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
|
||||
Or use the provided [docker-compose.yml](../../../docker-compose.yml) after modifying it to fit your configuration.
|
||||
|
||||
Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
#### Storage
|
||||
|
||||
You **must** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
|
||||
|
||||
* [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config`
|
||||
* [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config`
|
||||
|
||||
#### Networking
|
||||
|
||||
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you **must** map a port to the container in order to access the dashboard and use MS with some sources (Plex, Jellyfin).
|
||||
|
||||
The default container port is `9078`. To map container to host port:
|
||||
|
||||
* With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use)
|
||||
* With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"`
|
||||
|
||||
##### Base URL
|
||||
|
||||
Optionally, when
|
||||
|
||||
* using a [Source or Client](../configuration/configuration.md) that has a "Redirect URI" that you have not explicitly defined
|
||||
* and
|
||||
* using a bridge network or
|
||||
* installing MS on a different machine than the one used to view the dashboard
|
||||
|
||||
set the [Base URL](../configuration/configuration.md#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
|
||||
|
||||
* With docker: `-e BASE_URL="http://hostMachineIP"` (first port is the port on the host to use)
|
||||
* With docker-compose: [see comments in docker-compose.yml](../../../docker-compose.yml)
|
||||
|
||||
#### Other
|
||||
|
||||
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
|
||||
|
||||
### Linux Host
|
||||
|
||||
If you are
|
||||
|
||||
* using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_)
|
||||
* running docker on MacOS or Windows
|
||||
|
||||
this **DOES NOT** apply to you.
|
||||
|
||||
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
|
||||
|
||||
To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
* `id -u` -- prints UID
|
||||
* `id -g` -- prints GID
|
||||
|
||||
## Docker Usage Examples
|
||||
|
||||
If installing on a different machine make sure all redirect URIs are defined or that you have set a [Base URL](#base-url).
|
||||
|
||||
### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
### Using [file-based](../configuration/configuration.md#file-based-configuration) configuration
|
||||
|
||||
```bash
|
||||
docker run -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /path/on/host/config:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
See the [docker-compose.yml](../../../docker-compose.yml) file for how to use with docker-compose.
|
||||
@@ -0,0 +1,344 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 'Overview'
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
:::tip
|
||||
|
||||
For the difference between **ENV** and **File** examples in this document see [Configuration Types](../configuration/configuration.mdx#configuration-types).
|
||||
|
||||
:::
|
||||
|
||||
## Local Installation
|
||||
|
||||
After installation see [service.md](service.md) to configure multi-scrobbler to run automatically in the background.
|
||||
|
||||
### Nodejs
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
npm install
|
||||
npm run docs:install && npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Rollup build error
|
||||
|
||||
During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`
|
||||
|
||||
Modify `overrides` in `package.json` to use `@rollup/wasm-node` as a drop-in replacement for rollup:
|
||||
|
||||
```json
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
}
|
||||
"vite": {
|
||||
"rollup": "npm:@rollup/wasm-node@^4.9.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/135#issuecomment-1927080260) for more detail.
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
```shell
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://domain.tld" node src/index.js
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
|
||||
<details>
|
||||
<summary>`./config/config.json`</summary>
|
||||
|
||||
```json title="./config/config.json"
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
"clients": ["myConfig"],
|
||||
"name": "mySpotifySource",
|
||||
"data": {
|
||||
"clientId": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"clientSecret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
}
|
||||
}
|
||||
],
|
||||
"clients": [
|
||||
{
|
||||
"type": "maloja",
|
||||
"name": "myConfig",
|
||||
"data": {
|
||||
"url": "http://localhost:42010",
|
||||
"apiKey": "myMalojaKey"
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```shell
|
||||
npm run start
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
|
||||
The web UI and API is served on port `9078`. This can be modified using the `PORT` environmental variable.
|
||||
|
||||
:::
|
||||
|
||||
### 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:
|
||||
|
||||
* 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
|
||||
|
||||
::::
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
There are a few [options for running flatpak applications with temporary or permanent environmental variables.](https://ardasevinc.dev/launch-flatpak-apps-with-custom-args-and-environment-variables)
|
||||
|
||||
```shell
|
||||
flatpak run --env=SPOTIFY_CLIENT_ID=yourId --envSPOTIFY_CLIENT_SECRET=yourSecret --env=MALOJA_URL="http://domain.tld" io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
The config directory for multi-scrobbler as a flatpak can be found under `/home/YourUser/.var/app/io.github.foxxmd.multiscrobbler/config`
|
||||
|
||||
```shell
|
||||
flatpak run io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Docker
|
||||
|
||||
Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
|
||||
|
||||
:::info[Available Images]
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="docker" label="Dockerhub">
|
||||
[Repository Page](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
```
|
||||
docker.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="ghcr" label="Github Packages">
|
||||
[Repository Page](https://github.com/FoxxMD/multi-scrobbler/pkgs/container/multi-scrobbler)
|
||||
```
|
||||
ghcr.io/foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::
|
||||
|
||||
Or use the provided [docker-compose.yml](../../../docker-compose.yml) after modifying it to fit your configuration.
|
||||
|
||||
Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
#### Storage
|
||||
|
||||
You **should** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
[Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount):
|
||||
```shell
|
||||
docker run ... -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
#### Networking
|
||||
|
||||
If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you **must** map a port to the container in order to access the dashboard and use MS with some sources (Plex, Jellyfin). The default container port is `9078`.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
[Docker `run` publish options](https://docs.docker.com/engine/reference/commandline/run/#publish):
|
||||
```shell
|
||||
docker run ... -p 9078:9078 foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
ports:
|
||||
- "9078:9078"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
##### Base URL
|
||||
|
||||
Optionally, when
|
||||
|
||||
* using a [Source or Client](../configuration/configuration.mdx) that has a "Redirect URI" that you have not explicitly defined
|
||||
* and
|
||||
* using a bridge network or
|
||||
* installing MS on a different machine than the one used to view the dashboard
|
||||
|
||||
set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
```shell
|
||||
docker run ... -e BASE_URL="http://hostMachineIP" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
[docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1):
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
environment:
|
||||
- BASE_URL="http://hostMachineIP"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</details>
|
||||
|
||||
#### Other
|
||||
|
||||
* (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment))
|
||||
|
||||
### Linux Host
|
||||
|
||||
::::info
|
||||
|
||||
:::note
|
||||
|
||||
If you are using [rootless containers with Podman](https://developers.redhat.com/blog/2020/09/25/rootless-containers-with-podman-the-basics#why_podman_) or are running docker on MacOS/Windows this **DOES NOT** apply to you.
|
||||
|
||||
:::
|
||||
|
||||
If you are running Docker on a **Linux Host** you must specify `user:group` permissions of the user who owns the **configuration directory** on the host to avoid [docker file permission problems.](https://ikriv.com/blog/?p=4698) These can be specified using the [environmental variables **PUID** and **PGID**.](https://docs.linuxserver.io/general/understanding-puid-and-pgid)
|
||||
|
||||
To get the UID and GID for the current user run these commands from a terminal:
|
||||
|
||||
* `id -u` -- prints UID
|
||||
* `id -g` -- prints GID
|
||||
|
||||
::::
|
||||
|
||||
### Docker Usage Example
|
||||
|
||||
:::tip
|
||||
|
||||
See the [**Quick Start Guide**](../quickstart.mdx) for another guided docker-compose example
|
||||
|
||||
:::
|
||||
|
||||
The example scenario:
|
||||
|
||||
* [Spotify **Source**](../configuration/configuration.mdx#spotify)
|
||||
* [Maloja **Client**](../configuration/configuration.mdx#maloja)
|
||||
* Serving app on port `9078`
|
||||
* Docker container located on a different IP (`192.168.0.100`) so use [Base URL](../configuration/configuration.mdx#base-url)
|
||||
* Config/data directory on host machine in a directory next to `docker-compose.yml`
|
||||
* Linux uid/gid is `1000:1000`
|
||||
|
||||
<Tabs groupId="runType" queryString>
|
||||
<TabItem value="docker" label="Docker">
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "BASE_URL=192.168.0.100" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -e "PUID=1000" -e "PGID=1000" -p 9078:9078 -v /home/myUser/ms:/config foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
```bash
|
||||
docker run -e "PUID=1000" -e "PGID=1000" -e "BASE_URL=192.168.0.100" -p 9078:9078 -v "$(pwd)/config:/config" foxxmd/multi-scrobbler
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
See [`docker-compose.yml`](../../../docker-compose.yml) file for more options and annotations.
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
- SPOTIFY_CLIENT_ID=yourId
|
||||
- SPOTIFY_CLIENT_SECRET=yourSecret
|
||||
- BASE_URL="http://192.168.0.100:9078"
|
||||
- MALOJA_URL=http://domain.tld:42010
|
||||
- MALOJA_API_KEY=1234
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
ports:
|
||||
- "9078:9078"
|
||||
restart: unless-stopped
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ sidebar_position: 2
|
||||
title: 'As a Service'
|
||||
---
|
||||
|
||||
If you have multi-scrobbler installed [locally](installation.md#local) you can enable it to run as a background service when you login.
|
||||
If you have multi-scrobbler installed [locally](installation.mdx#nodejs) you can enable it to run as a background service when you login.
|
||||
|
||||
Before running as a service you should run it at least once in the foreground to ensure it can start up correctly!
|
||||
|
||||
@@ -17,7 +17,7 @@ This setup will create a [user service](https://wiki.archlinux.org/title/systemd
|
||||
|
||||
Create a new service file for multi-scrobbler under your HOME config:
|
||||
|
||||
```console
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
touch ~/.config/systemd/user/multi-scrobbler.service
|
||||
```
|
||||
@@ -38,11 +38,11 @@ Restart=no
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
The above assumes you [installed multi-scrobbler using flatpak](installation.md#flatpak)
|
||||
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.md#nodejs) you should modify the `[Service]`:
|
||||
If you are running multi-scrobbler directly with [nodejs from a clone repository directory](installation.mdx#nodejs) you should modify the `[Service]`:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
@@ -56,7 +56,7 @@ Restart=no
|
||||
|
||||
Save the file then run:
|
||||
|
||||
```console
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl --user enable multi-scrobbler.service
|
||||
systemctl --user start multi-scrobbler.service
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: 'Quickstart'
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
This guide will get you up and running with multi-scrobbler using [Docker](https://www.docker.com/) and [`docker compose`](https://docs.docker.com/compose/). At the end of the guide you will have:
|
||||
|
||||
* the dashboard served on port `9078` of a host machine which has an IP of `192.168.0.100`
|
||||
* data saved to the same directory as the `docker-compose.yml` file
|
||||
* multi-scrobbler monitoring [Spotify](./configuration/configuration.mdx#spotify) and/or [Jellyfin](./configuration/configuration.mdx#jellyfin) for listening activity
|
||||
* multi-scrobbler scrobbling to [Lastfm](./configuration/configuration.mdx#lastfm) and/or [Maloja](./configuration/configuration.mdx#maloja)
|
||||
|
||||
:::note
|
||||
|
||||
If the multi-scrobbler container is on the same machine you will be viewing the dashboard from (IE `localhost`) you can remove and ignore `BASE_URL` usage below. Additionally, replace usage of `192.168.0.100` with `localhost`.
|
||||
|
||||
:::
|
||||
|
||||
## Create Docker Compose File
|
||||
|
||||
Create a new folder for multi-scrobbler related data and then create `docker-compose.yml` with this content:
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
- BASE_URL="http://192.168.0.100:9078"
|
||||
# all Environmental Variables in below examples go here!
|
||||
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
ports:
|
||||
- "9078:9078"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## Setup Sources
|
||||
|
||||
**Sources** are the services multi-scrobbler monitors to look for listening activity.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="jellyfin" label="Jellyfin">
|
||||
Follow the Jellyfin configuration [instructions for setting up a **webhook**.](./configuration/configuration.mdx#jellyfin)
|
||||
|
||||
After webhook is setup add **at least one** of these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- JELLYFIN_USER=myUserName # comma-separated list of users to monitor
|
||||
- JELLYFIN_SERVER=myServerName # comma-separated list of servers that should be monitored
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="spotify" label="Spotify">
|
||||
To access your Spotify activity you must [register a Spotify application](https://developer.spotify.com/dashboard) to get a
|
||||
**Client ID/Secret**.
|
||||
|
||||
When creating the application add this to **Redirect URIs**
|
||||
|
||||
```
|
||||
http://192.168.0.100:9078/callback
|
||||
```
|
||||
|
||||
After the application is created add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- SPOTIFY_CLIENT_ID=yourClientId
|
||||
- SPOTIFY_CLIENT_SECRET=yourClientSecret
|
||||
```
|
||||
|
||||
Later, after [starting multi-scrobbler](#start-multi-scrobbler), visit the dashboard at `http://192.168.0.100:9078` and click **(Re)authenticate** on the Spotify card to authorize multi-scrobbler to use your account. Monitoring will begin automatically after authorization is complete.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Setup Clients
|
||||
|
||||
**Clients** are services that store scrobbles. Multi-scrobbler will scrobble all listening activity from the **Sources** you configured to all **Clients** you configure here.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="maloja" label="Maloja">
|
||||
Setup a [Maloja server](https://github.com/krateng/maloja?tab=readme-ov-file#how-to-install) if you have not already done this.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Maloja Setup Intructions</summary>
|
||||
|
||||
Using Maloja's example `docker-compose.yml`:
|
||||
|
||||
```yaml reference title="~/malojaData/docker-compose.yml"
|
||||
https://github.com/krateng/maloja/blob/master/example-compose.yml
|
||||
```
|
||||
|
||||
Uncomment `environment` and add `MALOJA_FORCE_PASSWORD=CHANGE_ME` to set an admin password
|
||||
|
||||
Start the container:
|
||||
|
||||
```shell title="~/malojaData"
|
||||
docker compose up -d
|
||||
```
|
||||
</details>
|
||||
|
||||
* Navigate to the Admin Panel (Cog in upper-right corner) -> API Keys (or at http://192.168.0.100:42010/admin_apikeys)
|
||||
* Create a **New Key** and then copy the generated key value
|
||||
|
||||
Finally, add these values to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- MALOJA_URL="http://192.168.0.100:42010"
|
||||
- MALOJA_API_KEY=myApiKey
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="lastfm" label="Last.fm">
|
||||
[Register for an API account at Last.fm.](https://www.last.fm/api/account/create)
|
||||
|
||||
Use the following for **Callback URL**:
|
||||
|
||||
```
|
||||
http://192.168.0.100:9078/lastfm/callback
|
||||
```
|
||||
|
||||
After account creation use the displayed information and add these values to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- LASTFM_API_KEY=myApiKey
|
||||
- LASTFM_SECRET=myApiSecret
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Start Multi-Scrobbler
|
||||
|
||||
:::tip
|
||||
|
||||
If you are running your multi-scrobbler container on a Linux host see [these instructions for setting proper file permissions.](./installation/installation.mdx#linux-host)
|
||||
|
||||
:::
|
||||
|
||||
From the same directory as the [`docker-compose.yml` you created earlier](#create-docker-compose-file) start the container:
|
||||
|
||||
```shell title="~/msData"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
You're done! Multi-scrobbler is now running. It will monitor the sources you configured and scrobble to clients you set up.
|
||||
|
||||
Visit `http://192.168.0.100:9078` to see the dashboard where
|
||||
|
||||
* configured Sources/Clients
|
||||
* show current status and authentication options
|
||||
* display statistics about discovered/scrobbled tracks and Now Playing status
|
||||
* a real-time log shows multi-scrobbler's activity
|
||||
|
||||
## Next Steps
|
||||
|
||||
* See more advanced docker options as well as other install methods in the [**Installation**](./installation/installation.mdx#docker) docs
|
||||
* Review the [**Configuration**](./configuration/configuration.mdx) docs
|
||||
* Learn about how to configure multi-scrobbler using files for more complicated Source/Client scenarios
|
||||
* See all available Sources/Clients alongside configuration examples
|
||||
* Learn how to set up [notification webhooks](./configuration/configuration.mdx#webhook-configurations)
|
||||
* Check out the [kitchensink example](./configuration/kitchensink.md)
|
||||
* Consult the [**FAQ**](./FAQ.md) for solutions to common problems
|
||||
@@ -1,11 +1,9 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
import type { Config } from '@docusaurus/types';
|
||||
import * as themes from 'prism-react-renderer';
|
||||
//import sidebars from './sidebars';
|
||||
|
||||
const lightCodeTheme = require('prism-react-renderer/themes/github');
|
||||
const darkCodeTheme = require('prism-react-renderer/themes/dracula');
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
const config: Config = {
|
||||
title: 'Multi-Scrobbler',
|
||||
tagline: 'Scrobble all the things',
|
||||
favicon: 'img/favicon.ico',
|
||||
@@ -14,7 +12,7 @@ const config = {
|
||||
url: 'https://foxxmd.github.io',
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: '/multi-scrobbler',
|
||||
baseUrl: process.env.DOCS_BASE !== undefined && process.env.DOCS_BASE !== '' ? process.env.DOCS_BASE : '/docs',
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
@@ -23,7 +21,7 @@ const config = {
|
||||
|
||||
trailingSlash: false,
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenLinks: 'warn',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
@@ -38,10 +36,9 @@ const config = {
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: require.resolve('./sidebars.js'),
|
||||
sidebarPath: './sidebars.ts',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
@@ -57,23 +54,33 @@ const config = {
|
||||
blog: false,
|
||||
|
||||
theme: {
|
||||
customCss: require.resolve('./src/css/custom.css'),
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
}),
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
themes: ["docusaurus-json-schema-plugin"],
|
||||
plugins: [
|
||||
themes: [
|
||||
"docusaurus-json-schema-plugin",
|
||||
[
|
||||
require.resolve("@cmfcmf/docusaurus-search-local"),
|
||||
"@easyops-cn/docusaurus-search-local",
|
||||
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
|
||||
{
|
||||
// ... Your options.
|
||||
// `hashed` is recommended as long-term-cache of index file is possible.
|
||||
hashed: true,
|
||||
indexBlog: false,
|
||||
// For Docs using Chinese, The `language` is recommended to set to:
|
||||
// ```
|
||||
// language: ["en", "zh"],
|
||||
// ```
|
||||
},
|
||||
],
|
||||
'docusaurus-theme-github-codeblock'
|
||||
],
|
||||
plugins: [
|
||||
],
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
{
|
||||
// Replace with your project's social card
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
navbar: {
|
||||
@@ -99,6 +106,11 @@ const config = {
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://foxxmd.github.io/multi-scrobbler/',
|
||||
label: 'Website',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
@@ -113,31 +125,18 @@ const config = {
|
||||
},
|
||||
{
|
||||
label: 'Installation',
|
||||
to: '/docs/installation',
|
||||
to: 'docs/installation',
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
to: '/docs/configuration',
|
||||
to: 'docs/configuration',
|
||||
},
|
||||
{
|
||||
label: 'Development',
|
||||
to: 'docs/development/dev-common',
|
||||
},
|
||||
],
|
||||
},
|
||||
/* {
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'Stack Overflow',
|
||||
href: 'https://stackoverflow.com/questions/tagged/docusaurus',
|
||||
},
|
||||
{
|
||||
label: 'Discord',
|
||||
href: 'https://discordapp.com/invite/docusaurus',
|
||||
},
|
||||
{
|
||||
label: 'Twitter',
|
||||
href: 'https://twitter.com/docusaurus',
|
||||
},
|
||||
],
|
||||
},*/
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
@@ -145,16 +144,32 @@ const config = {
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/foxxmd/multi-scrobbler',
|
||||
},
|
||||
{
|
||||
label: 'Website',
|
||||
href: 'https://foxxmd.github.io/multi-scrobbler/',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} Multi-Scrobbler. Built with Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
theme: themes.themes.github,
|
||||
darkTheme: themes.themes.dracula,
|
||||
additionalLanguages: ['json','json5','typescript', 'docker', 'bash', 'ini']
|
||||
},
|
||||
}),
|
||||
colorMode: {
|
||||
defaultMode: 'dark',
|
||||
disableSwitch: false,
|
||||
respectPrefersColorScheme: false,
|
||||
},
|
||||
codeblock: {
|
||||
showGithubLink: true,
|
||||
githubLinkLabel: 'View on GitHub',
|
||||
showRunmeLink: false,
|
||||
runmeLinkLabel: 'Checkout via Runme'
|
||||
}
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
|
||||
@@ -167,4 +182,4 @@ if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
|
||||
config.scripts.push(script)
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
export default config;
|
||||
Generated
+6283
-4056
File diff suppressed because it is too large
Load Diff
+29
-25
@@ -3,32 +3,36 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"start": "npx docusaurus start",
|
||||
"build": "npx docusaurus build",
|
||||
"swizzle": "npx docusaurus swizzle",
|
||||
"deploy": "npx docusaurus deploy",
|
||||
"clear": "npx docusaurus clear",
|
||||
"serve": "npx docusaurus serve",
|
||||
"write-translations": "npx docusaurus write-translations",
|
||||
"write-heading-ids": "npx docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cmfcmf/docusaurus-search-local": "^1.1.0",
|
||||
"@docusaurus/core": "2.4.1",
|
||||
"@docusaurus/preset-classic": "2.4.1",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-json-schema-plugin": "^1.6.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
"@docusaurus/core": "^3.4.0",
|
||||
"@docusaurus/preset-classic": "^3.4.0",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.44.3",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-json-schema-plugin": "^1.12.1",
|
||||
"docusaurus-theme-github-codeblock": "^2.0.2",
|
||||
"micromark-extension-directive": "^3.0.1",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.1",
|
||||
"@tsconfig/docusaurus": "^1.0.5",
|
||||
"typescript": "^4.7.4"
|
||||
"@docusaurus/module-type-aliases": "^3.4.0",
|
||||
"@docusaurus/tsconfig": "^3.4.0",
|
||||
"@docusaurus/types": "^3.4.0",
|
||||
"@types/react": "^18.2.29",
|
||||
"typescript": "~5.2.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -37,12 +41,12 @@
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14"
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
|
||||
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
@@ -9,10 +11,7 @@
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
const sidebars: SidebarsConfig = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [
|
||||
{type: 'autogenerated', dirName: '.'},
|
||||
@@ -37,4 +36,4 @@ const sidebars = {
|
||||
*/
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
||||
export default sidebars;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { Fragment } from "react"
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import Admonition from '@theme/Admonition';
|
||||
import ErrorBoundary from "@docusaurus/ErrorBoundary"
|
||||
import Error from "@theme/Error"
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import error = Simulate.error;
|
||||
|
||||
export interface AIOProps {
|
||||
data: string
|
||||
client?: boolean
|
||||
name: string
|
||||
}
|
||||
|
||||
const AIOExample = (props: AIOProps) => {
|
||||
const {
|
||||
data,
|
||||
name,
|
||||
client = false
|
||||
} = props;
|
||||
|
||||
let configObj;
|
||||
// eslint-disable-next-line prefer-const
|
||||
try {
|
||||
configObj = JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return <Admonition type="danger" title="Unexpected Error">
|
||||
<p>Example component crashed because of error!</p>
|
||||
<CodeBlock>{e.message}</CodeBlock>
|
||||
</Admonition>
|
||||
}
|
||||
configObj[0].type = name;
|
||||
const configType = client ? 'clients' : 'sources';
|
||||
|
||||
const aio = {[configType]: configObj};
|
||||
return <CodeBlock title="CONFIG_DIR/config.json" language="json5">{JSON.stringify(aio, null, 2)}</CodeBlock>
|
||||
}
|
||||
|
||||
const WrappedAIOExample = (props: AIOProps) => {
|
||||
return <ErrorBoundary
|
||||
fallback={({error}) => (
|
||||
<div>
|
||||
<p>Example component crashed because of error: {error.message}.</p>
|
||||
</div>
|
||||
)}
|
||||
><AIOExample {...props} /></ErrorBoundary>
|
||||
}
|
||||
|
||||
export default WrappedAIOExample;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, {PropsWithChildren, Fragment} from "react"
|
||||
|
||||
export interface SchemaLinkProps {
|
||||
objectName: string
|
||||
lower?: boolean
|
||||
client?: boolean
|
||||
}
|
||||
|
||||
const sourceURL = 'https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fsource.json';
|
||||
const clientURL = 'https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Fclient.json';
|
||||
|
||||
const SchemaLink = (props: PropsWithChildren<SchemaLinkProps>) => {
|
||||
const {
|
||||
children,
|
||||
lower,
|
||||
client = false
|
||||
} = props;
|
||||
let content = children;
|
||||
if(content === undefined) {
|
||||
content = <Fragment>{lower ? 'explore' : 'Explore'} the schema with an example and live editor/validator</Fragment>
|
||||
}
|
||||
const definition = `https://json-schema.app/view/%23/%23%2Fdefinitions%2F${props.objectName}`;
|
||||
const url = client ? clientURL : sourceURL;
|
||||
return <a target="_blank" href={`${definition}?url=${url}`}>{content}</a>
|
||||
}
|
||||
|
||||
export default SchemaLink;
|
||||
@@ -20,14 +20,15 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [WebScrobbler](docs/configuration#webscrobbler)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz--source-)
|
||||
* [Deezer](docs/configuration#deezer)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz-source)
|
||||
* [~~Deezer~~](docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Mopidy](docs/configuration#mopidy)
|
||||
* [JRiver](docs/configuration#jriver)
|
||||
* [Kodi](docs/configuration#kodi)
|
||||
* [Google Cast (Chromecast)](/docs/configuration#google-cast--chromecast-)
|
||||
* [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast)
|
||||
* [Musikcube](docs/configuration#musikcube)
|
||||
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
@@ -38,8 +39,13 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
* 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/transforms)
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](docs/quickstart)
|
||||
|
||||
<img src={require('/img/status-ui.png').default} width="800"/>
|
||||
|
||||
**Why should I use this over a browser extension and/or mobile app scrobbler?**
|
||||
|
||||
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
|
||||
@@ -49,9 +55,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm--source-) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
<img src={require('/img/status-ui.png').default} width="800"/>
|
||||
Yes! You can use [Last.fm as a **Source**](docs/configuration#lastfm-source) or [Listenbrainz as a **Source**](docs/configuration#listenbrainz-source) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location.
|
||||
|
||||
## How Does multi-scrobbler (MS) Work?
|
||||
|
||||
@@ -76,6 +80,10 @@ Client configurations consist of:
|
||||
* A friendly name.
|
||||
* Any data needed to communicate or authenticate with the Client.
|
||||
|
||||
## Quick Start
|
||||
|
||||
[See the **Quick Start Guide**](docs/quickstart)
|
||||
|
||||
## Installation
|
||||
|
||||
[See the **Installation** documentation](docs/installation)
|
||||
@@ -99,6 +107,10 @@ On first startup you may need to authorize Spotify and/or Last.fm by visiting th
|
||||
|
||||
Having issues with connections or configuration? Check the [FAQ](docs/FAQ) before creating an issue!
|
||||
|
||||
## Development
|
||||
|
||||
[Detailed architecture and development guides for Sources/Clients](docs/development/dev-common)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -17,7 +17,9 @@ function PlaygroundComponent(): JSX.Element {
|
||||
>
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
// required to keep SSR from trying to render monaco/etc...
|
||||
const PlaygroundInnerComponent =
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require("@site/src/components/PlaygroundInner").default
|
||||
return <PlaygroundInnerComponent />
|
||||
}}
|
||||
|
||||
+1208
-675
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/LastfmClientConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ListenBrainzClientConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MalojaClientConfig"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"CommonClientData": {
|
||||
"title": "CommonClientData",
|
||||
"type": "object"
|
||||
},
|
||||
"CommonClientOptions": {
|
||||
"properties": {
|
||||
"checkExistingScrobbles": {
|
||||
"default": true,
|
||||
"description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "checkExistingScrobbles",
|
||||
"type": "boolean"
|
||||
},
|
||||
"deadLetterRetries": {
|
||||
"default": 1,
|
||||
"description": "Number of times MS should automatically retry scrobbles in dead letter queue",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "deadLetterRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"refreshEnabled": {
|
||||
"default": true,
|
||||
"description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshInitialCount": {
|
||||
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
|
||||
"title": "refreshInitialCount",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"verbose": {
|
||||
"description": "Options used for increasing verbosity of logging in MS (used for debugging)",
|
||||
"properties": {
|
||||
"match": {
|
||||
"$ref": "#/definitions/MatchLoggingOptions",
|
||||
"title": "match"
|
||||
}
|
||||
},
|
||||
"title": "verbose",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "CommonClientOptions",
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmClientConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using LastFM as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"title": "configureAs",
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CommonClientData"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LastfmData"
|
||||
}
|
||||
],
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"title": "LastfmClientConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"LastfmData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key generated from Last.fm account",
|
||||
"examples": [
|
||||
"787c921a2a2ab42320831aba0c8f2fc2"
|
||||
],
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"redirectUri": {
|
||||
"default": "http://localhost:9078/lastfm/callback",
|
||||
"description": "Optional URI to use for callback. Specify this if callback should be different than the default. MUST have \"lastfm/callback\" in the URL somewhere.",
|
||||
"examples": [
|
||||
"http://localhost:9078/lastfm/callback"
|
||||
],
|
||||
"title": "redirectUri",
|
||||
"type": "string"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Secret generated from Last.fm account",
|
||||
"examples": [
|
||||
"ec42e09d5ae0ee0f0816ca151008412a"
|
||||
],
|
||||
"title": "secret",
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"description": "Optional session id returned from a completed auth flow",
|
||||
"title": "session",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"secret"
|
||||
],
|
||||
"title": "LastfmData",
|
||||
"type": "object"
|
||||
},
|
||||
"ListenBrainzClientConfig": {
|
||||
"properties": {
|
||||
"configureAs": {
|
||||
"default": "client",
|
||||
"description": "Should always be `client` when using Listenbrainz as a client",
|
||||
"enum": [
|
||||
"client",
|
||||
"source"
|
||||
],
|
||||
"examples": [
|
||||
"client"
|
||||
],
|
||||
"title": "configureAs",
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/ListenBrainzClientData",
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"title": "ListenBrainzClientConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"ListenBrainzClientData": {
|
||||
"properties": {
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"token": {
|
||||
"description": "User token for the user to scrobble for",
|
||||
"examples": [
|
||||
"6794186bf-1157-4de6-80e5-uvb411f3ea2b"
|
||||
],
|
||||
"title": "token",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "https://api.listenbrainz.org/",
|
||||
"description": "URL for the ListenBrainz server, if not using the default",
|
||||
"examples": [
|
||||
"https://api.listenbrainz.org/"
|
||||
],
|
||||
"title": "url",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"description": "Username of the user to scrobble for",
|
||||
"title": "username",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"token",
|
||||
"username"
|
||||
],
|
||||
"title": "ListenBrainzClientData",
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientConfig": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/MalojaClientData",
|
||||
"description": "Specific data required to configure this client",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.",
|
||||
"examples": [
|
||||
"MyConfig"
|
||||
],
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/CommonClientOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"name"
|
||||
],
|
||||
"title": "MalojaClientConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"MalojaClientData": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"description": "API Key for Maloja server",
|
||||
"examples": [
|
||||
"myApiKey"
|
||||
],
|
||||
"title": "apiKey",
|
||||
"type": "string"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL for maloja server",
|
||||
"examples": [
|
||||
"http://localhost:42010"
|
||||
],
|
||||
"title": "url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey",
|
||||
"url"
|
||||
],
|
||||
"title": "MalojaClientData",
|
||||
"type": "object"
|
||||
},
|
||||
"MatchLoggingOptions": {
|
||||
"description": "Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.",
|
||||
"properties": {
|
||||
"confidenceBreakdown": {
|
||||
"default": false,
|
||||
"description": "Include confidence breakdowns in track match logging, if applicable",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "confidenceBreakdown",
|
||||
"type": "boolean"
|
||||
},
|
||||
"onMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track DOES match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "onMatch",
|
||||
"type": "boolean"
|
||||
},
|
||||
"onNoMatch": {
|
||||
"default": false,
|
||||
"description": "Log to DEBUG when a new track does NOT match an existing scrobble",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "onNoMatch",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "MatchLoggingOptions",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+739
-481
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@tsconfig/docusaurus/tsconfig.json",
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"resolveJsonModule": true,
|
||||
|
||||
+1
-48
@@ -1,48 +1 @@
|
||||
NOTE: This steps are for building the flatpak entirely locally, from source. If you want to install the application normally then [get it through flathub](/docs/installation.md#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)
|
||||
|
||||
# 1. Install Requirements
|
||||
|
||||
## Flatpak and flatpak-builder
|
||||
|
||||
Install [Flatpak](https://flatpak.org/setup/)
|
||||
|
||||
Install [flatpak-builder](https://docs.flatpak.org/en/latest/first-build.html#building-your-first-flatpak)
|
||||
|
||||
## [flatpak-node-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/node)
|
||||
|
||||
Requires python 3.7+, [pip](https://pip.pypa.io/en/stable/)/[pipx](https://pypa.github.io/pipx/)
|
||||
|
||||
# 2. Update Project source
|
||||
|
||||
Set the `branch` `tag` or `commit` to use for MS in the `git` source in [`io.github.foxxmd.multiscrobbler.yml`](/flatpak/io.github.foxxmd.multiscrobbler.yml)
|
||||
|
||||
# 3. Use `flatpak-node-generator` to generate sources
|
||||
|
||||
First, [make sure `node_modules` is deleted or empty.](https://github.com/flatpak/flatpak-builder-tools/issues/354#issuecomment-1478518442)
|
||||
|
||||
Then, from MS project root:
|
||||
|
||||
```shell
|
||||
flatpak-node-generator npm package-lock.json
|
||||
```
|
||||
|
||||
Move `generated-sources.json` into [`/flatpak`](/flatpak)
|
||||
|
||||
# 4. Build flatpak
|
||||
|
||||
From MS project root:
|
||||
|
||||
```shell
|
||||
cd flatpak
|
||||
flatpak-builder --repo=/home/yourUser/multi-scrobbler-flatpak/repo --state-dir=/home/yourUser/multi-scrobbler-flatpak/state /home/yourUser/multi-scrobbler-flatpak/build io.github.foxxmd.multiscrobbler.yml --force-clean --install --user
|
||||
```
|
||||
Add `--install --user` to have the app installed immediately.
|
||||
# 5. Run (Locally)
|
||||
|
||||
If built with `--install --user` you can now run MS using the command
|
||||
|
||||
```shell
|
||||
flatpak run -u io.github.foxxmd.multiscrobbler
|
||||
```
|
||||
See flatpak docs on the [docs website](https://foxxmd.github.io/multi-scrobbler/docs/development/flatpak) or at [`/docsite/docs/development/flatpak.md`](../docsite/docs/development/flatpak.md)
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
<li>Jellyfin</li>
|
||||
<li>WebScrobbler</li>
|
||||
<li>Mopidy</li>
|
||||
<li>Deezer</li>
|
||||
<li>JRiver</li>
|
||||
<li>Kodi</li>
|
||||
<li>Webscrobbler</li>
|
||||
@@ -50,6 +49,8 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.8.2" date="2024-08-20"/>
|
||||
<release version="0.8.1" date="2024-07-17"/>
|
||||
<release version="0.8.0" date="2024-07-08"/>
|
||||
<release version="0.7.1" date="2024-04-11"/>
|
||||
<release version="0.7.0" date="2024-03-26"/>
|
||||
|
||||
@@ -21,7 +21,8 @@ modules:
|
||||
npm_config_offline: 'true'
|
||||
build-commands:
|
||||
# install npm dependencies
|
||||
- npm install --offline
|
||||
- npm ci --offline
|
||||
- cd docsite && npm ci --offline && cd ../
|
||||
|
||||
# build app
|
||||
- NODE_ENV=production npm run build
|
||||
@@ -34,6 +35,8 @@ modules:
|
||||
|
||||
# remove dev dependencies
|
||||
- npm prune --production
|
||||
# remove docsite dependencies since we've built static site
|
||||
- rm -r docsite/node_modules
|
||||
|
||||
# copy node_modules needed to run app
|
||||
- cp -r node_modules/. /app/lib/node_modules
|
||||
@@ -45,6 +48,7 @@ modules:
|
||||
# copy app files to runtime dir
|
||||
- cp -r dist/. /app/lib/dist
|
||||
- cp -r src/. /app/lib/src
|
||||
- cp -r docsite/. /app/lib/docsite
|
||||
- cp -r config /app/lib/config
|
||||
- cp -r assets /app/lib/assets
|
||||
- cp -r public /app/lib/public
|
||||
@@ -89,4 +93,5 @@ modules:
|
||||
fi
|
||||
- cd /app/lib && CONFIG_DIR=$XDG_CONFIG_HOME LOG_DIR=$XDG_CONFIG_HOME IS_LOCAL=true NODE_ENV=production node_modules/.bin/tsx src/backend/index.ts
|
||||
|
||||
- generated-sources.json
|
||||
- generated-sources.0.json
|
||||
- generated-sources.1.json
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
|
||||
OVERWRITE="0"
|
||||
CONFIRM="1"
|
||||
# https://stackoverflow.com/a/14203146
|
||||
POSITIONAL_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-b|--buildpath)
|
||||
BUILDPATH="$2"
|
||||
shift # past argument
|
||||
shift # past value
|
||||
;;
|
||||
-o|--overwrite)
|
||||
OVERWRITE="1"
|
||||
shift # past argument
|
||||
;;
|
||||
-y|--yes)
|
||||
CONFIRM="0"
|
||||
shift # past argument
|
||||
;;
|
||||
-*|--*)
|
||||
echo "Unknown option $1"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
POSITIONAL_ARGS+=("$1") # save positional arg
|
||||
shift # past argument
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
set -- "${POSITIONAL_ARGS[@]}"
|
||||
|
||||
if [ ! -f "./io.github.foxxmd.multiscrobbler.yml" ]; then
|
||||
echo "Run this script inside the 'flatpak' directory!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${BUILDPATH}" ]; then
|
||||
printf "\nNo build path set, using ./build\n"
|
||||
BUILDPATH="${PWD##*/}/build"
|
||||
fi
|
||||
|
||||
printf '\nBuild Path: %s' "${BUILDPATH}"
|
||||
if [ "$OVERWRITE" = "1" ]; then echo 'Overwrite Sources: True'; else printf 'Overwrite Sources: False\n'; fi
|
||||
|
||||
if [ "$CONFIRM" = "1" ]; then
|
||||
read -p "Continue? (Y/N): " confirm && [[ $confirm == [yY] || $confirm == [yY][eE][sS] ]] || exit 1
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
|
||||
cd ../
|
||||
|
||||
if [ -d ./node_modules ]; then
|
||||
echo 'Project node_modules exists, deleting...'
|
||||
rm -r node_modules
|
||||
fi
|
||||
|
||||
if [ -d ./docsite/node_modules ]; then
|
||||
echo 'Docsite node_modules exists, deleting...'
|
||||
rm -r docsite/node_modules
|
||||
fi
|
||||
|
||||
GENERATE_SOURCES="1"
|
||||
|
||||
if [ -f "flatpak/generated-sources.0.json" ] || [ -f "flatpak/generated-sources.1.json" ]; then
|
||||
if [ "$OVERWRITE" = "0" ]; then
|
||||
echo 'Generated sources exist, will not overwrite.';
|
||||
GENERATE_SOURCES=0
|
||||
else
|
||||
echo 'Deleting existing sources...';
|
||||
rm -f flatpak/generated-sources.0.json
|
||||
rm -f flatpak/generated-sources.1.json
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$GENERATE_SOURCES" = "1" ]; then
|
||||
printf '\nGenerating project sources...\n'
|
||||
rm -f generated-sources.json
|
||||
flatpak-node-generator npm package-lock.json
|
||||
mv generated-sources.json flatpak/generated-sources.0.json
|
||||
|
||||
printf '\nGenerating docsite sources...\n'
|
||||
flatpak-node-generator npm docsite/package-lock.json
|
||||
mv generated-sources.json flatpak/generated-sources.1.json
|
||||
fi
|
||||
|
||||
cd flatpak || exit
|
||||
|
||||
mkdir -p "$BUILDPATH"
|
||||
|
||||
printf '\nBuilding flatpak app...\n'
|
||||
set -x
|
||||
flatpak-builder --repo="$BUILDPATH"/repo --state-dir="$BUILDPATH"/state "$BUILDPATH"/build io.github.foxxmd.multiscrobbler.yml --force-clean --install --user
|
||||
# https://stackoverflow.com/questions/2853803/how-to-echo-shell-commands-as-they-are-executed#comment135696350_13718771
|
||||
{ set +x; } &> /dev/null
|
||||
|
||||
echo 'Done!'
|
||||
Generated
+315
-118
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20,6 +20,7 @@
|
||||
"@foxxmd/chromecast-client": "^1.0.4",
|
||||
"@foxxmd/get-version": "^0.0.3",
|
||||
"@foxxmd/logging": "^0.1.14",
|
||||
"@foxxmd/regex-buddy-core": "^0.1.0",
|
||||
"@foxxmd/string-sameness": "^0.4.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
@@ -45,12 +46,13 @@
|
||||
"fixed-size-list": "^0.3.0",
|
||||
"formidable": "^2.1",
|
||||
"gotify": "^1.1.0",
|
||||
"iso-websocket": "^0.2.0",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"kodi-api": "^0.2.1",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"mopidy": "^1.3.0",
|
||||
"mpd-api": "^1.1.2",
|
||||
"nanoid": "^3.3.1",
|
||||
"normalize-url": "^8.0.1",
|
||||
"ntfy": "^1.5.4",
|
||||
@@ -77,7 +79,7 @@
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
"@eslint/js": "^8.56.0",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@faker-js/faker": "^9.0.0-rc.1",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -114,14 +116,13 @@
|
||||
"msw": "^2.1.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"ts-essentials": "^9.1.2",
|
||||
"typedoc": "^0.25",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^7.0.1",
|
||||
"typescript-json-schema": "~0.55",
|
||||
"vite": "^5.2.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"node": ">=18.19.1",
|
||||
"npm": ">=9.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1120,9 +1121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@faker-js/faker": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz",
|
||||
"integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==",
|
||||
"version": "9.0.0-rc.1",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.0-rc.1.tgz",
|
||||
"integrity": "sha512-d9uL+O7Bud4W0axuHYxjCsfwazwo6iPRbgDkIPgWhtxpnziHqbFafOuKubnQ++4V31dI9NYYkajzGNJADHas4A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1131,8 +1132,8 @@
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0",
|
||||
"npm": ">=6.14.13"
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
@@ -1221,6 +1222,18 @@
|
||||
"npm": ">=9.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@foxxmd/regex-buddy-core": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@foxxmd/regex-buddy-core/-/regex-buddy-core-0.1.0.tgz",
|
||||
"integrity": "sha512-CzmFvnbl2mWnH4jJZzBIe6HwLCKFfahHMKUvj3IB6bCnHn0yTO2BKVpm6tqTvIw8BNXqggbiONp5mMLe8DroLA==",
|
||||
"dependencies": {
|
||||
"@stdlib/regexp-regexp": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@foxxmd/string-sameness": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@foxxmd/string-sameness/-/string-sameness-0.4.0.tgz",
|
||||
@@ -2015,6 +2028,188 @@
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/error-tools-fmtprodmsg": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/error-tools-fmtprodmsg/-/error-tools-fmtprodmsg-0.2.1.tgz",
|
||||
"integrity": "sha512-SaxvGeGfWfda/O3rTNGRGBzAL9gsY/yd8n1hXwzOl/2aUHf8nxcf6Fz6/BQ5PguT0GiBkca19XEhHZZHxX3X/g==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/stdlib"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/regexp-regexp": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/regexp-regexp/-/regexp-regexp-0.2.1.tgz",
|
||||
"integrity": "sha512-f25kXWc73YVPxDxC8/25NM6AKHn3gH5WVcQfhjyy1FMM++XLNFy2M+1sdAyQ8PnlZ9qJwtn3/NjbACeETGfYHQ==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stdlib/utils-define-nonenumerable-read-only-property": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/stdlib"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/string-base-format-interpolate": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/string-base-format-interpolate/-/string-base-format-interpolate-0.2.1.tgz",
|
||||
"integrity": "sha512-Uxz89eUi4m9yao4VjsqXIxLIF7qDmqEAH0e+XBRWRGC2zx6DhmK2kLnaU0xW69+VJPn3dq4itxq0oryw2E+qIQ==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/stdlib"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/string-base-format-tokenize": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/string-base-format-tokenize/-/string-base-format-tokenize-0.2.1.tgz",
|
||||
"integrity": "sha512-3Ut96pmCgEFArrdwXKm1q0j1FOqTnG/uOsh24uYNU/ABRsMOOajRlAjCCdQv9f8P916qPrSnF1V3Pd18LAaksg==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/stdlib"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/string-format": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/string-format/-/string-format-0.2.1.tgz",
|
||||
"integrity": "sha512-+HpXkEJ0Z4gthH5KicXvRRJiCiCTSrKzM+mS8N6vwaAD+OG+Oq8Cn43XBD1ic/UHROI9un42MruF1ZLlkSmdOw==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stdlib/string-base-format-interpolate": "^0.2.1",
|
||||
"@stdlib/string-base-format-tokenize": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/stdlib"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/utils-define-nonenumerable-read-only-property": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/utils-define-nonenumerable-read-only-property/-/utils-define-nonenumerable-read-only-property-0.2.1.tgz",
|
||||
"integrity": "sha512-L8fs1kI79T2RQIg8rHR9aQnnSDELqiDGWbK3jA1NP8iW+ydxlxXyO8Dw17fBCXVua3Y19a1NVyGtIN5WGe2UCw==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stdlib/utils-define-property": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/stdlib"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/utils-define-property": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/utils-define-property/-/utils-define-property-0.2.3.tgz",
|
||||
"integrity": "sha512-+EzWImaQR/6XNFbXIITFi3PLQGTbKVIWSYxJfHXAuTtibAMnhHOWvEzKOumVe/Q4Cdsrc3/PIkpjJzliqAX9AA==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stdlib/error-tools-fmtprodmsg": "^0.2.1",
|
||||
"@stdlib/string-format": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/stdlib"
|
||||
}
|
||||
},
|
||||
"node_modules/@supercharge/promise-pool": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@supercharge/promise-pool/-/promise-pool-3.2.0.tgz",
|
||||
@@ -2151,6 +2346,11 @@
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
@@ -3012,12 +3212,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-sequence-parser": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz",
|
||||
"integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -4859,6 +5053,22 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "16.5.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
|
||||
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
|
||||
"dependencies": {
|
||||
"readable-web-to-node-stream": "^3.0.0",
|
||||
"strtok3": "^6.2.4",
|
||||
"token-types": "^4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -6070,15 +6280,15 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
||||
},
|
||||
"node_modules/iso-websocket": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/iso-websocket/-/iso-websocket-0.2.0.tgz",
|
||||
"integrity": "sha512-imBalzmPSq0C9CfMouimB2kZ5X1qS4Yai8kGTQdluGRb0T0iu+BkPcakFelh4FIlTM8y6+BNuCEGog3lf8HC4A==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/iso-websocket/-/iso-websocket-0.3.0.tgz",
|
||||
"integrity": "sha512-RCzPkKMtX36F1FnoII4TO42aQF+ypGgtNuIamR2TwM+9a8JGBbxkxOcFf1WJKsSAh8sv1HXSjR+Lu+jGz/6oVA==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"debug": "^4.3.5",
|
||||
"retry": "^0.13.1",
|
||||
"typescript-event-target": "^1.1.0",
|
||||
"unws": "^0.2.4",
|
||||
"ws": "^8.16.0"
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic-ws": {
|
||||
@@ -6445,12 +6655,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz",
|
||||
"integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
||||
@@ -6661,12 +6865,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lunr": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
|
||||
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -6698,18 +6896,6 @@
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
||||
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
@@ -6965,6 +7151,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mpd-api": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/mpd-api/-/mpd-api-1.1.2.tgz",
|
||||
"integrity": "sha512-UWSkIzYQnTvuvhLTfD0bIhBa4PCGCz6OsnDlojp319khsL8xXwS4qLVyJ+R5J7gNUTWiV9Y6d1j6apSYt2WDVA==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
"file-type": "^16.5.3",
|
||||
"mpd2": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mpd2": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/mpd2/-/mpd2-1.0.5.tgz",
|
||||
"integrity": "sha512-DcIy3jISfjmFTlOpwDE2KDhBLcGuFIAMTvY23OMkCikJXRnDqqm1jaHG3/LVYkKjrUVzQhlABW31roGqv/esRw==",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
@@ -7958,6 +8165,18 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/peek-readable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
|
||||
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
|
||||
@@ -8664,6 +8883,21 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-web-to-node-stream": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
|
||||
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
|
||||
"dependencies": {
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -9202,18 +9436,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shiki": {
|
||||
"version": "0.14.7",
|
||||
"resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz",
|
||||
"integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-sequence-parser": "^1.1.0",
|
||||
"jsonc-parser": "^3.2.0",
|
||||
"vscode-oniguruma": "^1.7.0",
|
||||
"vscode-textmate": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
|
||||
@@ -9469,6 +9691,22 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"peek-readable": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
@@ -9787,6 +10025,22 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
|
||||
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/touch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
||||
@@ -9968,51 +10222,6 @@
|
||||
"is-typedarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc": {
|
||||
"version": "0.25.13",
|
||||
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz",
|
||||
"integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lunr": "^2.3.9",
|
||||
"marked": "^4.3.0",
|
||||
"minimatch": "^9.0.3",
|
||||
"shiki": "^0.14.7"
|
||||
},
|
||||
"bin": {
|
||||
"typedoc": "bin/typedoc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc/node_modules/minimatch": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
|
||||
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
@@ -10379,18 +10588,6 @@
|
||||
"picocolors": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-oniguruma": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
|
||||
"integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vscode-textmate": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz",
|
||||
"integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -10537,9 +10734,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
||||
+11
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
@@ -10,16 +10,17 @@
|
||||
"schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
|
||||
"typedoc": "typedoc",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "npm run -s test:backend",
|
||||
"test:backend": "mocha --reporter spec --recursive src/backend/tests/**/*.test.ts",
|
||||
"fileEndings": "jscodeshift --transformFrom js --transformTo none --importTypes relative --extensions=ts --parser tsx --transform codeshift/transform.ts src/backend",
|
||||
"dev": "nodemon -w src/backend -x tsx src/backend/index.ts",
|
||||
"start": "NODE_ENV=production tsx src/backend/index.ts",
|
||||
"build:frontend": "APP_VERSION=$npm_package_version vite build",
|
||||
"build:frontend": "vite build",
|
||||
"build:backend": "tsc -p src/backend",
|
||||
"build": "npm run -s build:backend && npm run -s build:frontend",
|
||||
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
|
||||
"docs:install": "cd docsite && npm ci",
|
||||
"docs:start": "cd docsite && npm start",
|
||||
"docs:build": "cd docsite && npm run build",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"exports": {
|
||||
@@ -29,7 +30,7 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"node": ">=18.19.1",
|
||||
"npm": ">=9.1.0"
|
||||
},
|
||||
"repository": {
|
||||
@@ -54,6 +55,7 @@
|
||||
"@foxxmd/chromecast-client": "^1.0.4",
|
||||
"@foxxmd/get-version": "^0.0.3",
|
||||
"@foxxmd/logging": "^0.1.14",
|
||||
"@foxxmd/regex-buddy-core": "^0.1.0",
|
||||
"@foxxmd/string-sameness": "^0.4.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
@@ -79,12 +81,13 @@
|
||||
"fixed-size-list": "^0.3.0",
|
||||
"formidable": "^2.1",
|
||||
"gotify": "^1.1.0",
|
||||
"iso-websocket": "^0.2.0",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
"kodi-api": "^0.2.1",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"mopidy": "^1.3.0",
|
||||
"mpd-api": "^1.1.2",
|
||||
"nanoid": "^3.3.1",
|
||||
"normalize-url": "^8.0.1",
|
||||
"ntfy": "^1.5.4",
|
||||
@@ -111,7 +114,7 @@
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
"@eslint/js": "^8.56.0",
|
||||
"@faker-js/faker": "^8.1.0",
|
||||
"@faker-js/faker": "^9.0.0-rc.1",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -148,7 +151,6 @@
|
||||
"msw": "^2.1.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"ts-essentials": "^9.1.2",
|
||||
"typedoc": "^0.25",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^7.0.1",
|
||||
"typescript-json-schema": "~0.55",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { childLogger, Logger } from "@foxxmd/logging";
|
||||
import { searchAndReplace, SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core";
|
||||
import { compare } from "compare-versions";
|
||||
import { ObjectPlayData, PlayData, PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString } from "../../core/StringUtils.js";
|
||||
import { configPartsToStrongParts, configValToSearchReplace } from "../utils.js";
|
||||
import { hasNodeNetworkException } from "./errors/NodeErrors.js";
|
||||
import { hasUpstreamError } from "./errors/UpstreamError.js";
|
||||
import { PlayTransformParts, PlayTransformRules, TRANSFORM_HOOK, TransformHook } from "./infrastructure/Atomic.js";
|
||||
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
|
||||
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
|
||||
|
||||
export default abstract class AbstractComponent {
|
||||
requiresAuth: boolean = false;
|
||||
@@ -13,13 +21,22 @@ export default abstract class AbstractComponent {
|
||||
|
||||
initializing: boolean = false;
|
||||
|
||||
config: CommonClientConfig | CommonSourceConfig;
|
||||
|
||||
transformRules!: PlayTransformRules;
|
||||
|
||||
logger: Logger;
|
||||
|
||||
protected constructor(config: CommonClientConfig | CommonSourceConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.logger.debug('Attempting to initialize...');
|
||||
try {
|
||||
this.initializing = true;
|
||||
await this.buildInitData();
|
||||
this.buildTransformRules();
|
||||
await this.checkConnection();
|
||||
await this.testAuth();
|
||||
this.logger.info('Fully Initialized!');
|
||||
@@ -72,6 +89,74 @@ export default abstract class AbstractComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
public buildTransformRules() {
|
||||
try {
|
||||
this.doBuildTransformRules();
|
||||
} catch (e) {
|
||||
this.buildOK = false;
|
||||
throw new Error('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
protected doBuildTransformRules() {
|
||||
const {
|
||||
options: {
|
||||
playTransform
|
||||
} = {}
|
||||
} = this.config;
|
||||
|
||||
if (playTransform === undefined) {
|
||||
this.transformRules = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
preCompare: preConfig,
|
||||
compare: {
|
||||
candidate: candidateConfig,
|
||||
existing: existingConfig,
|
||||
} = {},
|
||||
postCompare: postConfig
|
||||
} = playTransform;
|
||||
|
||||
let preCompare,
|
||||
candidate,
|
||||
existing,
|
||||
postCompare;
|
||||
|
||||
try {
|
||||
preCompare = configPartsToStrongParts(preConfig)
|
||||
} catch (e) {
|
||||
throw new Error('preCompare was not valid', {cause: e});
|
||||
}
|
||||
|
||||
try {
|
||||
candidate = configPartsToStrongParts(candidateConfig)
|
||||
} catch (e) {
|
||||
throw new Error('candidate was not valid', {cause: e});
|
||||
}
|
||||
|
||||
try {
|
||||
existing = configPartsToStrongParts(existingConfig)
|
||||
} catch (e) {
|
||||
throw new Error('existing was not valid', {cause: e});
|
||||
}
|
||||
|
||||
try {
|
||||
postCompare = configPartsToStrongParts(postConfig)
|
||||
} catch (e) {
|
||||
throw new Error('postCompare was not valid', {cause: e});
|
||||
}
|
||||
|
||||
this.transformRules = {
|
||||
preCompare,
|
||||
compare: {
|
||||
candidate,
|
||||
existing,
|
||||
},
|
||||
postCompare,
|
||||
}
|
||||
}
|
||||
|
||||
public async checkConnection() {
|
||||
try {
|
||||
@@ -111,10 +196,13 @@ export default abstract class AbstractComponent {
|
||||
protected doAuthentication = async (): Promise<boolean> => this.authed
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
testAuth = async (force: boolean = false) => {
|
||||
if(!this.requiresAuth) {
|
||||
return;
|
||||
}
|
||||
if(this.authed && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.authed = await this.doAuthentication();
|
||||
@@ -146,4 +234,143 @@ export default abstract class AbstractComponent {
|
||||
protected async postInitialize(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public transformPlay = (play: PlayObject, hookType: TransformHook, log?: boolean) => {
|
||||
|
||||
let logger: Logger;
|
||||
const labels = ['Play Transform', hookType];
|
||||
const getLogger = () => logger !== undefined ? logger : childLogger(this.logger, labels);
|
||||
|
||||
try {
|
||||
let hook: PlayTransformParts<SearchAndReplaceRegExp> | undefined;
|
||||
|
||||
switch (hookType) {
|
||||
case TRANSFORM_HOOK.preCompare:
|
||||
hook = this.transformRules.preCompare;
|
||||
break;
|
||||
case TRANSFORM_HOOK.candidate:
|
||||
hook = this.transformRules.compare?.candidate;
|
||||
break;
|
||||
case TRANSFORM_HOOK.existing:
|
||||
hook = this.transformRules.compare?.existing;
|
||||
break;
|
||||
case TRANSFORM_HOOK.postCompare:
|
||||
hook = this.transformRules.postCompare;
|
||||
break;
|
||||
}
|
||||
|
||||
if (hook === undefined) {
|
||||
return play;
|
||||
}
|
||||
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
artists,
|
||||
albumArtists,
|
||||
album
|
||||
} = {}
|
||||
} = play;
|
||||
|
||||
const transformedPlayData: Partial<ObjectPlayData> = {};
|
||||
|
||||
let isTransformed = false;
|
||||
|
||||
if (hook.title !== undefined && track !== undefined) {
|
||||
try {
|
||||
const t = searchAndReplace(track, hook.title);
|
||||
if (t !== track) {
|
||||
transformedPlayData.track = t.trim() === '' ? undefined : t;
|
||||
isTransformed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
getLogger().warn(new Error(`Failed to transform title: ${track}`, {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
if (hook.artists !== undefined && artists !== undefined && artists.length > 0) {
|
||||
const transformedArtists: string[] = [];
|
||||
let anyArtistTransformed = false;
|
||||
for (const artist of artists) {
|
||||
try {
|
||||
const t = searchAndReplace(artist, hook.artists);
|
||||
if (t !== artist) {
|
||||
anyArtistTransformed = true;
|
||||
isTransformed = true;
|
||||
}
|
||||
if(t.trim() !== '') {
|
||||
transformedArtists.push(t);
|
||||
}
|
||||
} catch (e) {
|
||||
getLogger().warn(new Error(`Failed to transform artist: ${artist}`, {cause: e}));
|
||||
transformedArtists.push(artist);
|
||||
}
|
||||
}
|
||||
if(anyArtistTransformed) {
|
||||
transformedPlayData.artists = transformedArtists;
|
||||
}
|
||||
}
|
||||
|
||||
if (hook.artists !== undefined && albumArtists !== undefined && albumArtists.length > 0) {
|
||||
const transformedArtists: string[] = [];
|
||||
let anyArtistTransformed = false;
|
||||
for (const artist of albumArtists) {
|
||||
try {
|
||||
const t = searchAndReplace(artist, hook.artists);
|
||||
if (t !== artist) {
|
||||
anyArtistTransformed = true;
|
||||
isTransformed = true;
|
||||
}
|
||||
if(t.trim() !== '') {
|
||||
transformedArtists.push(t);
|
||||
}
|
||||
} catch (e) {
|
||||
getLogger().warn(new Error(`Failed to transform albumArtist: ${artist}`, {cause: e}));
|
||||
transformedArtists.push(artist);
|
||||
}
|
||||
}
|
||||
if(anyArtistTransformed) {
|
||||
transformedPlayData.albumArtists = transformedArtists;
|
||||
}
|
||||
}
|
||||
|
||||
if (hook.album !== undefined && album !== undefined) {
|
||||
try {
|
||||
const t = searchAndReplace(album, hook.album);
|
||||
if (t !== album) {
|
||||
isTransformed = true;
|
||||
transformedPlayData.album = t.trim() === '' ? undefined : t;
|
||||
}
|
||||
} catch (e) {
|
||||
getLogger().warn(new Error(`Failed to transform album: ${album}`, {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
if(isTransformed) {
|
||||
|
||||
const transformedPlay = {
|
||||
...play,
|
||||
data: {
|
||||
...play.data,
|
||||
...transformedPlayData
|
||||
}
|
||||
}
|
||||
|
||||
const shouldLog = log ?? this.config.options?.playTransform?.log ?? true;
|
||||
if(shouldLog) {
|
||||
this.logger.debug({labels}, `Play transformed by ${hookType}:
|
||||
Original : ${buildTrackString(play, {include: ['artist', 'track', 'album']})}
|
||||
Transformed : ${buildTrackString(transformedPlay, {include: ['artist', 'track', 'album']})}
|
||||
`);
|
||||
}
|
||||
|
||||
return transformedPlay;
|
||||
}
|
||||
|
||||
return play;
|
||||
} catch (e) {
|
||||
getLogger().warn(new Error(`Unexpected error occurred, returning original play.`, {cause: e}));
|
||||
return play;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Logger } from '@foxxmd/logging';
|
||||
import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { Request, Response } from "express";
|
||||
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
|
||||
@@ -6,13 +7,56 @@ import { FixedSizeList } from 'fixed-size-list';
|
||||
import { PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
import TupleMap from "../TupleMap.js";
|
||||
|
||||
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver' | 'kodi' | 'webscrobbler' | 'chromecast' | 'musikcube';
|
||||
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver', 'kodi', 'webscrobbler', 'chromecast', 'musikcube'];
|
||||
export type SourceType =
|
||||
'spotify'
|
||||
| 'plex'
|
||||
| 'tautulli'
|
||||
| 'subsonic'
|
||||
| 'jellyfin'
|
||||
| 'lastfm'
|
||||
| 'deezer'
|
||||
| 'ytmusic'
|
||||
| 'mpris'
|
||||
| 'mopidy'
|
||||
| 'listenbrainz'
|
||||
| 'jriver'
|
||||
| 'kodi'
|
||||
| 'webscrobbler'
|
||||
| 'chromecast'
|
||||
| 'musikcube'
|
||||
| 'mpd';
|
||||
|
||||
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
'plex',
|
||||
'tautulli',
|
||||
'subsonic',
|
||||
'jellyfin',
|
||||
'lastfm',
|
||||
'deezer',
|
||||
'ytmusic',
|
||||
'mpris',
|
||||
'mopidy',
|
||||
'listenbrainz',
|
||||
'jriver',
|
||||
'kodi',
|
||||
'webscrobbler',
|
||||
'chromecast',
|
||||
'musikcube',
|
||||
'mpd'
|
||||
];
|
||||
|
||||
export type ClientType = 'maloja' | 'lastfm' | 'listenbrainz';
|
||||
export const clientTypes: ClientType[] = ['maloja', 'lastfm', 'listenbrainz'];
|
||||
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
|
||||
|
||||
export type ClientType =
|
||||
'maloja'
|
||||
| 'lastfm'
|
||||
| 'listenbrainz';
|
||||
export const clientTypes: ClientType[] = [
|
||||
'maloja',
|
||||
'lastfm',
|
||||
'listenbrainz'
|
||||
];
|
||||
|
||||
export type InitState = 0 | 1 | 2;
|
||||
export const NOT_INITIALIZED: InitState = 0;
|
||||
@@ -197,3 +241,30 @@ export interface MdnsDeviceInfo {
|
||||
export type AbstractApiOptions = Record<any, any> & { logger: Logger }
|
||||
|
||||
export type keyOmit<T, U extends keyof any> = T & { [P in U]?: never }
|
||||
|
||||
export type SearchAndReplaceTerm = string | SearchAndReplaceRegExp;
|
||||
|
||||
export interface PlayTransformParts<T> {
|
||||
title?: T[]
|
||||
artists?: T[]
|
||||
album?: T[]
|
||||
}
|
||||
|
||||
export interface PlayTransformHooks<T> {
|
||||
preCompare?: PlayTransformParts<T>
|
||||
compare?: {
|
||||
candidate?: PlayTransformParts<T>
|
||||
existing?: PlayTransformParts<T>
|
||||
}
|
||||
postCompare?: PlayTransformParts<T>
|
||||
}
|
||||
|
||||
export type PlayTransformRules = PlayTransformHooks<SearchAndReplaceRegExp>
|
||||
|
||||
export type TransformHook = 'preCompare' | 'compare' | 'candidate' | 'existing' | 'postCompare';
|
||||
export const TRANSFORM_HOOK = {
|
||||
preCompare: 'preCompare' as TransformHook,
|
||||
candidate: 'candidate' as TransformHook,
|
||||
existing: 'existing' as TransformHook,
|
||||
postCompare: 'postCompare' as TransformHook,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
|
||||
import { CommonConfig, CommonData, PlayTransformConfig, PlayTransformOptions, RequestRetryOptions } from "../common.js";
|
||||
|
||||
/**
|
||||
* Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging.
|
||||
@@ -37,6 +37,16 @@ export interface CommonClientOptions extends RequestRetryOptions {
|
||||
* @examples [true]
|
||||
* */
|
||||
refreshEnabled?: boolean
|
||||
/**
|
||||
* Force client to refresh scrobbled plays from upstream service if last refresh was at least X seconds ago
|
||||
*
|
||||
* **In most case this setting should NOT be used.** MS intelligently refreshes based on activity so using this setting may increase upstream service load and slow down scrobbles.
|
||||
*
|
||||
* This setting should only be used in specific scenarios where MS is handling multiple "relaying" client-services (IE lfm -> lz -> lfm) and there is the potential for a client to be out of sync after more than a few seconds.
|
||||
*
|
||||
* @examples [3]
|
||||
* */
|
||||
refreshStaleAfter?: number
|
||||
|
||||
/**
|
||||
* The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.
|
||||
@@ -63,6 +73,8 @@ export interface CommonClientOptions extends RequestRetryOptions {
|
||||
* @examples [1]
|
||||
* */
|
||||
deadLetterRetries?: number
|
||||
|
||||
playTransform?: PlayTransformOptions
|
||||
}
|
||||
|
||||
export interface CommonClientConfig extends CommonConfig {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { keyOmit } from "../Atomic.js";
|
||||
import { keyOmit, PlayTransformHooks, SearchAndReplaceTerm } from "../Atomic.js";
|
||||
|
||||
export interface CommonConfig {
|
||||
name?: string
|
||||
@@ -49,3 +49,7 @@ export interface PollingOptions {
|
||||
* */
|
||||
maxInterval?: number
|
||||
}
|
||||
|
||||
export type PlayTransformConfig = PlayTransformHooks<SearchAndReplaceTerm>;
|
||||
|
||||
export type PlayTransformOptions = PlayTransformConfig & { log?: boolean }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
|
||||
import { CommonConfig, CommonData, PlayTransformConfig, PlayTransformOptions, RequestRetryOptions } from "../common.js";
|
||||
|
||||
export interface SourceRetryOptions extends RequestRetryOptions {
|
||||
/**
|
||||
@@ -96,6 +96,8 @@ export interface CommonSourceOptions extends SourceRetryOptions {
|
||||
* * If not specified it defaults to the maximum number of listens the source API supports
|
||||
* */
|
||||
scrobbleBacklogCount?: number
|
||||
|
||||
playTransform?: PlayTransformOptions
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
|
||||
|
||||
export interface MPDData extends CommonSourceData {
|
||||
/**
|
||||
* URL:PORT of the MPD server to connect to
|
||||
*
|
||||
* To use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections
|
||||
*
|
||||
* @examples ["localhost:6600"]
|
||||
* @default "localhost:6600"
|
||||
* */
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* If using socket specify the path instead of url.
|
||||
*
|
||||
* trailing `~` is replaced by your home directory
|
||||
* */
|
||||
path?: string
|
||||
|
||||
/**
|
||||
* Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords
|
||||
* */
|
||||
password?: string
|
||||
|
||||
}
|
||||
|
||||
export interface MPDSourceOptions extends CommonSourceOptions {
|
||||
//disableDiscovery?: boolean
|
||||
}
|
||||
|
||||
export interface MPDSourceConfig extends CommonSourceConfig {
|
||||
data: MPDData
|
||||
options: MPDSourceOptions
|
||||
}
|
||||
|
||||
export interface MPDSourceAIOConfig extends MPDSourceConfig {
|
||||
type: 'mpd'
|
||||
}
|
||||
|
||||
export type PlayerState = 'play' | 'stop' | 'pause';
|
||||
|
||||
export interface StatusResponse {
|
||||
state: PlayerState
|
||||
/**
|
||||
* Position within the current song in seconds
|
||||
* */
|
||||
elapsed?: number
|
||||
/**
|
||||
* Duration of the current song in seconds
|
||||
* */
|
||||
duration?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CurrentSongResponse {
|
||||
file: string
|
||||
time: number
|
||||
name?: string
|
||||
performer?: string
|
||||
artist?: string
|
||||
album?: string
|
||||
albumartist?: string
|
||||
title?: string
|
||||
musicbrainz_albumartistid?: string
|
||||
musicbrainz_albumid?: string
|
||||
musicbrainz_artistid?: string
|
||||
musicbrainz_releasetrackid?: string
|
||||
musicbrainz_trackid?: string
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { KodiSourceAIOConfig, KodiSourceConfig } from "./kodi.js";
|
||||
import { LastFmSouceAIOConfig, LastfmSourceConfig } from "./lastfm.js";
|
||||
import { ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig } from "./listenbrainz.js";
|
||||
import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
|
||||
import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
|
||||
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
|
||||
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
|
||||
@@ -16,6 +17,40 @@ import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscro
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
|
||||
|
||||
|
||||
export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig | JRiverSourceConfig | KodiSourceConfig | WebScrobblerSourceConfig | ChromecastSourceConfig | MusikcubeSourceConfig;
|
||||
export type SourceConfig =
|
||||
SpotifySourceConfig
|
||||
| PlexSourceConfig
|
||||
| TautulliSourceConfig
|
||||
| DeezerSourceConfig
|
||||
| SubSonicSourceConfig
|
||||
| JellySourceConfig
|
||||
| LastfmSourceConfig
|
||||
| YTMusicSourceConfig
|
||||
| MPRISSourceConfig
|
||||
| MopidySourceConfig
|
||||
| ListenBrainzSourceConfig
|
||||
| JRiverSourceConfig
|
||||
| KodiSourceConfig
|
||||
| WebScrobblerSourceConfig
|
||||
| ChromecastSourceConfig
|
||||
| MusikcubeSourceConfig
|
||||
| MPDSourceConfig;
|
||||
|
||||
export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig | JRiverSourceAIOConfig | KodiSourceAIOConfig | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig | MusikcubeSourceAIOConfig;
|
||||
export type SourceAIOConfig =
|
||||
SpotifySourceAIOConfig
|
||||
| PlexSourceAIOConfig
|
||||
| TautulliSourceAIOConfig
|
||||
| DeezerSourceAIOConfig
|
||||
| SubsonicSourceAIOConfig
|
||||
| JellySourceAIOConfig
|
||||
| LastFmSouceAIOConfig
|
||||
| YTMusicSourceAIOConfig
|
||||
| MPRISSourceAIOConfig
|
||||
| MopidySourceAIOConfig
|
||||
| ListenBrainzSourceAIOConfig
|
||||
| JRiverSourceAIOConfig
|
||||
| KodiSourceAIOConfig
|
||||
| WebScrobblerSourceAIOConfig
|
||||
| ChromecastSourceAIOConfig
|
||||
| MusikcubeSourceAIOConfig
|
||||
| MPDSourceAIOConfig;
|
||||
|
||||
@@ -57,6 +57,15 @@
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshForce": {
|
||||
"default": false,
|
||||
"description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "refreshForce",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshInitialCount": {
|
||||
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
|
||||
"title": "refreshInitialCount",
|
||||
|
||||
@@ -969,6 +969,169 @@
|
||||
"title": "ListenBrainzSourceData",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDData": {
|
||||
"properties": {
|
||||
"password": {
|
||||
"description": "Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "If using socket specify the path instead of url.\n\ntrailing `~` is replaced by your home directory",
|
||||
"title": "path",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "localhost:6600",
|
||||
"description": "URL:PORT of the MPD server to connect to\n\nTo use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections",
|
||||
"examples": [
|
||||
"localhost:6600"
|
||||
],
|
||||
"title": "url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "MPDData",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/MPDData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/MPDSourceOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"mpd"
|
||||
],
|
||||
"title": "type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"options",
|
||||
"type"
|
||||
],
|
||||
"title": "MPDSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDSourceOptions": {
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logPlayerState": {
|
||||
"default": false,
|
||||
"description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklogCount": {
|
||||
"description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
|
||||
"title": "scrobbleBacklogCount",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"title": "MPDSourceOptions",
|
||||
"type": "object"
|
||||
},
|
||||
"MPRISData": {
|
||||
"properties": {
|
||||
"blacklist": {
|
||||
@@ -1440,6 +1603,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/MopidySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPDSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPRISSourceAIOConfig"
|
||||
},
|
||||
|
||||
@@ -346,6 +346,15 @@
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshForce": {
|
||||
"default": false,
|
||||
"description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "refreshForce",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshInitialCount": {
|
||||
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
|
||||
"title": "refreshInitialCount",
|
||||
@@ -417,6 +426,15 @@
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshForce": {
|
||||
"default": false,
|
||||
"description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "refreshForce",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshInitialCount": {
|
||||
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
|
||||
"title": "refreshInitialCount",
|
||||
@@ -1603,6 +1621,169 @@
|
||||
"title": "LogOptions",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDData": {
|
||||
"properties": {
|
||||
"password": {
|
||||
"description": "Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "If using socket specify the path instead of url.\n\ntrailing `~` is replaced by your home directory",
|
||||
"title": "path",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "localhost:6600",
|
||||
"description": "URL:PORT of the MPD server to connect to\n\nTo use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections",
|
||||
"examples": [
|
||||
"localhost:6600"
|
||||
],
|
||||
"title": "url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "MPDData",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDSourceAIOConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/MPDData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/MPDSourceOptions",
|
||||
"title": "options"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"mpd"
|
||||
],
|
||||
"title": "type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"options",
|
||||
"type"
|
||||
],
|
||||
"title": "MPDSourceAIOConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDSourceOptions": {
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logPlayerState": {
|
||||
"default": false,
|
||||
"description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklogCount": {
|
||||
"description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
|
||||
"title": "scrobbleBacklogCount",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"title": "MPDSourceOptions",
|
||||
"type": "object"
|
||||
},
|
||||
"MPRISData": {
|
||||
"properties": {
|
||||
"blacklist": {
|
||||
@@ -2285,6 +2466,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/MopidySourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPDSourceAIOConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPRISSourceAIOConfig"
|
||||
},
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
"title": "refreshEnabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshForce": {
|
||||
"default": false,
|
||||
"description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "refreshForce",
|
||||
"type": "boolean"
|
||||
},
|
||||
"refreshInitialCount": {
|
||||
"description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.",
|
||||
"title": "refreshInitialCount",
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/MopidySourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPDSourceConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MPRISSourceConfig"
|
||||
},
|
||||
@@ -963,6 +966,161 @@
|
||||
"title": "ListenBrainzSourceData",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDData": {
|
||||
"properties": {
|
||||
"password": {
|
||||
"description": "Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords",
|
||||
"title": "password",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "If using socket specify the path instead of url.\n\ntrailing `~` is replaced by your home directory",
|
||||
"title": "path",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"default": "localhost:6600",
|
||||
"description": "URL:PORT of the MPD server to connect to\n\nTo use this you must have TCP connections enabled for your MPD server https://mpd.readthedocs.io/en/stable/user.html#client-connections",
|
||||
"examples": [
|
||||
"localhost:6600"
|
||||
],
|
||||
"title": "url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "MPDData",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDSourceConfig": {
|
||||
"properties": {
|
||||
"clients": {
|
||||
"description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.",
|
||||
"examples": [
|
||||
[
|
||||
"MyMalojaConfigName",
|
||||
"MyLastFMConfigName"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "clients",
|
||||
"type": "array"
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/definitions/MPDData",
|
||||
"title": "data"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "Should MS use this client/source? Defaults to true",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"title": "enable",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Unique identifier for this source.",
|
||||
"title": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/definitions/MPDSourceOptions",
|
||||
"title": "options"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"options"
|
||||
],
|
||||
"title": "MPDSourceConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"MPDSourceOptions": {
|
||||
"properties": {
|
||||
"logFilterFailure": {
|
||||
"default": "warn",
|
||||
"description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.",
|
||||
"enum": [
|
||||
"debug",
|
||||
false,
|
||||
"warn"
|
||||
],
|
||||
"examples": [
|
||||
"warn"
|
||||
],
|
||||
"title": "logFilterFailure"
|
||||
},
|
||||
"logPayload": {
|
||||
"default": false,
|
||||
"description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPayload",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logPlayerState": {
|
||||
"default": false,
|
||||
"description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"title": "logPlayerState",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxPollRetries": {
|
||||
"default": 5,
|
||||
"description": "default # of automatic polling restarts on error",
|
||||
"examples": [
|
||||
5
|
||||
],
|
||||
"title": "maxPollRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRequestRetries": {
|
||||
"default": 1,
|
||||
"description": "default # of http request retries a source/client can make before error is thrown",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"title": "maxRequestRetries",
|
||||
"type": "number"
|
||||
},
|
||||
"retryMultiplier": {
|
||||
"default": 1.5,
|
||||
"description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)",
|
||||
"examples": [
|
||||
1.5
|
||||
],
|
||||
"title": "retryMultiplier",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleBacklog": {
|
||||
"default": true,
|
||||
"description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history",
|
||||
"examples": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"title": "scrobbleBacklog",
|
||||
"type": "boolean"
|
||||
},
|
||||
"scrobbleBacklogCount": {
|
||||
"description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports",
|
||||
"title": "scrobbleBacklogCount",
|
||||
"type": "number"
|
||||
},
|
||||
"scrobbleThresholds": {
|
||||
"$ref": "#/definitions/ScrobbleThresholds",
|
||||
"description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.",
|
||||
"title": "scrobbleThresholds"
|
||||
}
|
||||
},
|
||||
"title": "MPDSourceOptions",
|
||||
"type": "object"
|
||||
},
|
||||
"MPRISData": {
|
||||
"properties": {
|
||||
"blacklist": {
|
||||
|
||||
+30
-5
@@ -1,6 +1,6 @@
|
||||
import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import dayjs from "dayjs";
|
||||
import request, { Request } from 'superagent';
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { slice } from "../../../core/StringUtils.js";
|
||||
import { combinePartsToString } from "../../utils.js";
|
||||
@@ -127,7 +127,7 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
|
||||
|
||||
callApi = async <T>(req: Request, retries = 0): Promise<T> => {
|
||||
callApi = async <T = Response>(req: Request, retries = 0): Promise<T> => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = DEFAULT_RETRY_MULTIPLIER
|
||||
@@ -248,17 +248,27 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
submitListen = async (play: PlayObject) => {
|
||||
submitListen = async (play: PlayObject, log: boolean = false) => {
|
||||
try {
|
||||
const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]};
|
||||
await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload));
|
||||
if(log) {
|
||||
this.logger.debug(`Submit Payload: ${JSON.stringify(listenPayload)}`);
|
||||
}
|
||||
// response consists of {"status": "ok"}
|
||||
// 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));
|
||||
if(log) {
|
||||
this.logger.debug(`Submit Response: ${resp.text}`)
|
||||
}
|
||||
return listenPayload;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static playToListenPayload = (play: PlayObject): ListenPayload => {
|
||||
static playToListenPayload(play: PlayObject): ListenPayload {
|
||||
const {
|
||||
data: {
|
||||
playDate,
|
||||
@@ -581,6 +591,21 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
static submitToPlayObj(submitObj: SubmitPayload, playObj: PlayObject): PlayObject {
|
||||
if (submitObj.payload.length > 0) {
|
||||
const respPlay = {
|
||||
...playObj,
|
||||
};
|
||||
respPlay.data = {
|
||||
...playObj.data,
|
||||
album: submitObj.payload[0].track_metadata?.release_name ?? playObj.data.album,
|
||||
track: submitObj.payload[0].track_metadata?.track_name ?? playObj.data.album,
|
||||
};
|
||||
return respPlay;
|
||||
}
|
||||
return playObj;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj: any, options: FormatPlayObjectOptions): PlayObject {
|
||||
return ListenbrainzApiClient.listenResponseToPlay(obj);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import dayjs, { Dayjs } from "dayjs";
|
||||
import EventEmitter from "events";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { nanoid } from "nanoid";
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import {
|
||||
DeadLetterScrobble,
|
||||
PlayObject,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
FormatPlayObjectOptions,
|
||||
ScrobbledPlayObject,
|
||||
TIME_WEIGHT,
|
||||
TITLE_WEIGHT,
|
||||
TITLE_WEIGHT, TRANSFORM_HOOK,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { CommonClientConfig } from "../common/infrastructure/config/client/index.js";
|
||||
import { Notifiers } from "../notifier/Notifiers.js";
|
||||
@@ -73,13 +74,13 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
queuedScrobbles: QueuedScrobble<PlayObject>[] = [];
|
||||
deadLetterScrobbles: DeadLetterScrobble<PlayObject>[] = [];
|
||||
|
||||
config: CommonClientConfig;
|
||||
declare config: CommonClientConfig;
|
||||
|
||||
notifier: Notifiers;
|
||||
emitter: EventEmitter;
|
||||
|
||||
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
|
||||
super();
|
||||
super(config);
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.identifier = `${capitalize(this.type)} - ${name}`;
|
||||
@@ -89,7 +90,6 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
|
||||
this.scrobbledPlayObjs = new FixedSizeList<ScrobbledPlayObject>(this.MAX_STORED_SCROBBLES);
|
||||
|
||||
this.config = config;
|
||||
const {
|
||||
options: {
|
||||
refreshEnabled = true,
|
||||
@@ -148,8 +148,60 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
await this.refreshScrobbles(initialLimit);
|
||||
}
|
||||
|
||||
refreshScrobbles = async (limit?: number) => {
|
||||
this.logger.debug('Scrobbler does not have refresh function implemented!');
|
||||
refreshScrobbles = async (limit: number = this.MAX_STORED_SCROBBLES) => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const recent = await this.getScrobblesForRefresh(limit);
|
||||
this.logger.debug(`Found ${recent.length} recent scrobbles`);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.filterScrobbledTracks();
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
protected abstract getScrobblesForRefresh(limit: number): Promise<PlayObject[]>;
|
||||
|
||||
shouldRefreshScrobble = () => {
|
||||
const {
|
||||
refreshStaleAfter
|
||||
} = this.config.options || {};
|
||||
|
||||
if (!this.refreshEnabled) {
|
||||
this.logger.debug(`Should NOT refresh scrobbles => refreshEnabled is false`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const queuedPlayedDate = this.getLatestQueuePlayDate();
|
||||
|
||||
// if next queued play was played more recently than the last time we refreshed upstream scrobbles
|
||||
if (this.lastScrobbleCheck.unix() < queuedPlayedDate.unix()) {
|
||||
this.logger.debug('Should refresh scrobbles => queued scrobble playDate is newer than last upstream scrobble refresh');
|
||||
return true;
|
||||
}
|
||||
|
||||
// if the last scrobbled play is at or is newer than the next scrobble then we are inserting (or potentially duping)
|
||||
// in which case our data is probably stale
|
||||
if(this.newestScrobbleTime !== undefined && this.newestScrobbleTime.unix() >= queuedPlayedDate.unix()) {
|
||||
this.logger.debug('Should refresh scrobbles => queued scrobble playDate is equal to or older than the newest upstream scrobble');
|
||||
return true;
|
||||
}
|
||||
|
||||
if(refreshStaleAfter !== undefined) {
|
||||
const diff = dayjs().diff(this.lastScrobbleCheck, 's');
|
||||
if(diff > refreshStaleAfter) {
|
||||
this.logger.debug(`Should refresh scrobbles => last refresh (${diff}s ago) was longer than refreshStaleAfter (${refreshStaleAfter}s)`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Scrobble refresh not needed');
|
||||
return false;
|
||||
}
|
||||
|
||||
public abstract alreadyScrobbled(playObj: PlayObject, log?: boolean): Promise<boolean>;
|
||||
@@ -193,17 +245,13 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
|
||||
getScrobbledPlays = () => this.scrobbledPlayObjs.data.map(x => x.scrobble)
|
||||
|
||||
findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
|
||||
const {
|
||||
data: {
|
||||
playDate
|
||||
} = {},
|
||||
meta: {
|
||||
source,
|
||||
} = {}
|
||||
} = playObj;
|
||||
findExistingSubmittedPlayObj = (playObjPre: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => {
|
||||
|
||||
const dtInvariantMatches = this.scrobbledPlayObjs.data.filter(x => playObjDataMatch(playObj, x.play));
|
||||
const playObj = this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate);
|
||||
|
||||
const dtInvariantMatches = this.scrobbledPlayObjs.data
|
||||
.map(x => ({...x, play: this.transformPlay(x.play, TRANSFORM_HOOK.existing)}))
|
||||
.filter(x => playObjDataMatch(playObj, x.play));
|
||||
|
||||
if (dtInvariantMatches.length === 0) {
|
||||
return [undefined, []];
|
||||
@@ -240,7 +288,10 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
return [Math.min(compareScrobbleArtists(existing, candidate)/100, 1), wholeMatches]
|
||||
}
|
||||
|
||||
existingScrobble = async (playObj: PlayObject) => {
|
||||
existingScrobble = async (playObjPre: PlayObject) => {
|
||||
|
||||
const playObj = this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate);
|
||||
|
||||
const tr = truncateStringToLength(27);
|
||||
const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}};
|
||||
|
||||
@@ -256,7 +307,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
let closestMatch: {score: number, breakdowns: string[], confidence: string, scrobble?: PlayObject} = {score: 0, breakdowns: [], confidence: 'No existing scrobble matched with a score higher than 0'};
|
||||
|
||||
// then check if we have already recorded this
|
||||
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
|
||||
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObjPre);
|
||||
|
||||
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
|
||||
if (existingExactSubmitted !== undefined) {
|
||||
@@ -287,7 +338,9 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
|
||||
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
|
||||
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
|
||||
|
||||
existingScrobble = this.recentScrobbles.find((x) => {
|
||||
existingScrobble = this.recentScrobbles.find((xPre) => {
|
||||
|
||||
const x = this.transformPlay(xPre, TRANSFORM_HOOK.existing);
|
||||
|
||||
//const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
|
||||
|
||||
@@ -525,20 +578,21 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
this.scrobbling = true;
|
||||
while (!this.shouldStopScrobbleProcessing()) {
|
||||
while (this.queuedScrobbles.length > 0) {
|
||||
if (this.lastScrobbleCheck.unix() < this.getLatestQueuePlayDate().unix()) {
|
||||
if (this.shouldRefreshScrobble()) {
|
||||
await this.refreshScrobbles();
|
||||
}
|
||||
const currQueuedPlay = this.queuedScrobbles.shift();
|
||||
const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(currQueuedPlay.play);
|
||||
if (timeFrameValid && !(await this.alreadyScrobbled(currQueuedPlay.play))) {
|
||||
const transformedScrobble = this.transformPlay(currQueuedPlay.play, TRANSFORM_HOOK.postCompare);
|
||||
try {
|
||||
const scrobbledPlay = await this.scrobble(currQueuedPlay.play);
|
||||
this.emitEvent('scrobble', {play: currQueuedPlay.play});
|
||||
this.addScrobbledTrack(currQueuedPlay.play, scrobbledPlay);
|
||||
const scrobbledPlay = await this.scrobble(transformedScrobble);
|
||||
this.emitEvent('scrobble', {play: transformedScrobble});
|
||||
this.addScrobbledTrack(transformedScrobble, scrobbledPlay);
|
||||
} catch (e) {
|
||||
if (e instanceof UpstreamError && e.showStopper === false) {
|
||||
this.addDeadLetterScrobble(currQueuedPlay, e);
|
||||
this.logger.warn(new Error(`Could not scrobble ${buildTrackString(currQueuedPlay.play)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, {cause: e}));
|
||||
this.logger.warn(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${currQueuedPlay.source}' but error was not show stopping. Adding scrobble to Dead Letter Queue and will retry on next heartbeat.`, {cause: e}));
|
||||
} else {
|
||||
this.queuedScrobbles.unshift(currQueuedPlay);
|
||||
throw new Error('Error occurred while trying to scrobble', {cause: e});
|
||||
@@ -613,15 +667,16 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
}
|
||||
const [timeFrameValid, timeFrameValidLog] = this.timeFrameIsValid(deadScrobble.play);
|
||||
if (timeFrameValid && !(await this.alreadyScrobbled(deadScrobble.play))) {
|
||||
const transformedScrobble = this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.postCompare);
|
||||
try {
|
||||
const scrobbledPlay = await this.scrobble(deadScrobble.play);
|
||||
this.emitEvent('scrobble', {play: deadScrobble.play});
|
||||
this.addScrobbledTrack(deadScrobble.play, scrobbledPlay);
|
||||
const scrobbledPlay = await this.scrobble(transformedScrobble);
|
||||
this.emitEvent('scrobble', {play: transformedScrobble});
|
||||
this.addScrobbledTrack(transformedScrobble, scrobbledPlay);
|
||||
} catch (e) {
|
||||
deadScrobble.retries++;
|
||||
deadScrobble.error = messageWithCauses(e);
|
||||
deadScrobble.lastRetry = dayjs();
|
||||
this.logger.error(new Error(`Could not scrobble ${buildTrackString(deadScrobble.play)} from Source '${deadScrobble.source}' due to error`, {cause: e}));
|
||||
this.logger.error(new Error(`Could not scrobble ${buildTrackString(transformedScrobble)} from Source '${deadScrobble.source}' due to error`, {cause: e}));
|
||||
this.deadLetterScrobbles[deadScrobbleIndex] = deadScrobble;
|
||||
return [false, deadScrobble];
|
||||
} finally {
|
||||
@@ -661,7 +716,8 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
|
||||
queueScrobble = (data: PlayObject | PlayObject[], source: string) => {
|
||||
const plays = Array.isArray(data) ? data : [data];
|
||||
for(const p of plays) {
|
||||
const queuedPlay = {id: nanoid(), source, play: p}
|
||||
const transformedPlay = this.transformPlay(p, TRANSFORM_HOOK.preCompare);
|
||||
const queuedPlay = {id: nanoid(), source, play: transformedPlay}
|
||||
this.emitEvent('scrobbleQueued', {queuedPlay: queuedPlay});
|
||||
this.queuedScrobbles.push(queuedPlay);
|
||||
}
|
||||
|
||||
@@ -46,9 +46,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
}
|
||||
|
||||
refreshScrobbles = async (limit = this.MAX_STORED_SCROBBLES) => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
getScrobblesForRefresh = async (limit: number) => {
|
||||
const resp = await this.api.callApi<UserGetRecentTracksResponse>((client: any) => client.userGetRecentTracks({
|
||||
user: this.api.user,
|
||||
sk: this.api.client.sessionKey,
|
||||
@@ -60,7 +58,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
track: list = [],
|
||||
}
|
||||
} = resp;
|
||||
this.recentScrobbles = list.reduce((acc: any, x: any) => {
|
||||
return list.reduce((acc: any, x: any) => {
|
||||
try {
|
||||
const formatted = LastfmApiClient.formatPlayObj(x);
|
||||
const {
|
||||
@@ -91,17 +89,6 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
return acc;
|
||||
}
|
||||
}, []);
|
||||
this.logger.debug(`Found ${this.recentScrobbles.length} recent scrobbles`);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.filterScrobbledTracks();
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
|
||||
@@ -59,22 +59,8 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
}
|
||||
|
||||
refreshScrobbles = async (limit = this.MAX_STORED_SCROBBLES) => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.api.getRecentlyPlayed(limit);
|
||||
this.logger.debug(`Found ${resp.length} recent scrobbles`);
|
||||
this.recentScrobbles = resp;
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.filterScrobbledTracks();
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
getScrobblesForRefresh = async (limit: number) => {
|
||||
return await this.api.getRecentlyPlayed(limit);
|
||||
}
|
||||
|
||||
alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined
|
||||
@@ -91,30 +77,18 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
let rawPayload = {listen_type: 'single', payload: [this.playToClientPayload(playObj)]};
|
||||
|
||||
try {
|
||||
const resp = await this.api.submitListen(playObj);
|
||||
rawPayload = resp;
|
||||
await this.api.submitListen(playObj, true);
|
||||
|
||||
if (newFromSource) {
|
||||
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
|
||||
} else {
|
||||
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
|
||||
}
|
||||
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
|
||||
//await sleep(1000);
|
||||
return playObj;
|
||||
} catch (e) {
|
||||
await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'});
|
||||
this.logger.error(`Failed to scrobble => ${e.message}`, {payload: rawPayload});
|
||||
if(e instanceof UpstreamError) {
|
||||
throw e;
|
||||
} else {
|
||||
throw new UpstreamError(`Error occurred while making Listenbrainz API request: ${e.message}`, {cause: e, showStopper: true});
|
||||
}
|
||||
} finally {
|
||||
this.logger.debug(`Raw Payload:`, {rawPayload});
|
||||
throw new UpstreamError(`Error occurred while making Listenbrainz API scrobble request: ${e.message}`, {cause: e, showStopper: !(e instanceof UpstreamError)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,28 +299,15 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
}
|
||||
}
|
||||
|
||||
refreshScrobbles = async (limit = this.MAX_STORED_SCROBBLES) => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const {url} = this.config.data;
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
} = resp;
|
||||
this.logger.debug(`Found ${list.length} recent scrobbles`);
|
||||
this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x));
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
this.newestScrobbleTime = newestScrobbleTime;
|
||||
this.oldestScrobbleTime = oldestScrobbleTime;
|
||||
|
||||
this.filterScrobbledTracks();
|
||||
}
|
||||
}
|
||||
this.lastScrobbleCheck = dayjs();
|
||||
getScrobblesForRefresh = async (limit: number) => {
|
||||
const {url} = this.config.data;
|
||||
const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?perpage=${limit}`));
|
||||
const {
|
||||
body: {
|
||||
list = [],
|
||||
} = {},
|
||||
} = resp;
|
||||
return list.map((x: any) => this.formatPlayObj(x));
|
||||
}
|
||||
|
||||
cleanSourceSearchTitle = (playObj: PlayObject) => {
|
||||
|
||||
@@ -6,7 +6,9 @@ import express from 'express';
|
||||
import session from 'express-session';
|
||||
import { PassThrough } from "node:stream";
|
||||
import passport from 'passport';
|
||||
import path from "path";
|
||||
import ViteExpress from "vite-express";
|
||||
import { projectDir } from "../common/index.js";
|
||||
import { getRoot } from "../ioc.js";
|
||||
import { getAddress, parseBool } from "../utils.js";
|
||||
import { setupApi } from "./api.js";
|
||||
@@ -72,6 +74,8 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro
|
||||
}
|
||||
}
|
||||
|
||||
app.use('/docs', express.static(path.resolve(projectDir, `./docsite/build`)));
|
||||
|
||||
if(process.env.USE_HASH_ROUTER === undefined) {
|
||||
process.env.USE_HASH_ROUTER = root.get('isSubPath');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
PlayUserId,
|
||||
ProgressAwarePlayObject,
|
||||
SINGLE_USER_PLATFORM_ID,
|
||||
SourceType,
|
||||
SourceType, TRANSFORM_HOOK,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import { SourceConfig } from "../common/infrastructure/config/source/sources.js";
|
||||
import TupleMap from "../common/TupleMap.js";
|
||||
@@ -46,7 +46,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
type: SourceType;
|
||||
identifier: string;
|
||||
|
||||
config: SourceConfig;
|
||||
declare config: SourceConfig;
|
||||
clients: string[];
|
||||
instantiatedAt: Dayjs;
|
||||
lastActivityAt: Dayjs;
|
||||
@@ -64,6 +64,9 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
pollRetries: number = 0;
|
||||
tracksDiscovered: number = 0;
|
||||
|
||||
protected isSleeping: boolean = false;
|
||||
protected wakeAt: Dayjs = dayjs();
|
||||
|
||||
supportsUpstreamRecentlyPlayed: boolean = false;
|
||||
supportsUpstreamNowPlaying: boolean = false;
|
||||
|
||||
@@ -74,7 +77,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
protected recentDiscoveredPlays: GroupedFixedPlays = new TupleMap<DeviceId, PlayUserId, FixedSizeList<ProgressAwarePlayObject>>();
|
||||
|
||||
constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
super();
|
||||
super(config);
|
||||
const {clients = [] } = config;
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
@@ -144,8 +147,12 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
}
|
||||
});
|
||||
}
|
||||
const candidate = this.transformPlay(play, TRANSFORM_HOOK.candidate);
|
||||
for(const list of lists) {
|
||||
const existing = list.find(x => playObjDataMatch(x, play) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(x, play).match));
|
||||
const existing = list.find(x => {
|
||||
const e = this.transformPlay(x, TRANSFORM_HOOK.existing);
|
||||
return playObjDataMatch(e, candidate) && temporalAccuracyIsAtLeast(TA_CLOSE, comparePlayTemporally(e, candidate).match)
|
||||
});
|
||||
if(existing) {
|
||||
return existing;
|
||||
}
|
||||
@@ -163,7 +170,9 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
discover = (plays: PlayObject[], options: { checkAll?: boolean, [key: string]: any } = {}): PlayObject[] => {
|
||||
const newDiscoveredPlays: PlayObject[] = [];
|
||||
|
||||
for(const play of plays) {
|
||||
const transformedPlayed = plays.map(x => this.transformPlay(x, TRANSFORM_HOOK.preCompare));
|
||||
|
||||
for(const play of transformedPlayed) {
|
||||
if(!this.alreadyDiscovered(play, options)) {
|
||||
this.addPlayToDiscovered(play);
|
||||
newDiscoveredPlays.push(play);
|
||||
@@ -181,7 +190,7 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
if(newDiscoveredPlays.length > 0) {
|
||||
newDiscoveredPlays.sort(sortByOldestPlayDate);
|
||||
this.emitter.emit('discoveredToScrobble', {
|
||||
data: newDiscoveredPlays,
|
||||
data: newDiscoveredPlays.map(x => this.transformPlay(x, TRANSFORM_HOOK.postCompare)),
|
||||
options: {
|
||||
...options,
|
||||
checkTime: newDiscoveredPlays[newDiscoveredPlays.length-1].data.playDate.add(2, 'second'),
|
||||
@@ -305,6 +314,13 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
let pollRes: boolean | undefined = undefined;
|
||||
while (pollRes === undefined && this.pollRetries <= maxRetries) {
|
||||
try {
|
||||
if(!this.isReady() && this.buildOK) {
|
||||
this.logger.verbose(`Source is no longer ready! Will attempt to reinitialize => Connection OK: ${this.connectionOK} | Auth OK: ${this.authed}`);
|
||||
const init = await this.initialize();
|
||||
if(init === false) {
|
||||
throw new Error('Source failed reinitialization');
|
||||
}
|
||||
}
|
||||
pollRes = await this.doPolling();
|
||||
if(pollRes === true) {
|
||||
break;
|
||||
@@ -420,11 +436,13 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
} else {
|
||||
this.logger.debug(`Last activity was at ${this.lastActivityAt.format()} | Next check interval: ${formatNumber(sleepTime)}s`);
|
||||
}
|
||||
const wakeUpAt = pollFrom.add(sleepTime, 'seconds');
|
||||
while(!this.shouldStopPolling() && dayjs().isBefore(wakeUpAt)) {
|
||||
this.setWakeAt(pollFrom.add(sleepTime, 'seconds'));
|
||||
this.setIsSleeping(true);
|
||||
while(!this.shouldStopPolling() && dayjs().isBefore(this.getWakeAt())) {
|
||||
// check for polling status every half second and wait till wake up time
|
||||
await sleep(500);
|
||||
}
|
||||
this.setIsSleeping(false);
|
||||
|
||||
}
|
||||
if(this.shouldStopPolling()) {
|
||||
@@ -441,9 +459,27 @@ export default abstract class AbstractSource extends AbstractComponent implement
|
||||
this.emitEvent('statusChange', {status: 'Idle'});
|
||||
this.polling = false;
|
||||
throw e;
|
||||
} finally {
|
||||
this.setIsSleeping(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected setIsSleeping(sleeping: boolean) {
|
||||
this.isSleeping = sleeping;
|
||||
}
|
||||
|
||||
protected getIsSleeping() {
|
||||
return this.isSleeping;
|
||||
}
|
||||
|
||||
protected setWakeAt(dt: Dayjs) {
|
||||
this.wakeAt = dt;
|
||||
}
|
||||
|
||||
protected getWakeAt() {
|
||||
return this.wakeAt;
|
||||
}
|
||||
|
||||
protected getInterval() {
|
||||
const {interval = DEFAULT_POLLING_INTERVAL} = this.config.data;
|
||||
return interval;
|
||||
|
||||
@@ -90,6 +90,8 @@ export default class DeezerSource extends AbstractSource {
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
this.logger.warn('This Source is DEPRECATED! Deezer has dropped support official API support. New apps cannot be created and existing apps are not guaranteed to continue working. See the documentation or this issue for more information: https://github.com/FoxxMD/multi-scrobbler/issues/175#issuecomment-2296776625');
|
||||
|
||||
try {
|
||||
const credFile = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
if(credFile !== undefined) {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import dayjs from "dayjs";
|
||||
import { EventEmitter } from "events";
|
||||
import mpdapiNS, { MPDApi } from 'mpd-api';
|
||||
import mpd2 from 'mpd2';
|
||||
import { BrainzMeta, PlayObject } from "../../core/Atomic.js";
|
||||
import {
|
||||
FormatPlayObjectOptions,
|
||||
InternalConfig,
|
||||
PlayerStateData,
|
||||
REPORTED_PLAYER_STATUSES,
|
||||
ReportedPlayerStatus,
|
||||
SINGLE_USER_PLATFORM_ID,
|
||||
} from "../common/infrastructure/Atomic.js";
|
||||
import {
|
||||
CurrentSongResponse,
|
||||
MPDSourceConfig,
|
||||
PlayerState,
|
||||
StatusResponse,
|
||||
} from "../common/infrastructure/config/source/mpd.js";
|
||||
import { isPortReachable } from "../utils/NetworkUtils.js";
|
||||
import { RecentlyPlayedOptions } from "./AbstractSource.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
|
||||
const mpdClient = mpdapiNS.default;
|
||||
|
||||
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
|
||||
'play': REPORTED_PLAYER_STATUSES.playing,
|
||||
'pause': REPORTED_PLAYER_STATUSES.paused,
|
||||
'stop': REPORTED_PLAYER_STATUSES.stopped,
|
||||
}
|
||||
|
||||
export class MPDSource extends MemorySource {
|
||||
declare config: MPDSourceConfig;
|
||||
|
||||
host?: string
|
||||
port?: number
|
||||
// {host?: string, port?: number, path?: string, password?: string};
|
||||
clientConfig: mpd2.MPD.Config;
|
||||
client!: MPDApi.ClientAPI;
|
||||
deviceId: string
|
||||
|
||||
constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
|
||||
const {
|
||||
data = {}
|
||||
} = config;
|
||||
const {
|
||||
interval = 5, // reduced polling interval because its likely we are on the same network
|
||||
...rest
|
||||
} = data;
|
||||
super('mpd', name, {...config, data: {...rest, interval}}, internal, emitter);
|
||||
|
||||
this.requiresAuth = true;
|
||||
this.canPoll = true;
|
||||
}
|
||||
|
||||
static parseConnectionUrl(valRaw: string): [string, string] {
|
||||
if(valRaw.trim() === '') {
|
||||
throw new Error(`'url' cannot be an empty string`);
|
||||
}
|
||||
|
||||
const [host, port] = valRaw.trim().split(':');
|
||||
return [host, port ?? '6600'];
|
||||
}
|
||||
|
||||
protected async doBuildInitData(): Promise<true | string | undefined> {
|
||||
const {
|
||||
data: {
|
||||
url,
|
||||
path,
|
||||
password,
|
||||
} = {}
|
||||
} = this.config;
|
||||
|
||||
if(path === undefined) {
|
||||
const [host, port] = MPDSource.parseConnectionUrl(url ?? 'localhost:6600');
|
||||
this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`);
|
||||
this.host = host;
|
||||
this.port = Number.parseInt(port);
|
||||
this.clientConfig = {
|
||||
host,
|
||||
port: this.port,
|
||||
password
|
||||
}
|
||||
} else {
|
||||
this.logger.verbose(`Using socket path: ${path}`);
|
||||
this.clientConfig = {
|
||||
path,
|
||||
password
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
if(this.host !== undefined) {
|
||||
try {
|
||||
await isPortReachable(this.port, {host: this.host});
|
||||
return `${this.host}:${this.port} is reachable.`;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
doAuthentication = async () => {
|
||||
|
||||
try {
|
||||
this.client = await mpdClient.connect({...this.clientConfig, timeout: 1000});
|
||||
this.client.on('system-player', () => {
|
||||
if(this.getIsSleeping()) {
|
||||
// wake up now!
|
||||
this.logger.debug(`Waking up from sleeping ${Math.abs(this.getWakeAt().diff(dayjs(), 'ms'))}ms early due to player state change`)
|
||||
this.setWakeAt(dayjs());
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
let friendlyError: string | undefined;
|
||||
if(e.code === 'ENOENT') {
|
||||
friendlyError = 'Socket file does not exist'
|
||||
} else if(e.code === 'EACCES') {
|
||||
friendlyError = 'Incorrect permissions to access socket file'
|
||||
}
|
||||
// if(e.errno !== undefined) {
|
||||
// switch(e.errno) {
|
||||
// case mpd2.default.MPDError.CODES.PERMISSION:
|
||||
// friendlyError = 'No permission to connect';
|
||||
// break;
|
||||
// case mpd2.default.MPDError.CODES.PASSWORD:
|
||||
// friendlyError = 'Password is probably not correct';
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
throw new Error(`Could not connect to MPD server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj(obj: CurrentSongResponse, options: FormatPlayObjectOptions = {}): PlayObject {
|
||||
const {
|
||||
file,
|
||||
time,
|
||||
artist,
|
||||
performer,
|
||||
album,
|
||||
albumartist,
|
||||
title,
|
||||
name,
|
||||
musicbrainz_albumartistid,
|
||||
musicbrainz_albumid,
|
||||
musicbrainz_artistid,
|
||||
musicbrainz_releasetrackid,
|
||||
musicbrainz_trackid,
|
||||
} = obj;
|
||||
|
||||
let artists = [];
|
||||
let albumArtists = [];
|
||||
if(artist !== undefined) {
|
||||
artists.push(artist);
|
||||
}
|
||||
if(albumartist !== undefined && albumartist !== artist) {
|
||||
albumArtists.push(albumartist);
|
||||
}
|
||||
if(artists.length === 0 && performer !== undefined) {
|
||||
artists.push(performer);
|
||||
}
|
||||
if(artists.length === 0 && albumArtists.length !== 0) {
|
||||
// switch these, tags are probably improper
|
||||
artists = albumArtists;
|
||||
albumArtists = [];
|
||||
}
|
||||
|
||||
let trackName = title;
|
||||
if(trackName === undefined && name !== undefined) {
|
||||
trackName = name;
|
||||
} else if(trackName === undefined && file !== undefined) {
|
||||
trackName = file;
|
||||
}
|
||||
|
||||
const brainz: BrainzMeta = {
|
||||
albumArtist: musicbrainz_albumartistid,
|
||||
album: musicbrainz_albumid,
|
||||
track: musicbrainz_trackid,
|
||||
};
|
||||
if(musicbrainz_artistid !== undefined) {
|
||||
brainz.artist = [musicbrainz_artistid];
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists: artists,
|
||||
albumArtists,
|
||||
album,
|
||||
track: trackName,
|
||||
duration: time
|
||||
},
|
||||
meta: {
|
||||
brainz,
|
||||
trackProgressPosition: options.trackProgressPosition,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
|
||||
|
||||
let state: StatusResponse;
|
||||
let currentSong: CurrentSongResponse;
|
||||
try {
|
||||
state = await this.client.api.status.get<StatusResponse>();
|
||||
currentSong = await this.client.api.status.currentsong<CurrentSongResponse>();
|
||||
} catch (e) {
|
||||
this.connectionOK = false;
|
||||
this.authed = false;
|
||||
throw e;
|
||||
}
|
||||
|
||||
let play: PlayObject | undefined;
|
||||
if(currentSong !== undefined) {
|
||||
play = this.formatPlayObj(currentSong, {trackProgressPosition: state.elapsed});
|
||||
}
|
||||
|
||||
const playerState: PlayerStateData = {
|
||||
platformId: SINGLE_USER_PLATFORM_ID,
|
||||
status: CLIENT_PLAYER_STATE[state.state],
|
||||
play,
|
||||
position: state.elapsed
|
||||
}
|
||||
|
||||
return this.processRecentPlays([playerState]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { childLogger } from "@foxxmd/logging";
|
||||
import { EventEmitter } from "events";
|
||||
import { WS } from 'iso-websocket'
|
||||
// TODO remove when/if iso-websocket exports these
|
||||
// @ ts-expect-error not exported properly by package
|
||||
//import { CloseEvent, ErrorEvent, RetryEvent } from "iso-websocket/dist/src/events.js";
|
||||
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
|
||||
import { randomUUID } from "node:crypto";
|
||||
import normalizeUrl from 'normalize-url';
|
||||
import pEvent from 'p-event';
|
||||
@@ -102,8 +99,7 @@ export class MusikcubeSource extends MemorySource {
|
||||
automaticOpen: false,
|
||||
retry: {
|
||||
retries: 0
|
||||
},
|
||||
//errorInfo: true
|
||||
}
|
||||
});
|
||||
const wsLogger = childLogger(this.logger, 'WS');
|
||||
this.client.addEventListener('retry', (e) => {
|
||||
@@ -128,12 +124,8 @@ export class MusikcubeSource extends MemorySource {
|
||||
this.connectionOK = false;
|
||||
this.authed = false;
|
||||
}
|
||||
if(e.error.message === ('Websocket error')) {
|
||||
wsLogger.error('Communication with server failed => Websocket error');
|
||||
|
||||
} else {
|
||||
wsLogger.error(new Error('Communication with server failed', {cause: e.error}));
|
||||
}
|
||||
const hint = e.error?.cause?.message ?? undefined;
|
||||
wsLogger.error(new Error(`Communication with server failed${hint !== undefined ? ` (${hint})` : ''}`, {cause: e.error}));
|
||||
});
|
||||
|
||||
this.client.addEventListener('message', (e) => {
|
||||
@@ -148,11 +140,12 @@ export class MusikcubeSource extends MemorySource {
|
||||
protected async doCheckConnection(): Promise<true | string | undefined> {
|
||||
try {
|
||||
this.client.open();
|
||||
const e = await pEvent(this.client, 'open');
|
||||
const opened = await pEvent(this.client, 'open');
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.client.close();
|
||||
throw new Error(`Could not connect to Musikcube metadata server`);
|
||||
const hint = e.error?.cause?.message ?? undefined;
|
||||
throw new Error(`Could not connect to Musikcube metadata server${hint !== undefined ? ` (${hint})` : ''}`, {cause: e.error ?? e});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -284,21 +277,6 @@ const isRetryEvent = (e: Event): e is RetryEvent => {
|
||||
return e.type === 'retry';
|
||||
}
|
||||
|
||||
// TODO remove when/if iso-websockets exports these
|
||||
interface ErrorEvent extends Event {
|
||||
type: 'error'
|
||||
error: Error
|
||||
message: string
|
||||
}
|
||||
interface CloseEvent extends Event {
|
||||
type: 'close'
|
||||
reason: string
|
||||
code: number
|
||||
}
|
||||
interface RetryEvent extends Event {
|
||||
type: 'retry'
|
||||
}
|
||||
|
||||
const isAuthenticateResponse = (data: any): data is MCAuthenticateResponse => {
|
||||
return 'name' in data && data.name === 'authenticate';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { KodiData, KodiSourceConfig } from "../common/infrastructure/config/sour
|
||||
import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js";
|
||||
import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js";
|
||||
import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js";
|
||||
import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js";
|
||||
import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js";
|
||||
import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js";
|
||||
import { PlexSourceConfig } from "../common/infrastructure/config/source/plex.js";
|
||||
@@ -34,6 +35,7 @@ import { KodiSource } from "./KodiSource.js";
|
||||
import LastfmSource from "./LastfmSource.js";
|
||||
import ListenbrainzSource from "./ListenbrainzSource.js";
|
||||
import { MopidySource } from "./MopidySource.js";
|
||||
import { MPDSource } from "./MPDSource.js";
|
||||
import { MPRISSource } from "./MPRISSource.js";
|
||||
import { MusikcubeSource } from "./MusikcubeSource.js";
|
||||
import PlexSource from "./PlexSource.js";
|
||||
@@ -543,6 +545,9 @@ export default class ScrobbleSources {
|
||||
case 'musikcube':
|
||||
newSource = await new MusikcubeSource(name, compositeConfig as MusikcubeSourceConfig, internal, this.emitter);
|
||||
break;
|
||||
case 'mpd':
|
||||
newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, internal, this.emitter);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { loggerTest, loggerDebug, childLogger } from "@foxxmd/logging";
|
||||
import chai, { assert, expect } from 'chai';
|
||||
import asPromised from 'chai-as-promised';
|
||||
import { after, before, describe, it } from 'mocha';
|
||||
import AbstractComponent from "../../common/AbstractComponent.js";
|
||||
import { TRANSFORM_HOOK } from "../../common/infrastructure/Atomic.js";
|
||||
import { isSearchAndReplace } from "../../utils.js";
|
||||
import { asPlays, generatePlay, normalizePlays } from "../utils/PlayTestUtils.js";
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
class TestComponent extends AbstractComponent {
|
||||
constructor() {
|
||||
super({});
|
||||
}
|
||||
}
|
||||
|
||||
const component = new TestComponent();
|
||||
component.logger = childLogger(loggerTest, 'App');
|
||||
|
||||
describe('Play Transforms', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
component.config = {};
|
||||
component.transformRules = undefined;
|
||||
});
|
||||
|
||||
describe('Transform Config Parsing', function() {
|
||||
|
||||
it('Sets transform rules as empty object if config is not present', function() {
|
||||
component.buildTransformRules();
|
||||
expect(component.transformRules).exist;
|
||||
expect(Object.keys(component.transformRules).length).eq(0);
|
||||
});
|
||||
|
||||
it('Converts transform config into real S&P data', function() {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: ['something']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component.buildTransformRules();
|
||||
|
||||
expect(component.transformRules.preCompare).to.exist;
|
||||
expect(component.transformRules.preCompare.title).to.exist;
|
||||
expect(Array.isArray(component.transformRules.preCompare.title)).is.true;
|
||||
expect( isSearchAndReplace(component.transformRules.preCompare.title[0])).is.true
|
||||
});
|
||||
|
||||
it('Converts transform config into real S&P data with default being empty string', function() {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: ['something']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component.buildTransformRules();
|
||||
|
||||
expect(component.transformRules.preCompare).to.exist;
|
||||
expect(component.transformRules.preCompare.title).to.exist;
|
||||
expect(Array.isArray(component.transformRules.preCompare.title)).is.true;
|
||||
expect( isSearchAndReplace(component.transformRules.preCompare.title[0])).is.true
|
||||
expect( component.transformRules.preCompare.title[0].search).is.eq('something');
|
||||
expect( component.transformRules.preCompare.title[0].replace).is.eq('');
|
||||
});
|
||||
|
||||
it('Respects transform config when it is already S&P data', function() {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: [
|
||||
{
|
||||
search: 'nothing',
|
||||
replace: 'anything'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component.buildTransformRules();
|
||||
|
||||
expect(component.transformRules.preCompare).to.exist;
|
||||
expect(component.transformRules.preCompare.title).to.exist;
|
||||
expect(Array.isArray(component.transformRules.preCompare.title)).is.true;
|
||||
expect( isSearchAndReplace(component.transformRules.preCompare.title[0])).is.true
|
||||
expect( component.transformRules.preCompare.title[0].search).is.eq('nothing');
|
||||
expect( component.transformRules.preCompare.title[0].replace).is.eq('anything');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Play Transforming', function() {
|
||||
|
||||
it('Returns original play if no hooks are defined', function () {
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay();
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(JSON.stringify(play)).equal(JSON.stringify(transformed));
|
||||
});
|
||||
|
||||
it('Transforms when hook is present', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: ['something']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay({track: 'My coolsomething track'});
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(transformed.data.track).equal('My cool track');
|
||||
});
|
||||
|
||||
it('Transforms consecutively when hook is present with multiple values', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: ['something', 'cool']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay({track: 'My coolsomething track'});
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(transformed.data.track).equal('My track');
|
||||
});
|
||||
|
||||
it('Transforms using parsed regex', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: [
|
||||
{
|
||||
search: '/(cool )(some)(thing)/i',
|
||||
replace: '$1$3'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay({track: 'My cool something track'});
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(transformed.data.track).equal('My cool thing track');
|
||||
});
|
||||
|
||||
it('Removes title when transform replaces with empty string', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: ['something']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay({track: 'something'});
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(transformed.data.track).is.undefined;
|
||||
});
|
||||
|
||||
it('Removes album when transform replaces with empty string', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
album: ['something']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay({album: 'something'});
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(transformed.data.album).is.undefined;
|
||||
});
|
||||
|
||||
it('Removes an artist when transform replaces with empty string', function () {
|
||||
component.config = {
|
||||
options: {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
artists: ['something']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
component.buildTransformRules();
|
||||
|
||||
const play = generatePlay({artists: ['something', 'big']});
|
||||
const transformed = component.transformPlay(play, TRANSFORM_HOOK.preCompare);
|
||||
expect(transformed.data.artists.length).is.eq(1)
|
||||
expect(transformed.data.artists[0]).is.eq('big')
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
})
|
||||
@@ -6,6 +6,9 @@ import { Notifiers } from "../../notifier/Notifiers.js";
|
||||
import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient.js";
|
||||
|
||||
export class TestScrobbler extends AbstractScrobbleClient {
|
||||
protected async getScrobblesForRefresh(limit: number): Promise<PlayObject[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const logger = loggerTest;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import chai, { assert } from 'chai';
|
||||
import chai, { assert, expect } from 'chai';
|
||||
import asPromised from 'chai-as-promised';
|
||||
import clone from 'clone';
|
||||
import { source } from "common-tags";
|
||||
import dayjs from "dayjs";
|
||||
import { after, before, describe, it } from 'mocha';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -26,17 +27,20 @@ const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: first
|
||||
|
||||
const normalizedWithMixedDurOlder = normalizePlays(mixedDurPlays, {initialDate: olderFirstPlayDate});
|
||||
|
||||
const testScrobbler = new TestScrobbler();
|
||||
testScrobbler.verboseOptions = {
|
||||
match: {
|
||||
onMatch: true,
|
||||
onNoMatch: true,
|
||||
confidenceBreakdown: true
|
||||
}
|
||||
};
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
const generateTestScrobbler = () => {
|
||||
const testScrobbler = new TestScrobbler();
|
||||
testScrobbler.verboseOptions = {
|
||||
match: {
|
||||
onMatch: true,
|
||||
onNoMatch: true,
|
||||
confidenceBreakdown: true
|
||||
}
|
||||
};
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
return testScrobbler;
|
||||
}
|
||||
|
||||
const authScrobbler = new TestAuthScrobbler();
|
||||
let testScrobbler: TestScrobbler = generateTestScrobbler()
|
||||
|
||||
describe('Networking', function () {
|
||||
|
||||
@@ -52,6 +56,7 @@ describe('Networking', function () {
|
||||
)
|
||||
],
|
||||
async function() {
|
||||
const authScrobbler = new TestAuthScrobbler();
|
||||
await authScrobbler.testAuth();
|
||||
assert.isFalse(authScrobbler.authGated());
|
||||
}
|
||||
@@ -66,6 +71,7 @@ describe('Networking', function () {
|
||||
)
|
||||
],
|
||||
async function() {
|
||||
const authScrobbler = new TestAuthScrobbler();
|
||||
await authScrobbler.testAuth();
|
||||
assert.isTrue(authScrobbler.authGated());
|
||||
assert.isFalse(authScrobbler.authFailure);
|
||||
@@ -81,6 +87,7 @@ describe('Networking', function () {
|
||||
)
|
||||
],
|
||||
async function() {
|
||||
const authScrobbler = new TestAuthScrobbler();
|
||||
await authScrobbler.testAuth();
|
||||
assert.isTrue(authScrobbler.authGated());
|
||||
assert.isTrue(authScrobbler.authFailure);
|
||||
@@ -93,6 +100,10 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
describe('When scrobble is unique', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
});
|
||||
|
||||
it('It is not detected as duplicate when play date is newer than most recent', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
@@ -136,6 +147,10 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
describe('When scrobble track/artist/album matches existing but is a new scrobble', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
});
|
||||
|
||||
it('Is not detected as duplicate when artist is same, time is similar, but track is different', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
@@ -191,6 +206,11 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
});
|
||||
|
||||
describe('When existing has duration', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
});
|
||||
|
||||
it('A track with continuity to the previous track is not detected as a duplicate', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithDur;
|
||||
@@ -221,6 +241,10 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
describe('When scrobble is a duplicate (title/artists/album)', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when an exact match', async function () {
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
assert.isTrue(await testScrobbler.alreadyScrobbled(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]));
|
||||
@@ -354,6 +378,10 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
describe('When at least one play has duration', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
});
|
||||
|
||||
it('Is detected as duplicate when play date is close to the end of an existing scrobble', async function () {
|
||||
|
||||
testScrobbler.recentScrobbles = normalizedWithDur;
|
||||
@@ -380,11 +408,17 @@ describe('Detects duplicate and unique scrobbles from client recent history', fu
|
||||
|
||||
describe('Detects duplicate and unique scrobbles using actively tracked scrobbles', function() {
|
||||
|
||||
before(function () {
|
||||
beforeEach(function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
});
|
||||
|
||||
// before(function () {
|
||||
// testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
// testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
// });
|
||||
|
||||
it('Detects a unique play', async function() {
|
||||
const newScrobble = generatePlay({
|
||||
playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
|
||||
@@ -424,14 +458,181 @@ describe('Detects duplicate and unique scrobbles using actively tracked scrobble
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manages scrobble queue', function() {
|
||||
describe('Detects when upstream scrobbles should be refreshed', function() {
|
||||
|
||||
before(function() {
|
||||
const normalizedClose = normalizePlays(withDurPlays, {initialDate: dayjs().subtract(100, 'seconds')});
|
||||
|
||||
beforeEach(function () {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
testScrobbler.newestScrobbleTime = normalizedWithMixedDur[0].data.playDate;
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
testScrobbler.queuedScrobbles = [];
|
||||
testScrobbler.config.options = {};
|
||||
});
|
||||
|
||||
it('Detects queued scrobble date is newer than last scrobble refresh', async function() {
|
||||
const newScrobble = generatePlay({
|
||||
playDate: dayjs()
|
||||
});
|
||||
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
assert.isTrue(testScrobbler.shouldRefreshScrobble());
|
||||
});
|
||||
|
||||
it('Detects queued scrobble date is older than newest scrobble', async function() {
|
||||
testScrobbler.recentScrobbles = normalizedClose;
|
||||
testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate;
|
||||
|
||||
const newScrobble = generatePlay({
|
||||
playDate: dayjs().subtract(120, 'seconds')
|
||||
});
|
||||
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
assert.isTrue(testScrobbler.shouldRefreshScrobble());
|
||||
});
|
||||
|
||||
it('Forces refresh if refreshStaleAfter is set', async function() {
|
||||
testScrobbler.recentScrobbles = normalizedClose;
|
||||
testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate;
|
||||
testScrobbler.config.options = { refreshStaleAfter: 10 };
|
||||
|
||||
const newScrobble = generatePlay({
|
||||
playDate: dayjs().subtract(80, 'seconds')
|
||||
});
|
||||
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
assert.isTrue(testScrobbler.shouldRefreshScrobble());
|
||||
});
|
||||
|
||||
it('Does not refresh if scrobble is older than last check but newer than newest upstream scrobble', async function() {
|
||||
testScrobbler.recentScrobbles = normalizedClose;
|
||||
testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate;
|
||||
|
||||
const newScrobble = generatePlay({
|
||||
playDate: dayjs().subtract(80, 'seconds')
|
||||
});
|
||||
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
assert.isFalse(testScrobbler.shouldRefreshScrobble());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scrobble client uses transform plays correctly', function() {
|
||||
|
||||
beforeEach(async function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
await testScrobbler.initialize();
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
testScrobbler.scrobbleSleep = 500;
|
||||
testScrobbler.scrobbleDelay = 0;
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
testScrobbler.initScrobbleMonitoring();
|
||||
testScrobbler.config.options = {};
|
||||
//testScrobbler.initScrobbleMonitoring().catch(console.error);
|
||||
});
|
||||
|
||||
it('Transforms play before queue when preCompare is present', async function() {
|
||||
testScrobbler.config.options = {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: [
|
||||
'cool'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
testScrobbler.buildTransformRules();
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my cool track'
|
||||
});
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
expect(testScrobbler.queuedScrobbles[0].play.data.track).is.eq('my track');
|
||||
});
|
||||
|
||||
it('Transforms play on scrobble when postCompare is present', async function() {
|
||||
testScrobbler.config.options = {
|
||||
playTransform: {
|
||||
postCompare: {
|
||||
title: [
|
||||
'cool'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
testScrobbler.buildTransformRules();
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my cool track'
|
||||
});
|
||||
testScrobbler.queueScrobble(newScrobble, 'test');
|
||||
expect(testScrobbler.queuedScrobbles[0].play.data.track).is.eq('my cool track');
|
||||
testScrobbler.scrobbleSleep = 100;
|
||||
testScrobbler.initScrobbleMonitoring().catch(console.error);
|
||||
|
||||
const e = (await pEvent(testScrobbler.emitter, 'scrobble')) as {data: {play: PlayObject }};
|
||||
expect(e.data.play.data.track).is.eq('my track');
|
||||
});
|
||||
|
||||
it('Transforms candidate play on comparison', async function() {
|
||||
testScrobbler.config.options = {
|
||||
playTransform: {
|
||||
compare: {
|
||||
candidate: {
|
||||
title: [
|
||||
'hugely cool and very different track'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my hugely cool and very different track title'
|
||||
});
|
||||
|
||||
testScrobbler.recentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate});
|
||||
testScrobbler.buildTransformRules();
|
||||
|
||||
expect(await testScrobbler.alreadyScrobbled(newScrobble)).is.false;
|
||||
});
|
||||
|
||||
it('Transforms existing play on comparison', async function() {
|
||||
testScrobbler.config.options = {
|
||||
playTransform: {
|
||||
compare: {
|
||||
existing: {
|
||||
title: [
|
||||
'hugely cool and very different track'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my hugely cool and very different track title'
|
||||
});
|
||||
|
||||
testScrobbler.recentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate});
|
||||
testScrobbler.buildTransformRules();
|
||||
|
||||
expect(await testScrobbler.alreadyScrobbled(newScrobble)).is.false;
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
this.timeout(3500);
|
||||
await testScrobbler.tryStopScrobbling()
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Manages scrobble queue', function() {
|
||||
|
||||
before(async function() {
|
||||
testScrobbler = generateTestScrobbler();
|
||||
await testScrobbler.initialize();
|
||||
testScrobbler.recentScrobbles = normalizedWithMixedDur;
|
||||
testScrobbler.scrobbleSleep = 500;
|
||||
testScrobbler.scrobbleDelay = 0;
|
||||
testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
|
||||
testScrobbler.initScrobbleMonitoring().catch(console.error);
|
||||
});
|
||||
|
||||
it('Scrobbles a uniquely queued play', async function() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import AbstractSource from "../../sources/AbstractSource.js";
|
||||
|
||||
export class TestSource extends AbstractSource {
|
||||
handle(plays: PlayObject[]) {
|
||||
this.scrobble(plays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { loggerTest, loggerDebug } from "@foxxmd/logging";
|
||||
import chai, { assert, expect } from 'chai';
|
||||
import asPromised from 'chai-as-promised';
|
||||
import EventEmitter from "events";
|
||||
import { after, before, describe, it } from 'mocha';
|
||||
import pEvent from "p-event";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { generatePlay } from "../utils/PlayTestUtils.js";
|
||||
import { TestSource } from "./TestSource.js";
|
||||
|
||||
chai.use(asPromised);
|
||||
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const generateSource = () => {
|
||||
return new TestSource('spotify', 'test', {}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest}, emitter);
|
||||
}
|
||||
let source: TestSource = generateSource();
|
||||
|
||||
describe('Sources use transform plays correctly', function () {
|
||||
|
||||
beforeEach(function() {
|
||||
source = generateSource();
|
||||
});
|
||||
|
||||
it('Transforms play on preCompare', function() {
|
||||
source.config.options = {
|
||||
playTransform: {
|
||||
preCompare: {
|
||||
title: [
|
||||
{
|
||||
search: 'cool',
|
||||
replace: 'fun'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
source.buildTransformRules();
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my cool track'
|
||||
});
|
||||
const discovered = source.discover([newScrobble])
|
||||
expect(discovered.length).eq(1);
|
||||
expect(discovered[0].data.track).is.eq('my fun track');
|
||||
});
|
||||
|
||||
it('Transforms play on postCompare', async function() {
|
||||
source.config.options = {
|
||||
playTransform: {
|
||||
postCompare: {
|
||||
title: [
|
||||
{
|
||||
search: 'cool',
|
||||
replace: 'fun'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
source.buildTransformRules();
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my cool track'
|
||||
});
|
||||
const discovered = source.discover([newScrobble])
|
||||
expect(discovered.length).eq(1);
|
||||
expect(discovered[0].data.track).is.eq('my cool track');
|
||||
|
||||
const pAwaiter = pEvent(source.emitter, 'discoveredToScrobble') as Promise<{data: [PlayObject] }>;
|
||||
source.handle(discovered);
|
||||
const e = await pAwaiter;
|
||||
expect(e.data.length).is.eq(1);
|
||||
expect(e.data[0].data.track).is.eq('my fun track');
|
||||
});
|
||||
|
||||
it('Transforms play existing comparison', function() {
|
||||
source.config.options = {
|
||||
playTransform: {
|
||||
compare: {
|
||||
existing: {
|
||||
title: [
|
||||
{
|
||||
search: 'hugely cool and very different track',
|
||||
replace: 'fun'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
source.buildTransformRules();
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my hugely cool and very different track title',
|
||||
});
|
||||
const discovered = source.discover([newScrobble])
|
||||
expect(discovered.length).eq(1);
|
||||
expect(discovered[0].data.track).is.eq('my hugely cool and very different track title');
|
||||
|
||||
expect(source.discover([newScrobble]).length).is.eq(1);
|
||||
});
|
||||
|
||||
it('Transforms play candidate comparison', function() {
|
||||
source.config.options = {
|
||||
playTransform: {
|
||||
compare: {
|
||||
candidate: {
|
||||
title: [
|
||||
{
|
||||
search: 'hugely cool and very different track',
|
||||
replace: 'fun'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
source.buildTransformRules();
|
||||
const newScrobble = generatePlay({
|
||||
track: 'my hugely cool and very different track title',
|
||||
});
|
||||
const discovered = source.discover([newScrobble])
|
||||
expect(discovered.length).eq(1);
|
||||
expect(discovered[0].data.track).is.eq('my hugely cool and very different track title');
|
||||
|
||||
expect(source.discover([newScrobble]).length).is.eq(1);
|
||||
});
|
||||
})
|
||||
@@ -81,10 +81,10 @@ export const generatePlay = (data: ObjectPlayData = {}, meta: PlayMeta = {}): Pl
|
||||
return {
|
||||
data: {
|
||||
track: faker.music.songName(),
|
||||
artists: faker.helpers.multiple(faker.person.fullName, {count: {min: 1, max: 2}}),
|
||||
artists: faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}}),
|
||||
duration: faker.number.int({min: 30, max: 300}),
|
||||
playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})),
|
||||
album: faker.music.songName(),
|
||||
album: faker.music.album(),
|
||||
...data
|
||||
},
|
||||
meta: {
|
||||
|
||||
+64
-2
@@ -1,4 +1,5 @@
|
||||
import { Logger } from '@foxxmd/logging';
|
||||
import { SearchAndReplaceRegExp } from "@foxxmd/regex-buddy-core";
|
||||
import backoffStrategies from '@kenyip/backoff-strategies';
|
||||
import address from "address";
|
||||
import * as AjvNS from 'ajv';
|
||||
@@ -22,11 +23,11 @@ import {
|
||||
NO_USER,
|
||||
numberFormatOptions,
|
||||
PlayerStateData,
|
||||
PlayPlatformId,
|
||||
PlayPlatformId, PlayTransformParts,
|
||||
ProgressAwarePlayObject,
|
||||
RegExResult,
|
||||
RemoteIdentityParts,
|
||||
ScrobbleThresholdResult,
|
||||
ScrobbleThresholdResult, SearchAndReplaceTerm,
|
||||
} from "./common/infrastructure/Atomic.js";
|
||||
|
||||
//const { default: Ajv } = AjvNS;
|
||||
@@ -790,3 +791,64 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => {
|
||||
finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== '')));
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
export const configValToSearchReplace = (val: string | undefined | object): SearchAndReplaceRegExp | undefined => {
|
||||
if (val === undefined || val === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
return {
|
||||
search: val,
|
||||
replace: ''
|
||||
}
|
||||
}
|
||||
if (isSearchAndReplace(val)) {
|
||||
return val as SearchAndReplaceRegExp;
|
||||
}
|
||||
throw new Error(`Value must be a string or an object containing 'search: string' and 'replace: 'string'. Given: ${val}`);
|
||||
}
|
||||
|
||||
export const isSearchAndReplace = (val: object): val is SearchAndReplaceRegExp => {
|
||||
return typeof val === 'object'
|
||||
&& ('search' in val && typeof val.search === 'string')
|
||||
&& ('replace' in val && typeof val.replace === 'string');
|
||||
}
|
||||
|
||||
export const configPartsToStrongParts = (val: PlayTransformParts<SearchAndReplaceTerm> | undefined): PlayTransformParts<SearchAndReplaceRegExp> => {
|
||||
if (val === undefined) {
|
||||
return {}
|
||||
}
|
||||
const {
|
||||
title: titleConfig,
|
||||
artists: artistConfig,
|
||||
album: albumConfig
|
||||
} = val;
|
||||
let title,
|
||||
artists,
|
||||
album;
|
||||
|
||||
if (titleConfig !== undefined) {
|
||||
if (!Array.isArray(titleConfig)) {
|
||||
throw new Error('title must be an array');
|
||||
}
|
||||
title = titleConfig.map(configValToSearchReplace);
|
||||
}
|
||||
if (artistConfig !== undefined) {
|
||||
if (!Array.isArray(artistConfig)) {
|
||||
throw new Error('arist must be an array');
|
||||
}
|
||||
artists = artistConfig.map(configValToSearchReplace);
|
||||
}
|
||||
if (albumConfig !== undefined) {
|
||||
if (!Array.isArray(albumConfig)) {
|
||||
throw new Error('albumConfig must be an array');
|
||||
}
|
||||
album = albumConfig.map(configValToSearchReplace);
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
artists,
|
||||
album
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import net from 'node:net';
|
||||
|
||||
export interface PortReachableOpts {
|
||||
host: string,
|
||||
timeout?: number
|
||||
}
|
||||
/**
|
||||
* Copied from https://github.com/sindresorhus/is-port-reachable with error reporting
|
||||
* */
|
||||
export const isPortReachable = async (port: number, opts: PortReachableOpts) => {
|
||||
const {host, timeout = 1000} = opts;
|
||||
|
||||
const promise = new Promise(((resolve, reject) => {
|
||||
const socket = new net.Socket();
|
||||
|
||||
const onError = (e) => {
|
||||
socket.destroy();
|
||||
reject(e);
|
||||
};
|
||||
const onTimeout = () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out after ${timeout}ms`));
|
||||
}
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
|
||||
socket.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
}));
|
||||
|
||||
try {
|
||||
await promise;
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
+56
-15
@@ -6,6 +6,8 @@ import {
|
||||
} from "react-router-dom";
|
||||
import {connect, ConnectedProps, Provider} from 'react-redux'
|
||||
import './App.css';
|
||||
import CopyToClipboard from "./components/CopyToClipboard";
|
||||
import ExternalLink from "./components/ExternalLink";
|
||||
import {store} from './store';
|
||||
import Dashboard from "./dashboard/dashboard";
|
||||
import RecentPage from "./recent/RecentPage";
|
||||
@@ -16,7 +18,7 @@ import {useEventSource, useEventSourceListener} from "@react-nano/use-event-sour
|
||||
import Version from "./Version";
|
||||
|
||||
function NoMatch() {
|
||||
let location = useLocation();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -25,22 +27,52 @@ function NoMatch() {
|
||||
);
|
||||
}
|
||||
|
||||
// https://tailwindflex.com/@sienna/copy-code-block
|
||||
function MissingDocs() {
|
||||
return (
|
||||
<div>
|
||||
<div>Oops! You need to build docs first. Run the following commands to build:</div>
|
||||
<code
|
||||
className="mt-5 text-sm sm:text-base inline-flex text-left items-center space-x-4 bg-gray-900 text-white rounded-lg p-4 pl-6">
|
||||
<span className="flex gap-4">
|
||||
<span className="shrink-0 text-gray-500">
|
||||
$
|
||||
</span>
|
||||
|
||||
<span className="flex-1">
|
||||
<span>
|
||||
npm run <span className="text-yellow-500">docs:install</span> && npm run <span
|
||||
className="text-yellow-500">docs:build</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<CopyToClipboard text="npm run docs:install && npm run docs:build"/>
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
element: <Dashboard />,
|
||||
element: <Dashboard/>,
|
||||
},
|
||||
{
|
||||
path: "/recent",
|
||||
element: <RecentPage />,
|
||||
element: <RecentPage/>,
|
||||
},
|
||||
{
|
||||
path: "/scrobbled",
|
||||
element: <ScrobbledPage />,
|
||||
element: <ScrobbledPage/>,
|
||||
},
|
||||
{
|
||||
path: "/dead",
|
||||
element: <DeadPage />,
|
||||
element: <DeadPage/>,
|
||||
},
|
||||
{
|
||||
path: "/docs",
|
||||
element: <MissingDocs/>
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
@@ -93,18 +125,27 @@ function App() {
|
||||
<div className="min-w-screen min-h-screen bg-gray-800 font-sans text-white">
|
||||
<div className="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div className="container mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/" className="flex items-center flex-grow no-underline pr-4">
|
||||
<img src="icon.svg" style={{maxWidth: '30px'}}/>
|
||||
<Version/>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/" className="flex items-center no-underline pr-4">
|
||||
<img src="/icon.svg" style={{maxWidth: '30px'}}/>
|
||||
<span className="ml-2">Multi Scrobbler</span>
|
||||
</a>
|
||||
<Version/>
|
||||
<span className="space-x-3" style={{marginLeft: 'auto'}}>
|
||||
<a href="/docs">
|
||||
Docs
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/FoxxMD/multi-scrobbler">
|
||||
Github <ExternalLink/>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto">
|
||||
<ConnectedGlobal/>
|
||||
<RouterProvider router={router}/>
|
||||
</div>
|
||||
<div className="container mx-auto">
|
||||
<ConnectedGlobal/>
|
||||
<RouterProvider router={router}/>
|
||||
</div>
|
||||
</div>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -18,8 +18,8 @@ const Version = () => {
|
||||
data = undefined,
|
||||
} = useGetVersionQuery(undefined);
|
||||
|
||||
return <span className="px-4 break-normal">
|
||||
Multi Scrobbler <span className="ml-2 text-xs version">{data === undefined ? null : `${data.version}`}</span>
|
||||
return <span className="break-normal">
|
||||
<span className="text-xs version">{data === undefined ? null : `${data.version}`}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface Props {
|
||||
classNames?: string[]
|
||||
style?: object
|
||||
text?: string
|
||||
}
|
||||
|
||||
const defaultStyle = {}
|
||||
const defaultClassNames: string[] = ['shrink-0', 'h-5', 'w-5', 'transition', 'text-gray-500', 'hover:text-white', 'cursor-pointer'];
|
||||
|
||||
const CopyToClipboard = (props: Props) => {
|
||||
const {classNames = [], style = defaultStyle, text } = props;
|
||||
const classes = defaultClassNames;
|
||||
clsx(classes.concat(classNames))
|
||||
return (
|
||||
<svg
|
||||
onClick={() => {navigator.clipboard.writeText(text)}}
|
||||
className={clsx(classes.concat(classNames))} style={style}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z"></path>
|
||||
<path
|
||||
d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z">
|
||||
</path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyToClipboard;
|
||||
@@ -0,0 +1,24 @@
|
||||
import {PropsWithChildren} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface TooltipProps {
|
||||
classNames?: string[]
|
||||
style?: object
|
||||
}
|
||||
|
||||
const defaultStyle = {marginLeft: '2px', display: 'inline-block'};
|
||||
|
||||
const ExternalLink = (props: PropsWithChildren<TooltipProps>) => {
|
||||
const {classNames = [], style = defaultStyle } = props;
|
||||
const classes = [];
|
||||
clsx(classes.concat(classNames))
|
||||
return (
|
||||
<svg width="13.5" height="13.5" aria-hidden="true" viewBox="0 0 24 24"
|
||||
className={clsx(classes.concat(classNames))} style={style}>
|
||||
<path fill="currentColor"
|
||||
d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035 4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExternalLink;
|
||||
@@ -1,22 +1,33 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import React, { Fragment, useMemo } from 'react';
|
||||
import PlayDisplay from "../components/PlayDisplay";
|
||||
import {recentIncludes} from "../../core/Atomic";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {useGetRecentQuery} from "./recentDucks";
|
||||
import { recentIncludes } from "../../core/Atomic";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useGetRecentQuery } from "./recentDucks";
|
||||
import Tooltip from "../components/Tooltip";
|
||||
import {faQuestionCircle} from "@fortawesome/free-solid-svg-icons";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {data} from "autoprefixer";
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
const displayOpts = {
|
||||
include: recentIncludes,
|
||||
includeWeb: true
|
||||
}
|
||||
|
||||
const apiTip = <Fragment>
|
||||
const apiTipContent = <Fragment>
|
||||
<div>Data that is directly returned by the Source API.</div>
|
||||
<div>If you do not see your recent plays in this data it is likely the Source's data is lagging behind your actual activity.</div>
|
||||
</Fragment>
|
||||
<div>If you do not see your recent plays in this data it is likely the Source's data is lagging behind your
|
||||
actual activity.
|
||||
</div>
|
||||
<div className="mt-2"></div>
|
||||
</Fragment>;
|
||||
|
||||
const tsTip = <div className="mt-2">
|
||||
<div>
|
||||
<code>(C)</code> - Scrobble timestamped when listen was <strong>completed</strong>
|
||||
</div>
|
||||
<div>
|
||||
<code>(S)</code> - Scrobble timestamped when listen was <strong>started</strong>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const recent = () => {
|
||||
let [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -25,17 +36,32 @@ const recent = () => {
|
||||
error,
|
||||
isLoading,
|
||||
isSuccess
|
||||
} = useGetRecentQuery({name: searchParams.get('name'), type: searchParams.get('type'), upstream: searchParams.get('upstream')});
|
||||
} = useGetRecentQuery({
|
||||
name: searchParams.get('name'),
|
||||
type: searchParams.get('type'),
|
||||
upstream: searchParams.get('upstream')
|
||||
});
|
||||
|
||||
const isUpstream = searchParams.get('upstream') === '1';
|
||||
|
||||
const tipContents = useMemo(() => {
|
||||
return <Fragment>
|
||||
{isUpstream ? apiTipContent : null}
|
||||
{tsTip}
|
||||
</Fragment>
|
||||
}, [isUpstream]);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="shadow-md rounded bg-gray-500 text-white">
|
||||
<div className="p-3 font-semibold bg-gray-700 text-white">
|
||||
<h2>Recently Played{isUpstream ? ' from Source API' : null}{isUpstream ? <Tooltip message={apiTip}
|
||||
classNames={['ml-2']}
|
||||
style={{display: 'inline-flex', width: '35%'}}><FontAwesomeIcon color="white" icon={faQuestionCircle}/></Tooltip> : null}
|
||||
<h2>Recently Played{isUpstream ? ' from Source API' : null}<Tooltip message={tipContents}
|
||||
classNames={['ml-2']}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
width: '35%'
|
||||
}}><FontAwesomeIcon
|
||||
color="white" icon={faQuestionCircle}/></Tooltip>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
|
||||
+12
-9
@@ -31,13 +31,14 @@ export interface ClientStatusData {
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
export type PlayObjectIncludeTypes = 'time' | 'artist' | 'track' | 'timeFromNow' | 'trackId' | 'comment';
|
||||
export const recentIncludes: PlayObjectIncludeTypes[] = ['time', 'timeFromNow', 'track', 'artist', 'comment'];
|
||||
export type PlayObjectIncludeTypes = 'album' | 'time' | 'artist' | 'track' | 'timeFromNow' | 'trackId' | 'comment';
|
||||
export const recentIncludes: PlayObjectIncludeTypes[] = ['time', 'timeFromNow', 'track', 'album', 'artist', 'comment'];
|
||||
|
||||
export interface TrackStringOptions<T = string> {
|
||||
include?: PlayObjectIncludeTypes[]
|
||||
transformers?: {
|
||||
artists?: (a: string[]) => T | string
|
||||
album?: (t: string,data: AmbPlayObject, hasExistingParts?: boolean) => T | string
|
||||
track?: (t: string,data: AmbPlayObject, hasExistingParts?: boolean) => T | string
|
||||
time?: (t: Dayjs, i?: ScrobbleTsSOC) => T | string
|
||||
timeFromNow?: (t: Dayjs) => T | string
|
||||
@@ -57,6 +58,14 @@ export interface ListenRangeData {
|
||||
end: ListenProgress
|
||||
}
|
||||
|
||||
export interface BrainzMeta {
|
||||
artist?: string[]
|
||||
albumArtist?: string
|
||||
album?: string
|
||||
track?: string
|
||||
releaseGroup?: string
|
||||
}
|
||||
|
||||
export interface TrackData {
|
||||
artists?: string[]
|
||||
albumArtists?: string[]
|
||||
@@ -68,13 +77,7 @@ export interface TrackData {
|
||||
duration?: number
|
||||
|
||||
meta?: {
|
||||
brainz?: {
|
||||
artist?: string[]
|
||||
albumArtist?: string
|
||||
album?: string
|
||||
track?: string
|
||||
releaseGroup?: string
|
||||
}
|
||||
brainz?: BrainzMeta
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,14 @@ export const truncateStringToLength = (length: any, truncStr = '...') => (val: a
|
||||
export const defaultTrackTransformer = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `- ${input}` : input;
|
||||
export const defaultReducer = (acc, curr) => `${acc} ${curr}`;
|
||||
export const defaultArtistFunc = (a: string[]) => a.join(' / ');
|
||||
export const defaultAlbumFunc = (input: any, data: AmbPlayObject, hasExistingParts: boolean = false) => hasExistingParts ? `--- ${input}` : input;
|
||||
export const defaultTimeFunc = (t: Dayjs | undefined, i?: ScrobbleTsSOC) => t === undefined ? '@ N/A' : `@ ${t.local().format()} ${i === undefined ? '' : (i === SCROBBLE_TS_SOC_START ? '(S)' : '(C)')}`;
|
||||
export const defaultTimeFromNowFunc = (t: Dayjs | undefined) => t === undefined ? undefined : `(${t.local().fromNow()})`;
|
||||
export const defaultCommentFunc = (c: string | undefined) => c === undefined ? undefined : `(${c})`;
|
||||
export const defaultBuildTrackStringTransformers = {
|
||||
artists: defaultArtistFunc,
|
||||
track: defaultTrackTransformer,
|
||||
album: defaultAlbumFunc,
|
||||
time: defaultTimeFunc,
|
||||
timeFromNow: defaultTimeFromNowFunc,
|
||||
comment: defaultCommentFunc
|
||||
@@ -48,6 +50,7 @@ export const buildTrackString = <T = string>(playObj: AmbPlayObject, options: Tr
|
||||
include = ['time', 'artist', 'track'],
|
||||
transformers: {
|
||||
artists: artistsFunc = defaultBuildTrackStringTransformers.artists,
|
||||
album: albumFunc = defaultBuildTrackStringTransformers.album,
|
||||
track: trackFunc = defaultBuildTrackStringTransformers.track,
|
||||
time: timeFunc = defaultBuildTrackStringTransformers.time,
|
||||
timeFromNow = defaultBuildTrackStringTransformers.timeFromNow,
|
||||
@@ -89,6 +92,9 @@ export const buildTrackString = <T = string>(playObj: AmbPlayObject, options: Tr
|
||||
if (include.includes('track')) {
|
||||
strParts.push(trackFunc(track, playObj, strParts.length > 0));
|
||||
}
|
||||
if (include.includes('album')) {
|
||||
strParts.push(albumFunc(album, playObj, strParts.length > 0));
|
||||
}
|
||||
if (include.includes('time')) {
|
||||
strParts.push(timeFunc(pd, usedTsSOC));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user