mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a8ad2418 | ||
|
|
49bd1c8836 | ||
|
|
fc3dd62858 | ||
|
|
ff114a0ae5 | ||
|
|
8bfcb6cd9a | ||
|
|
8154e30939 | ||
|
|
506440825f | ||
|
|
646723fcf8 | ||
|
|
a560a49aac | ||
|
|
de763d68f3 | ||
|
|
f731c25332 | ||
|
|
f738eb92e8 | ||
|
|
b9f4f43d30 | ||
|
|
e42f00604e | ||
|
|
74eed98b9b | ||
|
|
cef6f5864a | ||
|
|
d5e816b1d3 | ||
|
|
dcc4201019 | ||
|
|
9c093a8455 | ||
|
|
9a56a3ee4d | ||
|
|
f2b0714dea | ||
|
|
0a1357acc7 | ||
|
|
403af711eb | ||
|
|
31cd72cd15 | ||
|
|
55afe876e0 | ||
|
|
49fadd8c9a | ||
|
|
58157ddfee | ||
|
|
2a9cd87848 | ||
|
|
7b31285f89 | ||
|
|
89db858289 | ||
|
|
2fec6aff6e | ||
|
|
9beadfaf0f | ||
|
|
855d3f6144 |
@@ -10,6 +10,8 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git
|
||||
* [Spotify](/docs/configuration.md#spotify)
|
||||
* [Plex](/docs/configuration.md#plex) or [Tautulli](/docs/configuration.md#tautulli)
|
||||
* [Subsonic-compatible APIs](/docs/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
|
||||
* [Jellyfin](/docs/configuration.md#jellyfin)
|
||||
* [Last.fm](/docs/configuration.md#lastfm-source)
|
||||
* Supports scrobbling to many clients
|
||||
* [Maloja](/docs/configuration.md#maloja)
|
||||
* [Last.fm](/docs/configuration.md#lastfm)
|
||||
@@ -26,6 +28,12 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git
|
||||
* **Consolidate play sources** -- Scrobble from many sources to one client with ease and without duplicating tracks.
|
||||
* **Manage scrobbling for others** -- Scrobble for your friends and family without any setup on their part. Easily silo sources to specific clients to keep plays separate.
|
||||
|
||||
**But I already scrobble my music to Last.fm, is multi-scrobbler for me?**
|
||||
|
||||
Yes! You can use [Last.fm as a Source](/docs/configuration.md#lastfm-source) to mirror scrobbles from your Last.fm profile to Maloja. 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.jpg" width="800">
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -42,7 +50,7 @@ npm install
|
||||
### [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
|
||||
|
||||
```
|
||||
foxxmd/spotify-scrobbler:latest
|
||||
foxxmd/multi-scrobbler:latest
|
||||
```
|
||||
|
||||
## Setup
|
||||
@@ -61,7 +69,7 @@ SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL=http://doma
|
||||
#### Docker
|
||||
|
||||
```bash
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -v /path/on/host/config:/home/node/app/config foxxmd/spotify-scrobbler
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -v /path/on/host/config:/home/node/app/config foxxmd/multi-scrobbler
|
||||
```
|
||||
|
||||
**But I want to use json for configuration?**
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {capitalize, createLabelledLogger} from "../utils.js";
|
||||
|
||||
export default class AbstractApiClient {
|
||||
name;
|
||||
type;
|
||||
initialized = false;
|
||||
|
||||
config;
|
||||
options;
|
||||
logger;
|
||||
|
||||
client;
|
||||
workingCredsPath;
|
||||
redirectUri;
|
||||
|
||||
constructor(type, name, config = {}, options = {}) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `API - ${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(identifier, identifier);
|
||||
this.config = config;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
static formatPlayObj = obj => {
|
||||
throw new Error('should be overridden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import LastFm from "lastfm-node-client";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import dayjs from "dayjs";
|
||||
import {readJson, sleep, writeFile} from "../utils.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
'invalid session key',
|
||||
'invalid api key',
|
||||
'authentication failed'
|
||||
];
|
||||
|
||||
const retryErrors = [
|
||||
'operation failed',
|
||||
'service offline',
|
||||
'temporarily unavailable',
|
||||
'rate limit'
|
||||
]
|
||||
|
||||
export default class LastfmApiClient extends AbstractApiClient {
|
||||
|
||||
user;
|
||||
|
||||
constructor(name, config = {}, options = {}) {
|
||||
super('lastfm', name, config, options);
|
||||
const {redirectUri, apiKey, secret, session, configDir} = config;
|
||||
this.redirectUri = `${redirectUri}?state=${name}`;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config!");
|
||||
}
|
||||
this.workingCredsPath = `${configDir}/currentCreds-lastfm-${name}.json`;
|
||||
this.client = new LastFm(apiKey, secret, session);
|
||||
}
|
||||
|
||||
static formatPlayObj = obj => {
|
||||
const {
|
||||
artist: {
|
||||
// last.fm doesn't seem consistent with which of these properties it returns...
|
||||
'#text': artists,
|
||||
name: artistName,
|
||||
},
|
||||
name: title,
|
||||
album: {
|
||||
'#text': album,
|
||||
},
|
||||
duration,
|
||||
date: {
|
||||
uts: time,
|
||||
} = {},
|
||||
'@attr': {
|
||||
nowplaying = 'false',
|
||||
} = {},
|
||||
url,
|
||||
mbid,
|
||||
} = obj;
|
||||
// arbitrary decision yikes
|
||||
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: time !== undefined ? dayjs.unix(time) : undefined,
|
||||
},
|
||||
meta: {
|
||||
nowPlaying: nowplaying === 'true',
|
||||
mbid,
|
||||
source: 'Lastfm',
|
||||
url: {
|
||||
web: url,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callApi = async (func, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 2,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
return await func(this.client);
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
} = e;
|
||||
// for now check for exceptional errors by matching error code text
|
||||
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
|
||||
if(undefined !== retryError) {
|
||||
if(retries < maxRequestRetries) {
|
||||
const delay = (retries + 1) * retryMultiplier;
|
||||
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
|
||||
await sleep(delay * 1000);
|
||||
return this.callApi(func, retries + 1);
|
||||
} else {
|
||||
this.logger.warn('Could not recover!');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl = () => {
|
||||
const redir = `${this.config.redirectUri}?state=${this.name}`;
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
|
||||
}
|
||||
|
||||
authenticate = async (token) => {
|
||||
const sessionRes = await this.client.authGetSession({token});
|
||||
const {
|
||||
session: {
|
||||
key: sessionKey,
|
||||
name, // username
|
||||
} = {}
|
||||
} = sessionRes;
|
||||
this.client.sessionKey = sessionKey;
|
||||
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
sessionKey,
|
||||
}));
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
|
||||
try {
|
||||
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
const {sessionKey} = creds || {};
|
||||
if (this.client.sessionKey === undefined && sessionKey !== undefined) {
|
||||
this.client.sessionKey = sessionKey;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.warn('Current lastfm credentials file exists but could not be parsed', {path: this.workingCredsPath});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
if (this.client.sessionKey === undefined) {
|
||||
this.logger.info('No session key found. User interaction for authentication required.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const infoResp = await this.callApi(client => client.userGetInfo());
|
||||
const {
|
||||
user: {
|
||||
name,
|
||||
} = {}
|
||||
} = infoResp;
|
||||
this.user = name;
|
||||
this.initialized = true;
|
||||
this.logger.info(`Client authorized for user ${name}`)
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing auth failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@@ -6,6 +6,9 @@ export default class AbstractScrobbleClient {
|
||||
name;
|
||||
type;
|
||||
initialized = false;
|
||||
requiresAuth = false;
|
||||
requiresAuthInteraction = false;
|
||||
authed = false;
|
||||
|
||||
recentScrobbles = [];
|
||||
scrobbledPlayObjs = [];
|
||||
@@ -24,7 +27,7 @@ export default class AbstractScrobbleClient {
|
||||
constructor(type, name, config = {}) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
const identifier = `${capitalize(this.type)} - ${name}`;
|
||||
const identifier = `Client ${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(identifier, identifier);
|
||||
|
||||
const {
|
||||
@@ -60,6 +63,17 @@ export default class AbstractScrobbleClient {
|
||||
};
|
||||
}
|
||||
|
||||
// default init function, should be overridden if init stage is required
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
scrobblesLastCheckedAt = () => {
|
||||
return this.lastScrobbleCheck;
|
||||
}
|
||||
|
||||
+50
-132
@@ -1,167 +1,85 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import dayjs from 'dayjs';
|
||||
import LastFm from 'lastfm-node-client';
|
||||
|
||||
import {
|
||||
buildTrackString,
|
||||
playObjDataMatch,
|
||||
readJson,
|
||||
setIntersection, sleep,
|
||||
sortByPlayDate,
|
||||
truncateStringToLength, writeFile
|
||||
truncateStringToLength,
|
||||
} from "../utils.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
'invalid session key',
|
||||
'invalid api key',
|
||||
'authentication failed'
|
||||
];
|
||||
|
||||
const retryErrors = [
|
||||
'operation failed',
|
||||
'service offline',
|
||||
'temporarily unavailable',
|
||||
'rate limit'
|
||||
]
|
||||
import LastfmApiClient from "../apis/LastfmApiClient.js";
|
||||
|
||||
export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
client;
|
||||
redirectUri;
|
||||
workingCredsPath;
|
||||
api;
|
||||
initialized = false;
|
||||
user;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
constructor(name, config = {}, options = {}) {
|
||||
super('lastfm', name, config, options);
|
||||
const {redirectUri, apiKey, secret, session, configDir} = config;
|
||||
this.redirectUri = `${redirectUri}?state=${name}`;
|
||||
if (apiKey === undefined) {
|
||||
this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble");
|
||||
}
|
||||
this.workingCredsPath = `${configDir}/currentCreds-lastfm-${name}.json`;
|
||||
this.client = new LastFm(apiKey, secret, session);
|
||||
this.api = new LastfmApiClient(name, config, options)
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
const {
|
||||
artist: {
|
||||
'#text': artists
|
||||
},
|
||||
name: title,
|
||||
album: {
|
||||
'#text': album,
|
||||
},
|
||||
duration,
|
||||
date: {
|
||||
uts: time,
|
||||
},
|
||||
} = obj;
|
||||
let artistStrings = artists.split(',');
|
||||
return {
|
||||
data: {
|
||||
artists: [...new Set(artistStrings)],
|
||||
track: title,
|
||||
album,
|
||||
duration,
|
||||
playDate: dayjs.unix(time),
|
||||
},
|
||||
meta: {
|
||||
source: 'Lastfm',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatPlayObj = obj => LastfmScrobbler.formatPlayObj(obj);
|
||||
|
||||
callApi = async (func, tries = 1) => {
|
||||
try {
|
||||
return await func(this.client);
|
||||
} catch (e) {
|
||||
const {
|
||||
message,
|
||||
} = e;
|
||||
// for now check for exceptional errors by matching error code text
|
||||
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
|
||||
if(undefined !== retryError) {
|
||||
if(tries <= 2) {
|
||||
const delay = tries * 3;
|
||||
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
|
||||
await sleep(delay * 1000);
|
||||
return this.callApi(func, tries + 1);
|
||||
} else {
|
||||
this.logger.warn('Could not recover!');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl = () => {
|
||||
const redir = `${this.config.redirectUri}?state=${this.name}`;
|
||||
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
|
||||
}
|
||||
|
||||
authenticate = async (token) => {
|
||||
const sessionRes = await this.client.authGetSession({token});
|
||||
const {
|
||||
session: {
|
||||
key: sessionKey,
|
||||
name, // username
|
||||
} = {}
|
||||
} = sessionRes;
|
||||
this.client.sessionKey = sessionKey;
|
||||
|
||||
await writeFile(this.workingCredsPath, JSON.stringify({
|
||||
sessionKey,
|
||||
}));
|
||||
}
|
||||
formatPlayObj = obj => LastfmApiClient.formatPlayObj(obj);
|
||||
|
||||
initialize = async () => {
|
||||
this.initialized = await this.api.initialize();
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
const creds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
|
||||
const {sessionKey} = creds || {};
|
||||
if (this.client.sessionKey === undefined && sessionKey !== undefined) {
|
||||
this.client.sessionKey = sessionKey;
|
||||
}
|
||||
this.authed = await this.api.testAuth();
|
||||
} catch (e) {
|
||||
this.logger.warn('Current lastfm credentials file exists but could not be parsed', {path: this.workingCredsPath});
|
||||
}
|
||||
|
||||
if (this.client.sessionKey === undefined) {
|
||||
this.logger.info('No session key found. User interaction for authentication required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const infoResp = await this.callApi(client => client.userGetInfo());
|
||||
const {
|
||||
user: {
|
||||
name,
|
||||
} = {}
|
||||
} = infoResp;
|
||||
this.user = name;
|
||||
this.initialized = true;
|
||||
this.logger.info(`Client authorized for user ${name}`)
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing connection failed');
|
||||
return false;
|
||||
this.logger.error('Could not successfully communicate with Last.fm API');
|
||||
this.logger.error(e);
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
if (this.refreshEnabled) {
|
||||
this.logger.debug('Refreshing recent scrobbles');
|
||||
const resp = await this.callApi(client => client.userGetRecentTracks({user: this.user, limit: 20}));
|
||||
const resp = await this.api.callApi(client => client.userGetRecentTracks({user: this.api.user, limit: 20, extended: true}));
|
||||
const {
|
||||
recenttracks: {
|
||||
track: list = [],
|
||||
}
|
||||
} = resp;
|
||||
this.recentScrobbles = list.map(x => LastfmScrobbler.formatPlayObj(x)).sort(sortByPlayDate);
|
||||
this.recentScrobbles = list.reduce((acc, x) => {
|
||||
try {
|
||||
const formatted = LastfmApiClient.formatPlayObj(x);
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
playDate,
|
||||
},
|
||||
meta: {
|
||||
mbid,
|
||||
nowPlaying,
|
||||
}
|
||||
} = formatted;
|
||||
if(nowPlaying === true) {
|
||||
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
|
||||
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
|
||||
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
|
||||
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
|
||||
return acc;
|
||||
} else if(playDate === undefined) {
|
||||
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
|
||||
return acc;
|
||||
}
|
||||
return acc.concat(formatted);
|
||||
} catch (e) {
|
||||
this.logger.warn('Failed to format Last.fm recently scrobbled track, omitting from time frame check', {error: e.message});
|
||||
this.logger.debug('Full api response object:');
|
||||
this.logger.debug(x);
|
||||
return acc;
|
||||
}
|
||||
}, []).sort(sortByPlayDate);
|
||||
if (this.recentScrobbles.length > 0) {
|
||||
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
|
||||
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
|
||||
@@ -340,7 +258,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
|
||||
const sType = newFromSource ? 'New' : 'Backlog';
|
||||
|
||||
try {
|
||||
const response = await this.callApi(client => client.trackScrobble(
|
||||
const response = await this.api.callApi(client => client.trackScrobble(
|
||||
{
|
||||
artist: artists.join(', '),
|
||||
duration,
|
||||
|
||||
+69
-20
@@ -1,12 +1,22 @@
|
||||
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
|
||||
import request from 'superagent';
|
||||
import dayjs from 'dayjs';
|
||||
import {buildTrackString, playObjDataMatch, setIntersection, sortByPlayDate, truncateStringToLength} from "../utils.js";
|
||||
import {
|
||||
buildTrackString,
|
||||
playObjDataMatch,
|
||||
setIntersection,
|
||||
sleep,
|
||||
sortByPlayDate,
|
||||
truncateStringToLength,
|
||||
parseRetryAfterSecsFromObj
|
||||
} from "../utils.js";
|
||||
|
||||
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
|
||||
|
||||
export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
requiresAuth = true;
|
||||
|
||||
constructor(name, config = {}, options = {}) {
|
||||
super('maloja', name, config, options);
|
||||
const {url, apiKey} = config;
|
||||
@@ -52,10 +62,21 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
formatPlayObj = obj => MalojaScrobbler.formatPlayObj(obj);
|
||||
|
||||
callApi = async (req) => {
|
||||
callApi = async (req, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
try {
|
||||
return await req;
|
||||
} catch (e) {
|
||||
if(retries < maxRequestRetries) {
|
||||
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
|
||||
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
return await this.callApi(req, retries + 1)
|
||||
}
|
||||
const {
|
||||
message,
|
||||
response: {
|
||||
@@ -74,24 +95,50 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
|
||||
testConnection = async () => {
|
||||
|
||||
const {url, apiKey} = this.config;
|
||||
const {url} = this.config;
|
||||
try {
|
||||
const serverInfoResp = await this.callApi(request.get(`${url}/apis/mlj_1/serverinfo`));
|
||||
const {
|
||||
statusCode,
|
||||
body: {
|
||||
version = [],
|
||||
versionstring = '',
|
||||
} = {},
|
||||
} = serverInfoResp;
|
||||
if (version.length === 0) {
|
||||
this.logger.error('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old :(');
|
||||
|
||||
if (statusCode >= 300) {
|
||||
this.logger.info('Test connection failed');
|
||||
return false;
|
||||
}
|
||||
this.logger.info(`Maloja Server Version: ${versionstring}`);
|
||||
if (version[0] < 2 || version[1] < 7) {
|
||||
this.logger.warn('Maloja Server Version is less than 2.7, please upgrade to ensure compatibility');
|
||||
}
|
||||
|
||||
this.logger.info('Test connection succeeded!');
|
||||
|
||||
if (version.length === 0) {
|
||||
this.logger.warn('Server did not respond with a version. Either the base URL is incorrect or this Maloja server is too old :(');
|
||||
} else {
|
||||
this.logger.info(`Maloja Server Version: ${versionstring}`);
|
||||
if (version[0] < 2 || version[1] < 7) {
|
||||
this.logger.warn('Maloja Server Version is less than 2.7, please upgrade to ensure compatibility');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing connection failed');
|
||||
this.logger.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// just checking that we can get a connection
|
||||
this.initialized = await this.testConnection();
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async (withKey = true) => {
|
||||
|
||||
const {url, apiKey} = this.config;
|
||||
try {
|
||||
const resp = await this.callApi(request
|
||||
.get(`${url}/apis/mlj_1/test`)
|
||||
.query({key: apiKey}));
|
||||
@@ -105,20 +152,22 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
|
||||
text = '',
|
||||
} = resp;
|
||||
if (bodyStatus.toLocaleLowerCase() === 'ok') {
|
||||
this.logger.info('Test connection succeeded!');
|
||||
this.initialized = true;
|
||||
return true;
|
||||
this.logger.info('Auth test passed!');
|
||||
this.authed = true;
|
||||
} else {
|
||||
this.authed = false;
|
||||
this.logger.error('Testing connection failed => Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {
|
||||
status,
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
});
|
||||
}
|
||||
this.logger.error('Testing connection failed => Server Response body was malformed -- should have returned "status: ok"...is the URL correct?', {
|
||||
status,
|
||||
body,
|
||||
text: text.slice(0, 50)
|
||||
});
|
||||
return false;
|
||||
} catch (e) {
|
||||
this.logger.error('Testing connection failed');
|
||||
return false;
|
||||
this.logger.error('Auth test failed');
|
||||
this.logger.error(e);
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
|
||||
+109
-55
@@ -26,6 +26,10 @@ export default class ScrobbleClients {
|
||||
return this.clients.find(x => x.name === name);
|
||||
}
|
||||
|
||||
getByType = (type) => {
|
||||
return this.clients.filter(x => x.type === type);
|
||||
}
|
||||
|
||||
buildClientsFromConfig = async () => {
|
||||
let configs = [];
|
||||
|
||||
@@ -33,20 +37,39 @@ export default class ScrobbleClients {
|
||||
try {
|
||||
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
// think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore
|
||||
throw new Error('config.json could not be parsed');
|
||||
}
|
||||
let clientDefaults = {};
|
||||
if (configFile !== undefined) {
|
||||
const {clients: mainConfigClientConfigs = []} = configFile;
|
||||
if (!mainConfigClientConfigs.every(x => x !== null && typeof x === 'object')) {
|
||||
throw new Error('All clients from config.json must be objects');
|
||||
}
|
||||
for (const c of mainConfigClientConfigs) {
|
||||
const {
|
||||
clients: mainConfigClientConfigs = [],
|
||||
clientDefaults: cd = {},
|
||||
} = configFile;
|
||||
clientDefaults = cd;
|
||||
const validMainConfigs = mainConfigClientConfigs.reduce((acc, curr, i) => {
|
||||
if(curr === null) {
|
||||
this.logger.error(`The client config entry at index ${i} in config.json is null but should be an object, will not parse`);
|
||||
return acc;
|
||||
}
|
||||
if(typeof curr !== 'object') {
|
||||
this.logger.error(`The client config entry at index ${i} in config.json should be an object, will not parse`);
|
||||
return acc;
|
||||
}
|
||||
return acc.concat(curr);
|
||||
}, []);
|
||||
for (const c of validMainConfigs) {
|
||||
const {name = 'unnamed'} = c;
|
||||
configs.push({...c, name, source: 'config.json'});
|
||||
configs.push({...c,
|
||||
name,
|
||||
source: 'config.json',
|
||||
configureAs: 'client', //override user value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const clientType of this.clientTypes) {
|
||||
let defaultConfigureAs = 'client';
|
||||
switch (clientType) {
|
||||
case 'maloja':
|
||||
// env builder for single user mode
|
||||
@@ -89,115 +112,142 @@ export default class ScrobbleClients {
|
||||
try {
|
||||
rawClientConfigs = await readJson(`${this.configDir}/${clientType}.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error(`${clientType}.json config file could not be parsed`);
|
||||
this.logger.error(`${clientType}.json config file could not be parsed`);
|
||||
continue;
|
||||
}
|
||||
if (rawClientConfigs !== undefined) {
|
||||
let clientConfigs = [];
|
||||
if (Array.isArray(rawClientConfigs)) {
|
||||
clientConfigs = rawClientConfigs;
|
||||
} else if (rawClientConfigs === null || typeof rawClientConfigs === 'object') {
|
||||
} else if(rawClientConfigs === null) {
|
||||
this.logger.error(`${clientType}.json contained no data`);
|
||||
continue;
|
||||
} else if (typeof rawClientConfigs === 'object') {
|
||||
// backwards compatibility, assuming its single-user mode
|
||||
this.logger.warn(`DEPRECATED: Starting in 0.4 configurations in all [type].json files (${clientType}.json) must be in an array.`);
|
||||
if (rawClientConfigs.data === undefined) {
|
||||
clientConfigs = [{data: rawClientConfigs, mode: 'single', name: 'unnamed'}];
|
||||
} else {
|
||||
clientConfigs = [rawClientConfigs];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`All top level data from ${clientType}.json must be an object or array of objects`);
|
||||
this.logger.error(`All top level data from ${clientType}.json must be an array of objects, will not parse configs from file`);
|
||||
continue;
|
||||
}
|
||||
for (const m of clientConfigs) {
|
||||
if (m === null || typeof m !== 'object') {
|
||||
throw new Error(`All top-level data from ${clientType}.json must be an object or array of objects`);
|
||||
for (const [i,m] of clientConfigs.entries()) {
|
||||
if(m === null) {
|
||||
this.logger.error(`The config entry at index ${i} from ${clientType}.json is null`);
|
||||
continue;
|
||||
}
|
||||
if (typeof m !== 'object') {
|
||||
this.logger.error(`The config entry at index ${i} from ${clientType}.json was not an object, skipping`, m);
|
||||
continue;
|
||||
}
|
||||
const {configureAs = defaultConfigureAs} = m;
|
||||
if(configureAs === 'client') {
|
||||
m.source = `${clientType}.json`;
|
||||
m.type = clientType;
|
||||
configs.push(m);
|
||||
}
|
||||
m.source = `${clientType}.json`;
|
||||
m.type = clientType;
|
||||
configs.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we have all possible client configurations so we'll check they are minimally valid
|
||||
const configErrors = configs.reduce((acc, c) => {
|
||||
const validConfigs = configs.reduce((acc, c) => {
|
||||
const isValid = isValidConfigStructure(c, {type: true, data: true});
|
||||
if (isValid !== true) {
|
||||
const msg = `Client config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] has errors: ${isValid.join(' | ')}`;
|
||||
return acc.concat(msg);
|
||||
this.logger.error(`Client config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] will not be used because it has structural errors: ${isValid.join(' | ')}`);
|
||||
return acc;
|
||||
}
|
||||
return acc;
|
||||
return acc.concat(c);
|
||||
}, []);
|
||||
if (configErrors.length > 0) {
|
||||
for (const m of configErrors) {
|
||||
this.logger.error(m);
|
||||
}
|
||||
throw new Error('Could not build clients due to above errors');
|
||||
}
|
||||
|
||||
// all client configs are minimally valid
|
||||
// now check that names are unique
|
||||
const nameGroupedConfigs = configs.reduce((acc, curr) => {
|
||||
const nameGroupedConfigs = validConfigs.reduce((acc, curr) => {
|
||||
const {name = 'unnamed'} = curr;
|
||||
const {[name]: n = []} = acc;
|
||||
return {...acc, [name]: [...n, curr]};
|
||||
}, {});
|
||||
let nameErrors = false;
|
||||
let noConflictConfigs = [];
|
||||
for (const [name, configs] of Object.entries(nameGroupedConfigs)) {
|
||||
if (configs.length > 1) {
|
||||
const sources = configs.map(c => `Config object from ${c.source} of type [${c.type}]`);
|
||||
this.logger.error(`Client config naming conflicts -- the following configs have the same name "${name}":
|
||||
this.logger.error(`The following clients will not be built because of config naming conflicts (they have the same name of "${name}"):
|
||||
${sources.join('\n')}`);
|
||||
nameErrors = true;
|
||||
if (name === 'unnamed') {
|
||||
this.logger.info('HINT: "unnamed" configs occur when using ENVs, if a multi-user mode config does not have a "name" property, or if a config is built in single-user mode');
|
||||
}
|
||||
} else {
|
||||
noConflictConfigs = [...noConflictConfigs, ...configs];
|
||||
}
|
||||
}
|
||||
if (nameErrors) {
|
||||
throw new Error('Could not build clients due to naming conflicts');
|
||||
}
|
||||
|
||||
// finally! all configs are valid, structurally, and can now be passed to addClient
|
||||
// just need to re-map unnnamed to default
|
||||
const finalConfigs = configs.map(({name = 'unnamed', ...x}) => ({
|
||||
const finalConfigs = noConflictConfigs.map(({name = 'unnamed', ...x}) => ({
|
||||
...x,
|
||||
name
|
||||
}));
|
||||
for (const c of finalConfigs) {
|
||||
await this.addClient(c);
|
||||
try {
|
||||
await this.addClient(c, clientDefaults);
|
||||
} catch(e) {
|
||||
this.logger.error(`Client ${c.name} was not added because it had unrecoverable errors`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addClient = async (clientConfig) => {
|
||||
addClient = async (clientConfig, defaults = {}) => {
|
||||
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
|
||||
if (isValidConfig !== true) {
|
||||
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
}
|
||||
const {type, name, data = {}} = clientConfig;
|
||||
const {type, name, data: d = {}} = clientConfig;
|
||||
// add defaults
|
||||
const data = {...defaults, ...d};
|
||||
let newClient;
|
||||
this.logger.debug(`(${name}) Constructing ${type} client...`);
|
||||
switch (type) {
|
||||
case 'maloja':
|
||||
this.logger.debug(`(${name}) Attempting Maloja initialization...`);
|
||||
const mj = new MalojaScrobbler(name, data);
|
||||
const testSuccess = await mj.testConnection();
|
||||
if (testSuccess === false) {
|
||||
throw new Error(`(${name}) Maloja client not initialized due to failure during connection testing`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) Maloja client initialized`);
|
||||
this.clients.push(mj)
|
||||
}
|
||||
newClient = new MalojaScrobbler(name, data);
|
||||
break;
|
||||
case 'lastfm':
|
||||
this.logger.debug(`(${name}) Attempting Lastfm initialization...`);
|
||||
const lfm = new LastfmScrobbler(name, {...data, configDir: this.configDir});
|
||||
try {
|
||||
await lfm.initialize()
|
||||
this.logger.info(`(${name}) Lastfm client initialized`);
|
||||
this.clients.push(lfm)
|
||||
} catch(e) {
|
||||
this.logger.info(`(${name}) Could not initialize Lastfm client`)
|
||||
}
|
||||
newClient = new LastfmScrobbler(name, {...data, configDir: this.configDir});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if(newClient === undefined) {
|
||||
// really shouldn't get here!
|
||||
throw new Error(`Client of type ${type} was not recognized??`);
|
||||
}
|
||||
if(newClient.initialized === false) {
|
||||
this.logger.debug(`(${name}) Attempting ${type} initialization...`);
|
||||
if (await newClient.initialize() === false) {
|
||||
this.logger.error(`(${name}) ${type} client failed to initialize. Client needs to be successfully initialized before scrobbling.`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} client initialized`);
|
||||
}
|
||||
}
|
||||
if(newClient.requiresAuth && !newClient.authed) {
|
||||
this.logger.debug(`(${name}) Checking ${type} client auth...`);
|
||||
let success;
|
||||
try {
|
||||
success = await newClient.testAuth();
|
||||
} catch (e) {
|
||||
success = false;
|
||||
}
|
||||
if(!success) {
|
||||
this.logger.warn(`(${name}) ${type} client auth failed.`);
|
||||
} else {
|
||||
this.logger.warn(`(${name}) ${type} client auth OK`);
|
||||
}
|
||||
}
|
||||
this.clients.push(newClient);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,7 +276,11 @@ ${sources.join('\n')}`);
|
||||
continue;
|
||||
}
|
||||
if(client.initialized === false) {
|
||||
this.logger.debug(`Client '${client.name}' is not yet initialized (check authorization?)`);
|
||||
this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is not yet initialized`);
|
||||
continue;
|
||||
}
|
||||
if(client.requiresAuthInteraction === true && !client.authed) {
|
||||
this.logger.warn(`Cannot scrobble to Client '${client.name}' because user interaction is required for authentication`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify", // required, source type
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "default", // optional, friendly name for logs
|
||||
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
|
||||
"data": {
|
||||
"users": ["FoxxMD"], // optional, list of users to scrobble tracks for
|
||||
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "myLastFm", // required, a name to identify your Client
|
||||
"name": "myLastFm", // [As Client/Source] required if configured as "client", a name to identify your Client/Source
|
||||
"configureAs": "client", // optional and default to "client", set to "source" to use this configuration as a Source
|
||||
"clients": [], // [As Source] optional, list of scrobble Clients (by config name) that this Source should scrobble to. Using an empty list or not including this property will make this Source scrobble to all Clients.
|
||||
"data": {
|
||||
"apiKey": "string", // required, Lastfm api key
|
||||
"secret": "string", // required, Lastfm shared secret
|
||||
@@ -8,6 +10,7 @@
|
||||
// if not specified will be generated during authentication
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback" // optional, if not different than this default
|
||||
// callback for auth. Must have "lastfm/callback" in the url somewhere
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"data": {
|
||||
"url": "https://domain.tld", // required, the base url of your maloja installation
|
||||
"apiKey": "string" // required, your maloja api key
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"accessToken": "string", // spotify access token -- required if not providing client id/secret
|
||||
"refreshToken": "string", // spotify refresh token -- recommended to provide if not providing client id/secret
|
||||
"redirectUri": "http://localhost:9078/callback",// spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
"interval": 60 // optional, how long to wait before calling spotify for new tracks
|
||||
"interval": 60, // optional, how long to wait before calling spotify for new tracks
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"data": {
|
||||
"url": "http://localhost:4040/airsonic",// required, the url you would visit to listen to music on the web
|
||||
"user": "yourUser", // required, username to login with
|
||||
"password": "yourPassword" // required, password to login with
|
||||
"password": "yourPassword", // required, password to login with
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -143,6 +143,43 @@ Can use this source for any application that implements the [Subsonic API](http:
|
||||
|
||||
See [`subsonic.json.example`](../config/subsonic.json.example)
|
||||
|
||||
## [Jellyfin](https://jellyfin.org/)
|
||||
|
||||
Must be using Jellyfin 10.7 or greater
|
||||
|
||||
* Add the [Webhook Plugin](https://github.com/crobibero/jellyfin-plugin-webhook) repository to your plugins, then restart your server
|
||||
* In the Webhook settings:
|
||||
* `Add Generic Destination`
|
||||
* In the new `Generic` dropdown:
|
||||
* Webhook Url: `http://localhost:9078/jellyfin`
|
||||
* Notification Type: `Playback Progress`
|
||||
* Item Type: `Songs`
|
||||
* Check `Send All Properties`
|
||||
* Save
|
||||
|
||||
### ENV-Based
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|-------------------------------------------------------------------|
|
||||
| `JELLYFIN_USER` | | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
|
||||
| `JELLYFIN_SERVER` | | | Comma-separated list of Jellyfin server names to scrobble from |
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`jellyfin.json.example`](../config/jellyfin.json.example)
|
||||
|
||||
## [Last.fm (Source)](https://www.last.fm)
|
||||
|
||||
See the [Last.fm (Client)](#lastfm) setup for registration instructions.
|
||||
|
||||
### ENV-Based
|
||||
|
||||
No support for ENV based for Last.fm as a client (only source)
|
||||
|
||||
### JSON-Based
|
||||
|
||||
See [`lastfm.json.example`](../config/lastfm.json.example), change `configureAs` to `source`.
|
||||
|
||||
# Clients
|
||||
|
||||
## [Maloja](https://github.com/krateng/maloja)
|
||||
|
||||
@@ -18,6 +18,15 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify",
|
||||
@@ -26,6 +35,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
|
||||
"data": {
|
||||
"clientId": "foxxSpotifyAppId",
|
||||
"clientSecret": "foxxSpotifyAppSecret",
|
||||
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import isBetween from 'dayjs/plugin/isBetween.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import {Writable} from 'stream';
|
||||
import 'winston-daily-rotate-file';
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./server/middleware.js";
|
||||
import TautulliSource from "./sources/TautulliSource.js";
|
||||
import PlexSource from "./sources/PlexSource.js";
|
||||
import JellyfinSource from "./sources/JellyfinSource.js";
|
||||
|
||||
const storage = multer.memoryStorage()
|
||||
const upload = multer({storage: storage})
|
||||
@@ -29,13 +31,19 @@ const upload = multer({storage: storage})
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(duration);
|
||||
|
||||
const {transports} = winston;
|
||||
|
||||
let output = []
|
||||
const stream = new Writable()
|
||||
stream._write = (chunk, encoding, next) => {
|
||||
output.unshift(chunk.toString().replace('\n', ''));
|
||||
let formatString = chunk.toString().replace('\n', '<br />')
|
||||
.replace(/(debug)/gi, '<span class="debug text-pink-400">$1</span>')
|
||||
.replace(/(warn)/gi, '<span class="warn text-blue-400">$1</span>')
|
||||
.replace(/(info)/gi, '<span class="info text-yellow-500">$1</span>')
|
||||
.replace(/(error)/gi, '<span class="error text-red-400">$1</span>')
|
||||
output.unshift(formatString);
|
||||
output = output.slice(0, 101);
|
||||
next()
|
||||
}
|
||||
@@ -120,7 +128,7 @@ app.use(bodyParser.json());
|
||||
const scrobbleSources = new ScrobbleSources(localUrl, configDir);
|
||||
let deprecatedConfigs = [];
|
||||
if (spotify !== undefined) {
|
||||
logger.warn(`Using 'spotify' top-level property in config.json is deprecated and will be removed in next major version. Please use 'sources' instead.`)
|
||||
logger.warn(`DEPRECATED: Using 'spotify' top-level property in config.json will be removed in next major version (0.4). Please use 'sources' instead.`)
|
||||
deprecatedConfigs.push({
|
||||
type: 'spotify',
|
||||
name: 'unnamed',
|
||||
@@ -130,7 +138,7 @@ app.use(bodyParser.json());
|
||||
});
|
||||
}
|
||||
if (plex !== undefined) {
|
||||
logger.warn(`Using 'plex' top-level property in config.json is deprecated and will be removed in next major version. Please use 'sources' instead.`)
|
||||
logger.warn(`DEPRECATED: Using 'plex' top-level property in config.json will be removed in next major version (0.4). Please use 'sources' instead.`)
|
||||
deprecatedConfigs.push({
|
||||
type: 'plex',
|
||||
name: 'unnamed',
|
||||
@@ -144,63 +152,88 @@ app.use(bodyParser.json());
|
||||
const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients);
|
||||
const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources);
|
||||
|
||||
// check ambiguous client/source types like this for now
|
||||
const lastfmSources = scrobbleSources.getByType('lastfm');
|
||||
const lastfmScrobbles = scrobbleClients.getByType('lastfm');
|
||||
|
||||
const scrobblerNames = lastfmScrobbles.map(x => x.name);
|
||||
const nameColl = lastfmSources.filter(x => scrobblerNames.includes(x.name));
|
||||
if(nameColl.length > 0) {
|
||||
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
|
||||
}
|
||||
|
||||
app.getAsync('/', async function (req, res) {
|
||||
let slicedLog = output.slice(0, logConfig.limit + 1);
|
||||
if (logConfig.sort === 'ascending') {
|
||||
slicedLog.reverse();
|
||||
}
|
||||
// TODO links for re-trying auth and variables for signalling it (and API recently played)
|
||||
const sourceData = scrobbleSources.sources.map((x) => {
|
||||
const {type, tracksDiscovered = 0, name, canPoll = false, polling = false} = x;
|
||||
const base = {type, display: capitalize(type), tracksDiscovered, name, canPoll, hasAuth: false};
|
||||
if (canPoll) {
|
||||
const {
|
||||
type,
|
||||
tracksDiscovered = 0,
|
||||
name,
|
||||
canPoll = false,
|
||||
polling = false,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered,
|
||||
name,
|
||||
canPoll,
|
||||
hasAuth: requiresAuth,
|
||||
hasAuthInteraction: requiresAuthInteraction,
|
||||
};
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else if(canPoll) {
|
||||
base.status = polling ? 'Running' : 'Idle';
|
||||
} else {
|
||||
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
|
||||
}
|
||||
switch (x.type) {
|
||||
case 'spotify':
|
||||
const authed = x.spotifyApi === undefined || x.spotifyApi.getAccessToken() !== undefined;
|
||||
return {
|
||||
...base,
|
||||
hasAuth: true,
|
||||
authed,
|
||||
status: authed ? base.status : 'Auth Interaction Required',
|
||||
}
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
const clientData = scrobbleClients.clients.map((x) => {
|
||||
const {type, tracksScrobbled = 0, name} = x;
|
||||
const {
|
||||
type,
|
||||
tracksScrobbled = 0,
|
||||
name,
|
||||
initialized = false,
|
||||
requiresAuth = false,
|
||||
requiresAuthInteraction = false,
|
||||
authed = false,
|
||||
} = x;
|
||||
const base = {
|
||||
type,
|
||||
display: capitalize(type),
|
||||
tracksDiscovered: tracksScrobbled,
|
||||
name,
|
||||
hasAuth: false,
|
||||
status: tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data'
|
||||
hasAuth: requiresAuth,
|
||||
};
|
||||
switch (x.type) {
|
||||
case 'lastfm':
|
||||
const authed = x.initialized;
|
||||
return {
|
||||
...base,
|
||||
hasAuth: true,
|
||||
authed,
|
||||
status: authed ? base.status : 'Auth Interaction Required',
|
||||
}
|
||||
default:
|
||||
return base;
|
||||
if(!initialized) {
|
||||
base.status = 'Not Initialized';
|
||||
} else if(requiresAuth && !authed) {
|
||||
base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted'
|
||||
} else {
|
||||
base.status = tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data';
|
||||
}
|
||||
return base;
|
||||
})
|
||||
res.render('status', {
|
||||
sources: sourceData,
|
||||
clients: clientData,
|
||||
logs: {
|
||||
output: slicedLog,
|
||||
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'bold' : ''}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
|
||||
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'bold' : ''}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
|
||||
level: availableLevels.map(x => `<a class="capitalize ${logConfig.level === x ? 'bold' : ''}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
|
||||
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
|
||||
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
|
||||
level: availableLevels.map(x => `<a class="capitalize ${logConfig.level === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
|
||||
}
|
||||
});
|
||||
})
|
||||
@@ -249,6 +282,17 @@ app.use(bodyParser.json());
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
|
||||
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
|
||||
app.postAsync('/jellyfin', jellyfinJsonParser, async function (req, res) {
|
||||
const playObj = JellyfinSource.formatPlayObj(req.body, true);
|
||||
const pSources = scrobbleSources.getByType('jellyfin');
|
||||
for (const source of pSources) {
|
||||
await source.handle(playObj, scrobbleClients);
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/client/auth', clientCheckMiddle);
|
||||
app.getAsync('/client/auth', async function (req, res) {
|
||||
const {
|
||||
@@ -257,7 +301,7 @@ app.use(bodyParser.json());
|
||||
|
||||
switch (scrobbleClient.type) {
|
||||
case 'lastfm':
|
||||
res.redirect(scrobbleClient.getAuthUrl());
|
||||
res.redirect(scrobbleClient.api.getAuthUrl());
|
||||
break;
|
||||
default:
|
||||
return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`);
|
||||
@@ -280,6 +324,9 @@ app.use(bodyParser.json());
|
||||
res.redirect(source.createAuthUrl());
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
res.redirect(source.api.getAuthUrl());
|
||||
break;
|
||||
default:
|
||||
return res.status(400).send(`Specified source does not have auth implemented (${source.type})`);
|
||||
}
|
||||
@@ -367,10 +414,13 @@ app.use(bodyParser.json());
|
||||
token
|
||||
} = {}
|
||||
} = req;
|
||||
const client = scrobbleClients.getByName(state);
|
||||
let entity = scrobbleClients.getByName(state);
|
||||
if(entity === undefined) {
|
||||
entity = scrobbleSources.getByName(state);
|
||||
}
|
||||
try {
|
||||
await client.authenticate(token);
|
||||
await client.initialize();
|
||||
await entity.api.authenticate(token);
|
||||
await entity.initialize();
|
||||
return res.send('OK');
|
||||
} catch (e) {
|
||||
return res.send(e.message);
|
||||
@@ -402,6 +452,11 @@ app.use(bodyParser.json());
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
if(source.initialized === true) {
|
||||
source.poll(scrobbleClients);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (source.poll !== undefined) {
|
||||
source.poll(scrobbleClients);
|
||||
|
||||
Generated
+1317
-14
File diff suppressed because it is too large
Load Diff
+8
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "maloja-spotify-scrobbler",
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -13,24 +13,24 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/FoxxMD/maloja-spotify-scrobbler.git"
|
||||
"url": "git+https://github.com/FoxxMD/multi-scrobbler.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/FoxxMD/maloja-spotify-scrobbler/issues"
|
||||
"url": "https://github.com/FoxxMD/multi-scrobbler/issues"
|
||||
},
|
||||
"homepage": "https://github.com/FoxxMD/maloja-spotify-scrobbler#readme",
|
||||
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"body-parser": "^1.19.0",
|
||||
"dayjs": "^1.9.6",
|
||||
"ejs": "^3.1.5",
|
||||
"dayjs": "^1.10.4",
|
||||
"ejs": "^3.1.6",
|
||||
"express": "^4.17.1",
|
||||
"lastfm-node-client": "^2.2.0",
|
||||
"multer": "^1.4.2",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"spotify-web-api-node": "^5.0.0",
|
||||
"spotify-web-api-node": "^5.0.2",
|
||||
"superagent": "^6.1.0",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.5.0"
|
||||
|
||||
+66
-13
@@ -10,18 +10,36 @@ export default class AbstractSource {
|
||||
config;
|
||||
clients;
|
||||
logger;
|
||||
instantiatedAt;
|
||||
initialized = false;
|
||||
requiresAuth = false;
|
||||
requiresAuthInteraction = false;
|
||||
authed = false;
|
||||
|
||||
canPoll = false;
|
||||
polling = false;
|
||||
pollRetries = 0;
|
||||
tracksDiscovered = 0;
|
||||
|
||||
constructor(type, name, config = {}, clients = []) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.identifier = `${capitalize(this.type)} - ${name}`;
|
||||
this.identifier = `Source - ${capitalize(this.type)} - ${name}`;
|
||||
this.logger = createLabelledLogger(this.identifier, this.identifier);
|
||||
this.config = config;
|
||||
this.clients = clients;
|
||||
this.instantiatedAt = dayjs();
|
||||
}
|
||||
|
||||
// default init function, should be overridden if init stage is required
|
||||
initialize = async () => {
|
||||
this.initialized = true;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
testAuth = async () => {
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
@@ -39,16 +57,49 @@ export default class AbstractSource {
|
||||
await this.startPolling(allClients);
|
||||
}
|
||||
|
||||
startPolling = async (allClients) => {
|
||||
if(this.requiresAuthInteraction && !this.authed) {
|
||||
this.logger.error('Cannot start polling because user interaction is required for authentication');
|
||||
return;
|
||||
}
|
||||
// reset poll attempts if already previously run
|
||||
this.pollRetries = 0;
|
||||
|
||||
const {
|
||||
maxPollRetries = 0,
|
||||
retryMultiplier = 1.5,
|
||||
} = this.config;
|
||||
|
||||
// can't have negative retries!
|
||||
const maxRetries = Math.max(0, maxPollRetries);
|
||||
|
||||
while (this.pollRetries <= maxRetries) {
|
||||
try {
|
||||
await this.doPolling(allClients);
|
||||
} catch (e) {
|
||||
if (this.pollRetries < maxRetries) {
|
||||
const delayFor = (this.pollRetries + 1) * retryMultiplier;
|
||||
this.logger.info(`Poll reties (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`);
|
||||
await sleep((delayFor) * 1000);
|
||||
} else {
|
||||
this.logger.warn(`Poll retries (${this.pollRetries}) equal to max poll retries (${maxRetries}), stopping polling!`);
|
||||
}
|
||||
this.pollRetries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScrobbleClients} allClients
|
||||
*/
|
||||
startPolling = async (allClients) => {
|
||||
doPolling = async (allClients) => {
|
||||
if (this.polling === true) {
|
||||
return;
|
||||
}
|
||||
this.logger.info('Polling started');
|
||||
let lastTrackPlayedAt = dayjs();
|
||||
let lastTrackPlayedAt = this.instantiatedAt;
|
||||
let checkCount = 0;
|
||||
let checksOverThreshold = 0;
|
||||
try {
|
||||
this.polling = true;
|
||||
while (true) {
|
||||
@@ -106,9 +157,13 @@ export default class AbstractSource {
|
||||
}
|
||||
} else {
|
||||
checkCount = 0;
|
||||
checksOverThreshold = 0;
|
||||
}
|
||||
|
||||
// use the source instantiation time or the last track play time to determine if we should refresh clients..
|
||||
// we only need to refresh clients when the source has "newer" information otherwise we're just refreshing clients for no reason
|
||||
const scrobbleResult = await allClients.scrobble(playObjs, {
|
||||
checkTime: lastTrackPlayedAt.add(2, 's'),
|
||||
forceRefresh: closeToInterval,
|
||||
scrobbleFrom: this.identifier,
|
||||
scrobbleTo: this.clients
|
||||
@@ -119,19 +174,16 @@ export default class AbstractSource {
|
||||
this.tracksDiscovered += scrobbleResult.length;
|
||||
}
|
||||
|
||||
const {interval = 30} = this.config;
|
||||
const {interval = 30, checkActiveFor = 300, maxSleep = 300} = this.config;
|
||||
|
||||
let sleepTime = interval;
|
||||
// don't need to do back off calc if interval is 10 minutes or greater since its already pretty light on API calls
|
||||
// don't need to do back off calc if interval is 5 minutes or greater since its already pretty light on API calls
|
||||
// and don't want to back off if we just started the app
|
||||
if (checkCount > 5 && sleepTime < 600) {
|
||||
const lastPlayToNowSecs = Math.abs(now.unix() - lastTrackPlayedAt.unix());
|
||||
// back off if last play was longer than 10 minutes ago
|
||||
const backoffThreshold = Math.min((interval * 10), 600);
|
||||
if (lastPlayToNowSecs >= backoffThreshold) {
|
||||
// back off to a maximum of 5 minutes
|
||||
sleepTime = Math.min(interval * 5, 300);
|
||||
}
|
||||
const activeThreshold = lastTrackPlayedAt.add(checkActiveFor, 's');
|
||||
if (activeThreshold.isBefore(dayjs()) && sleepTime < 300) {
|
||||
checksOverThreshold++;
|
||||
const backoffMultiplier = Math.min(checksOverThreshold, 1000) * 1.5;
|
||||
sleepTime = Math.min(interval * backoffMultiplier, maxSleep);
|
||||
}
|
||||
|
||||
// sleep for interval
|
||||
@@ -143,6 +195,7 @@ export default class AbstractSource {
|
||||
this.logger.error('Error occurred while polling');
|
||||
this.logger.error(e);
|
||||
this.polling = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import MemorySource from "./MemorySource.js";
|
||||
import dayjs from "dayjs";
|
||||
import {buildTrackString} from "../utils.js";
|
||||
|
||||
|
||||
export default class JellyfinSource extends MemorySource {
|
||||
users;
|
||||
servers;
|
||||
|
||||
constructor(name, config, clients, type = 'jellyfin') {
|
||||
super(type, name, config, clients);
|
||||
const {users, servers} = config
|
||||
|
||||
if (users === undefined || users === null) {
|
||||
this.users = undefined;
|
||||
} else {
|
||||
if (!Array.isArray(users)) {
|
||||
this.users = [users];
|
||||
} else {
|
||||
this.users = users;
|
||||
}
|
||||
this.users = this.users.map(x => x.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
if (servers === undefined || servers === null) {
|
||||
this.servers = undefined;
|
||||
} else {
|
||||
if (!Array.isArray(servers)) {
|
||||
this.servers = [servers];
|
||||
} else {
|
||||
this.servers = servers;
|
||||
}
|
||||
this.servers = this.servers.map(x => x.toLocaleLowerCase())
|
||||
}
|
||||
|
||||
if (users === undefined && servers === undefined) {
|
||||
this.logger.warn('Initializing, but with no filters! All tracks from all users on all servers will be scrobbled.');
|
||||
} else {
|
||||
this.logger.info(`Initializing with the following filters => Users: ${this.users === undefined ? 'N/A' : this.users.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj, newFromSource = false) {
|
||||
const {
|
||||
ServerId,
|
||||
ServerName,
|
||||
Username,
|
||||
UserId,
|
||||
NotificationType,
|
||||
UtcTimestamp,
|
||||
Album,
|
||||
Artist,
|
||||
Name,
|
||||
RunTime,
|
||||
ItemId,
|
||||
ItemType,
|
||||
} = obj;
|
||||
|
||||
const parsedRuntime = RunTime.split(':');
|
||||
const dur = dayjs.duration({
|
||||
hours: Number.parseInt(parsedRuntime[0]),
|
||||
minutes: Number.parseInt(parsedRuntime[1]),
|
||||
seconds: Number.parseInt(parsedRuntime[2])
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
artists: [Artist],
|
||||
album: Album,
|
||||
track: Name,
|
||||
duration: dur.as('seconds'),
|
||||
playDate: dayjs(),
|
||||
},
|
||||
meta: {
|
||||
event: NotificationType,
|
||||
mediaType: ItemType,
|
||||
sourceId: ItemId,
|
||||
user: Username,
|
||||
server: ServerName,
|
||||
source: 'Jellyfin',
|
||||
newFromSource,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isValidEvent = (playObj) => {
|
||||
const {
|
||||
meta: {
|
||||
mediaType, event, user, server
|
||||
},
|
||||
data: {
|
||||
artists,
|
||||
track,
|
||||
} = {}
|
||||
} = playObj;
|
||||
|
||||
if (this.users !== undefined) {
|
||||
if (user === undefined) {
|
||||
this.logger.warn(`Config defined users but payload contained no user info${hint}`);
|
||||
} else if (!this.users.includes(user.toLocaleLowerCase())) {
|
||||
this.logger.debug(`Will not scrobble event because author was not an allowed user: ${user}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (event !== undefined && !['PlaybackProgress','PlaybackStarted'].includes(event)) {
|
||||
this.logger.debug(`Will not scrobble event because it is not media.scrobble (${event})`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mediaType !== 'Audio') {
|
||||
this.logger.debug(`Will not scrobble event because media type was not 'Audio' (${mediaType})`, {
|
||||
artists,
|
||||
track
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.servers !== undefined && !this.servers.includes(server.toLocaleLowerCase())) {
|
||||
this.logger.debug(`Will not scrobble event because server was not on allowed list: ${server}`, {
|
||||
artists,
|
||||
track
|
||||
})
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
return this.statefulRecentlyPlayed;
|
||||
}
|
||||
|
||||
handle = async (playObj, allClients) => {
|
||||
if (!this.isValidEvent(playObj)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPlays = this.processRecentPlays([playObj]);
|
||||
|
||||
for(const p of newPlays) {
|
||||
this.logger.info(`New Track => ${buildTrackString(p)}`);
|
||||
}
|
||||
|
||||
if(newPlays.length > 0) {
|
||||
const recent = await this.getRecentlyPlayed();
|
||||
const newestPlay = recent[recent.length - 1];
|
||||
try {
|
||||
await allClients.scrobble(newPlays, {scrobbleTo: this.clients, scrobbleFrom: this.identifier, checkTime: newestPlay.data.playDate});
|
||||
// only gets hit if we scrobbled ok
|
||||
this.tracksDiscovered++;
|
||||
} catch (e) {
|
||||
this.logger.error('Encountered error while scrobbling')
|
||||
this.logger.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
import LastfmApiClient from "../apis/LastfmApiClient.js";
|
||||
import {sortByPlayDate} from "../utils.js";
|
||||
|
||||
export default class LastfmSource extends AbstractSource {
|
||||
|
||||
api;
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
constructor(name, config = {}, clients = []) {
|
||||
super('lastfm', name, config, clients);
|
||||
this.canPoll = true;
|
||||
this.api = new LastfmApiClient(name, config);
|
||||
}
|
||||
|
||||
static formatPlayObj(obj) {
|
||||
return LastfmApiClient.formatPlayObj(obj);
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
this.initialized = await this.api.initialize();
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
this.authed = await this.api.testAuth();
|
||||
} catch (e) {
|
||||
this.logger.error('Could not successfully communicate with Last.fm API');
|
||||
this.logger.error(e);
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
|
||||
getRecentlyPlayed = async(options = {}) => {
|
||||
const {limit = 20, formatted = false} = options;
|
||||
const resp = await this.api.callApi(client => client.userGetRecentTracks({user: this.api.user, limit, extended: true}));
|
||||
const {
|
||||
recenttracks: {
|
||||
track: list = [],
|
||||
}
|
||||
} = resp;
|
||||
|
||||
return list.reduce((acc, x) => {
|
||||
try {
|
||||
const formatted = LastfmApiClient.formatPlayObj(x);
|
||||
const {
|
||||
data: {
|
||||
track,
|
||||
playDate,
|
||||
},
|
||||
meta: {
|
||||
mbid,
|
||||
nowPlaying,
|
||||
}
|
||||
} = formatted;
|
||||
if(nowPlaying === true) {
|
||||
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
|
||||
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
|
||||
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
|
||||
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
|
||||
return acc;
|
||||
} else if(playDate === undefined) {
|
||||
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
|
||||
return acc;
|
||||
}
|
||||
return acc.concat(formatted);
|
||||
} catch (e) {
|
||||
this.logger.warn('Failed to format Last.fm recently scrobbled track, omitting from time frame check', {error: e.message});
|
||||
this.logger.debug('Full api response object:');
|
||||
this.logger.debug(x);
|
||||
return acc;
|
||||
}
|
||||
}, []).sort(sortByPlayDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
import {playObjDataMatch, sortByPlayDate, buildTrackString} from "../utils.js";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default class MemorySource extends AbstractSource {
|
||||
/*
|
||||
* MemorySource uses its own state to maintain a list of recently played tracks and determine if a track if valid.
|
||||
* This is necessary for any source that
|
||||
* * doesn't have its own source of truth for "recently played" or
|
||||
* * that does not return "started at" and "duration" timestamps for recent plays or
|
||||
* * where these timestamps don't have enough granularity (IE second accuracy)
|
||||
* such as subsonic and jellyfin */
|
||||
|
||||
statefulRecentlyPlayed = [];
|
||||
candidateRecentlyPlayed = [];
|
||||
|
||||
processRecentPlays = (plays) => {
|
||||
|
||||
let newStatefulPlays = [];
|
||||
// first format new plays with locked play date
|
||||
const lockedPlays = plays.map((p) => {
|
||||
const {data: {playDate, ...restData}, ...rest} = p;
|
||||
return {data: {...restData, playDate: dayjs()}, ...rest};
|
||||
})
|
||||
// if no candidates exist new plays are new candidates
|
||||
if(this.candidateRecentlyPlayed.length === 0) {
|
||||
this.candidateRecentlyPlayed = lockedPlays;
|
||||
} else {
|
||||
// otherwise determine new tracks (not found in prior candidates)
|
||||
const newTracks = lockedPlays.filter(x => this.candidateRecentlyPlayed.every(y => !playObjDataMatch(y, x)));
|
||||
// filter prior candidates based on new recently played
|
||||
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.filter(x => lockedPlays.some(y => playObjDataMatch(x, y)));
|
||||
// and then combine still playing with new tracks
|
||||
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.concat(newTracks);
|
||||
this.candidateRecentlyPlayed.sort(sortByPlayDate);
|
||||
|
||||
for(const candidate of this.candidateRecentlyPlayed) {
|
||||
const {data: {playDate, track}} = candidate;
|
||||
if(playDate.isBefore(dayjs().subtract(30, 's'))) {
|
||||
// a prior candidate has been playing for more than 30 seconds, time to check statefuls
|
||||
|
||||
const matchingRecent = this.statefulRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
|
||||
let stPrefix = `(Stateful Play) ${buildTrackString(candidate, {include: ['artist', 'track']})}`;
|
||||
if(matchingRecent === undefined) {
|
||||
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and not matching any prior plays`);
|
||||
newStatefulPlays.push(candidate);
|
||||
this.statefulRecentlyPlayed.push(candidate);
|
||||
} else {
|
||||
const {data: { playDate, duration }} = candidate;
|
||||
const {data: { playDate: rplayDate }} = matchingRecent;
|
||||
if(!playDate.isSame(rplayDate)) {
|
||||
if(duration !== undefined) {
|
||||
if(playDate.isAfter(rplayDate.add(duration, 's'))) {
|
||||
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and having a different timestamp than a prior play`);
|
||||
newStatefulPlays.push(candidate);
|
||||
this.statefulRecentlyPlayed.push(candidate);
|
||||
}
|
||||
} else if(!playObjDataMatch(this.statefulRecentlyPlayed[0], candidate)) {
|
||||
// if most recent stateful play is not this track we'll add it
|
||||
this.logger.debug(`${stPrefix} added after being seen for 30 seconds. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
|
||||
newStatefulPlays.push(candidate);
|
||||
this.statefulRecentlyPlayed.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.statefulRecentlyPlayed.sort(sortByPlayDate);
|
||||
}
|
||||
return newStatefulPlays;
|
||||
}
|
||||
|
||||
recentlyPlayedTrackIsValid = (playObj) => {
|
||||
return playObj.data.playDate.isBefore(dayjs().subtract(30, 's'));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import dayjs from "dayjs";import LastFm from "lastfm-node-client";
|
||||
import LastfmScrobbler from '../clients/LastfmScrobbler.js';
|
||||
import {buildTrackString} from "../utils.js";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
|
||||
@@ -49,6 +50,7 @@ export default class PlexSource extends AbstractSource {
|
||||
} else {
|
||||
this.logger.info(`Initializing with the following filters => Users: ${this.users === undefined ? 'N/A' : this.users.join(', ')} | Libraries: ${this.libraries === undefined ? 'N/A' : this.libraries.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
static formatPlayObj(obj, newFromSource = false) {
|
||||
|
||||
+129
-42
@@ -3,6 +3,8 @@ import SpotifySource from "./SpotifySource.js";
|
||||
import PlexSource from "./PlexSource.js";
|
||||
import TautulliSource from "./TautulliSource.js";
|
||||
import {SubsonicSource} from "./SubsonicSource.js";
|
||||
import JellyfinSource from "./JellyfinSource.js";
|
||||
import LastfmSource from "./LastfmSource.js";
|
||||
|
||||
export default class ScrobbleSources {
|
||||
|
||||
@@ -11,7 +13,7 @@ export default class ScrobbleSources {
|
||||
configDir;
|
||||
localUrl;
|
||||
|
||||
sourceTypes = ['spotify', 'plex', 'tautulli', 'subsonic'];
|
||||
sourceTypes = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm'];
|
||||
|
||||
constructor(localUrl, configDir = process.cwd()) {
|
||||
this.configDir = configDir;
|
||||
@@ -40,18 +42,36 @@ export default class ScrobbleSources {
|
||||
} catch (e) {
|
||||
throw new Error('config.json could not be parsed');
|
||||
}
|
||||
let sourceDefaults = {};
|
||||
if (configFile !== undefined) {
|
||||
const {sources: mainConfigSourcesConfigs = []} = configFile;
|
||||
if (!mainConfigSourcesConfigs.every(x => x !== null && typeof x === 'object')) {
|
||||
throw new Error('All sources from config.json must be objects');
|
||||
}
|
||||
for (const c of mainConfigSourcesConfigs) {
|
||||
const {
|
||||
sources: mainConfigSourcesConfigs = [],
|
||||
sourceDefaults: sd = {},
|
||||
} = configFile;
|
||||
sourceDefaults = sd;
|
||||
const validMainConfigs = mainConfigSourcesConfigs.reduce((acc, curr, i) => {
|
||||
if(curr === null) {
|
||||
this.logger.error(`The source config entry at index ${i} in config.json is null but should be an object, will not parse`);
|
||||
return acc;
|
||||
}
|
||||
if(typeof curr !== 'object') {
|
||||
this.logger.error(`The source config entry at index ${i} in config.json should be an object, will not parse`);
|
||||
return acc;
|
||||
}
|
||||
return acc.concat(curr);
|
||||
}, []);
|
||||
for (const c of validMainConfigs) {
|
||||
const {name = 'unnamed'} = c;
|
||||
configs.push({...c, name, source: 'config.json'});
|
||||
configs.push({...c,
|
||||
name,
|
||||
source: 'config.json',
|
||||
configureAs: 'source' // override user value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let sourceType of this.sourceTypes) {
|
||||
let defaultConfigureAs = 'source';
|
||||
// env builder for single user mode
|
||||
switch (sourceType) {
|
||||
case 'spotify':
|
||||
@@ -117,6 +137,25 @@ export default class ScrobbleSources {
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'jellyfin':
|
||||
const j = {
|
||||
user: process.env.JELLYFIN_USER,
|
||||
server: process.env.JELLYFIN_SERVER,
|
||||
};
|
||||
if (!Object.values(j).every(x => x === undefined)) {
|
||||
configs.push({
|
||||
type: 'jellyfin',
|
||||
name: 'unnamed',
|
||||
source: 'ENV',
|
||||
mode: 'single',
|
||||
data: j
|
||||
})
|
||||
}
|
||||
break;
|
||||
case 'lastfm':
|
||||
// sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior
|
||||
defaultConfigureAs = 'client';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -124,52 +163,60 @@ export default class ScrobbleSources {
|
||||
try {
|
||||
rawSourceConfigs = await readJson(`${this.configDir}/${sourceType}.json`, {throwOnNotFound: false});
|
||||
} catch (e) {
|
||||
throw new Error(`${sourceType}.json config file could not be parsed`);
|
||||
this.logger.error(`${sourceType}.json config file could not be parsed`);
|
||||
continue;
|
||||
}
|
||||
if (rawSourceConfigs !== undefined) {
|
||||
let sourceConfigs = [];
|
||||
if (Array.isArray(rawSourceConfigs)) {
|
||||
sourceConfigs = rawSourceConfigs;
|
||||
} else if (rawSourceConfigs === null || typeof rawSourceConfigs === 'object') {
|
||||
} else if (rawSourceConfigs === null) {
|
||||
this.logger.error(`${sourceType}.json contained no data`);
|
||||
continue;
|
||||
} else if (typeof rawSourceConfigs === 'object') {
|
||||
// backwards compatibility, assuming its single-user mode
|
||||
this.logger.warn(`DEPRECATED: Starting in 0.4 configurations in all [type].json files (${sourceType}.json) must be in an array.`);
|
||||
if (rawSourceConfigs.data === undefined) {
|
||||
sourceConfigs = [{data: rawSourceConfigs, mode: 'single', name: 'unnamed'}];
|
||||
} else {
|
||||
sourceConfigs = [rawSourceConfigs];
|
||||
}
|
||||
} else {
|
||||
throw new Error(`All top level data from ${sourceType}.json must be an object or array of objects`);
|
||||
this.logger.error(`All top level data from ${sourceType}.json must be an array of objects, will not parse configs from file`);
|
||||
continue;
|
||||
}
|
||||
for (const m of sourceConfigs) {
|
||||
if (m === null || typeof m !== 'object') {
|
||||
throw new Error(`All top-level data from ${sourceType}.json must be an object or array of objects`);
|
||||
for (const [i,m] of sourceConfigs.entries()) {
|
||||
if(m === null) {
|
||||
this.logger.error(`The config entry at index ${i} from ${sourceType}.json is null`);
|
||||
continue;
|
||||
}
|
||||
if (typeof m !== 'object') {
|
||||
this.logger.error(`The config entry at index ${i} from ${sourceType}.json was not an object, skipping`, m);
|
||||
continue;
|
||||
}
|
||||
const {configureAs = defaultConfigureAs} = m;
|
||||
if(configureAs === 'source') {
|
||||
m.source = `${sourceType}.json`;
|
||||
m.type = sourceType;
|
||||
configs.push(m);
|
||||
}
|
||||
m.source = `${sourceType}.json`;
|
||||
m.type = sourceType;
|
||||
configs.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we have all possible configurations so we'll check they are minimally valid
|
||||
const configErrors = configs.reduce((acc, c) => {
|
||||
const validConfigs = configs.reduce((acc, c) => {
|
||||
const isValid = isValidConfigStructure(c, {type: true, data: true});
|
||||
if (isValid !== true) {
|
||||
const msg = `Source config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] has errors: ${isValid.join(' | ')}`;
|
||||
return acc.concat(msg);
|
||||
this.logger.error(`Source config from ${c.source} with name [${c.name || 'unnamed'}] of type [${c.type || 'unknown'}] will not be used because it has structural errors: ${isValid.join(' | ')}`);
|
||||
return acc;
|
||||
}
|
||||
return acc;
|
||||
return acc.concat(c);
|
||||
}, []);
|
||||
if (configErrors.length > 0) {
|
||||
for (const m of configErrors) {
|
||||
this.logger.error(m);
|
||||
}
|
||||
throw new Error('Could not build sources due to above errors');
|
||||
}
|
||||
|
||||
// finally! all configs are valid, structurally, and can now be passed to addClient
|
||||
// do a last check that names (within each type) are unique and warn if not, but add anyways
|
||||
const typeGroupedConfigs = configs.reduce((acc, curr) => {
|
||||
const typeGroupedConfigs = validConfigs.reduce((acc, curr) => {
|
||||
const {type} = curr;
|
||||
const {[type]: t = []} = acc;
|
||||
return {...acc, [type]: [...t, curr]};
|
||||
@@ -196,45 +243,85 @@ export default class ScrobbleSources {
|
||||
name: hasDups ? `${name}${i + 1}` : name
|
||||
}));
|
||||
for (const c of tempNamedConfigs) {
|
||||
await this.addSource(c);
|
||||
try {
|
||||
await this.addSource(c, sourceDefaults);
|
||||
} catch(e) {
|
||||
this.logger.error(`Source ${c.name} of type ${c.type} was not added because of unrecoverable errors`);
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addSource = async (clientConfig) => {
|
||||
addSource = async (clientConfig, defaults = {}) => {
|
||||
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
|
||||
if (isValidConfig !== true) {
|
||||
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
|
||||
}
|
||||
const {type, name, clients = [], data = {}} = clientConfig;
|
||||
this.logger.debug(`(${name}) Initializing ${type} source`);
|
||||
const {type, name, clients = [], data: d = {}} = clientConfig;
|
||||
// add defaults
|
||||
const data = {...defaults, ...d};
|
||||
this.logger.debug(`(${name}) Constructing ${type} source`);
|
||||
let newSource;
|
||||
switch (type) {
|
||||
case 'spotify':
|
||||
const spotifySource = new SpotifySource(name, {
|
||||
newSource = new SpotifySource(name, {
|
||||
...data,
|
||||
localUrl: this.localUrl,
|
||||
configDir: this.configDir
|
||||
}, clients);
|
||||
await spotifySource.buildSpotifyApi();
|
||||
this.sources.push(spotifySource);
|
||||
break;
|
||||
case 'plex':
|
||||
const plexSource = await new PlexSource(name, data, clients);
|
||||
this.sources.push(plexSource);
|
||||
newSource = await new PlexSource(name, data, clients);
|
||||
break;
|
||||
case 'tautulli':
|
||||
const tautulliSource = await new TautulliSource(name, data, clients);
|
||||
this.sources.push(tautulliSource);
|
||||
newSource = await new TautulliSource(name, data, clients);
|
||||
break;
|
||||
case 'subsonic':
|
||||
const ssSource = new SubsonicSource(name, data, clients);
|
||||
await ssSource.testConnection();
|
||||
this.sources.push(ssSource);
|
||||
newSource = new SubsonicSource(name, data, clients);
|
||||
break;
|
||||
case 'jellyfin':
|
||||
newSource = await new JellyfinSource(name, data, clients);
|
||||
break;
|
||||
case 'lastfm':
|
||||
newSource = await new LastfmSource(name, {...data, configDir: this.configDir}, clients);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.logger.info(`(${name}) ${type} source initialized`);
|
||||
|
||||
if(newSource === undefined) {
|
||||
// really shouldn't get here!
|
||||
throw new Error(`Source of type ${type} was not recognized??`);
|
||||
}
|
||||
if(newSource.initialized === false) {
|
||||
this.logger.debug(`(${name}) Attempting ${type} initialization...`);
|
||||
if (await newSource.initialize() === false) {
|
||||
this.logger.error(`(${name}) ${type} source failed to initialize. Source needs to be successfully initialized before activity capture can begin.`);
|
||||
return;
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} source initialized`);
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} source initialized`);
|
||||
}
|
||||
|
||||
if(newSource.requiresAuth && !newSource.authed) {
|
||||
this.logger.debug(`(${name}) Checking ${type} source auth...`);
|
||||
let success;
|
||||
try {
|
||||
success = await newSource.testAuth();
|
||||
} catch (e) {
|
||||
success = false;
|
||||
}
|
||||
if(!success) {
|
||||
this.logger.warn(`(${name}) ${type} source auth failed.`);
|
||||
} else {
|
||||
this.logger.info(`(${name}) ${type} source auth OK`);
|
||||
}
|
||||
}
|
||||
|
||||
this.sources.push(newSource);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
|
||||
import {
|
||||
readJson,
|
||||
writeFile,
|
||||
sortByPlayDate,
|
||||
sortByPlayDate, sleep, parseRetryAfterSecsFromObj,
|
||||
} from "../utils.js";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
import AbstractSource from "./AbstractSource.js";
|
||||
@@ -17,6 +17,9 @@ export default class SpotifySource extends AbstractSource {
|
||||
workingCredsPath;
|
||||
configDir;
|
||||
|
||||
requiresAuth = true;
|
||||
requiresAuthInteraction = true;
|
||||
|
||||
constructor(name, config = {}, clients = []) {
|
||||
super('spotify', name, config, clients);
|
||||
const {
|
||||
@@ -140,6 +143,25 @@ export default class SpotifySource extends AbstractSource {
|
||||
this.spotifyApi = new SpotifyWebApi(apiConfig);
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
if(this.spotifyApi === undefined) {
|
||||
await this.buildSpotifyApi();
|
||||
}
|
||||
this.initialized = true;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth = async () => {
|
||||
try {
|
||||
await this.callApi((api => api.getMe()));
|
||||
this.authed = true;
|
||||
} catch (e) {
|
||||
this.logger.error('Could not successfully communicate with Spotify API');
|
||||
this.authed = false;
|
||||
}
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
createAuthUrl = () => {
|
||||
return this.spotifyApi.createAuthorizeURL(scopes, this.name);
|
||||
}
|
||||
@@ -174,7 +196,11 @@ export default class SpotifySource extends AbstractSource {
|
||||
return result;
|
||||
}
|
||||
|
||||
callApi = async (func) => {
|
||||
callApi = async (func, retries = 0) => {
|
||||
const {
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 2,
|
||||
} = this.config;
|
||||
try {
|
||||
return await func(this.spotifyApi);
|
||||
} catch (e) {
|
||||
@@ -205,8 +231,13 @@ export default class SpotifySource extends AbstractSource {
|
||||
this.logger.error(ee, {label: 'Spotify'});
|
||||
throw ee;
|
||||
}
|
||||
} else if(maxRequestRetries > retries) {
|
||||
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
|
||||
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
return this.callApi(func, retries + 1);
|
||||
} else {
|
||||
this.logger.error('Refreshing access token encountered an error');
|
||||
this.logger.error(`Request failed on retry (${retries}) with no more retries permitted (max ${maxRequestRetries})`);
|
||||
this.logger.error(e, {label: 'Spotify'});
|
||||
throw e;
|
||||
}
|
||||
|
||||
+46
-19
@@ -3,14 +3,19 @@ import request from 'superagent';
|
||||
import crypto from 'crypto';
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
|
||||
import {buildTrackString} from "../utils.js";
|
||||
import {buildTrackString, parseRetryAfterSecsFromObj, sleep} from "../utils.js";
|
||||
import MemorySource from "./MemorySource.js";
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
export class SubsonicSource extends AbstractSource {
|
||||
export class SubsonicSource extends MemorySource {
|
||||
|
||||
requiresAuth = true;
|
||||
|
||||
constructor(name, config = {}, clients = []) {
|
||||
super('subsonic', name, config, clients);
|
||||
// default to quick interval so we can get a decently accurate nowPlaying
|
||||
const subsonicConfig = {interval: 10, maxSleep: 30, ...config};
|
||||
super('subsonic', name, subsonicConfig, clients);
|
||||
|
||||
const {user, password, url} = this.config;
|
||||
|
||||
@@ -55,18 +60,13 @@ export class SubsonicSource extends AbstractSource {
|
||||
}
|
||||
}
|
||||
|
||||
recentlyPlayedTrackIsValid = (playObj) => {
|
||||
const {data: {playDate} = {}} = playObj;
|
||||
// want to make sure that the track has been played for at least one minute (according to subsonic api)
|
||||
const isValid = dayjs().startOf('minute').subtract(1, 'minute').isSameOrAfter(playDate);
|
||||
if (!isValid) {
|
||||
this.logger.debug(`${buildTrackString(playObj, {include: ['artist', 'track']})} recently played but not valid b/c it has not been playing for >= 1 minute`);
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
callApi = async (req) => {
|
||||
const {user, password} = this.config;
|
||||
callApi = async (req, retries = 0) => {
|
||||
const {
|
||||
user,
|
||||
password,
|
||||
maxRequestRetries = 1,
|
||||
retryMultiplier = 1.5
|
||||
} = this.config;
|
||||
|
||||
const salt = await crypto.randomBytes(10).toString('hex');
|
||||
const hash = crypto.createHash('md5').update(`${password}${salt}`).digest('hex')
|
||||
@@ -77,7 +77,7 @@ export class SubsonicSource extends AbstractSource {
|
||||
v: '1.15.0',
|
||||
c: `multi-scrobbler - ${this.name}`,
|
||||
f: 'json'
|
||||
})
|
||||
});
|
||||
try {
|
||||
const resp = await req;
|
||||
const {
|
||||
@@ -95,6 +95,12 @@ export class SubsonicSource extends AbstractSource {
|
||||
}
|
||||
return ssResp;
|
||||
} catch (e) {
|
||||
if(retries < maxRequestRetries) {
|
||||
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
|
||||
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
return await this.callApi(req, retries + 1)
|
||||
}
|
||||
const {
|
||||
message,
|
||||
response: {
|
||||
@@ -120,14 +126,34 @@ export class SubsonicSource extends AbstractSource {
|
||||
}
|
||||
}
|
||||
|
||||
testConnection = async () => {
|
||||
initialize = async () => {
|
||||
const {url} = this.config;
|
||||
try {
|
||||
await request.get(`${url}/`);
|
||||
this.logger.info('Subsonic Connection: ok');
|
||||
this.initialized = true;
|
||||
} catch (e) {
|
||||
if(e.status !== undefined && e.status !== 404) {
|
||||
this.logger.info('Subsonic Connection: ok');
|
||||
// we at least got a response!
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
testAuth= async () => {
|
||||
const {url} = this.config;
|
||||
try {
|
||||
await this.callApi(request.get(`${url}/rest/ping`));
|
||||
this.authed = true;
|
||||
this.logger.info('Subsonic API Status: ok');
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
this.authed = false;
|
||||
}
|
||||
|
||||
return this.authed;
|
||||
}
|
||||
|
||||
getRecentlyPlayed = async (options = {}) => {
|
||||
@@ -139,6 +165,7 @@ export class SubsonicSource extends AbstractSource {
|
||||
entry = []
|
||||
} = {}
|
||||
} = resp;
|
||||
return entry.map(x => formatted ? SubsonicSource.formatPlayObj(x) : x)
|
||||
this.processRecentPlays(entry.map(x => formatted ? SubsonicSource.formatPlayObj(x) : x));
|
||||
return this.statefulRecentlyPlayed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import winston from "winston";
|
||||
import jsonStringify from 'safe-stable-stringify';
|
||||
import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js";
|
||||
import { Response } from 'superagent';
|
||||
|
||||
const {format} = winston;
|
||||
const {combine, printf, timestamp, label, splat, errors} = format;
|
||||
@@ -260,3 +262,68 @@ export const playObjDataMatch = (a, b) => {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const parseRetryAfterSecsFromObj = (err) => {
|
||||
|
||||
let raVal;
|
||||
|
||||
if (err instanceof TimeoutError) {
|
||||
return undefined;
|
||||
}
|
||||
if (err instanceof WebapiError || err instanceof Response) {
|
||||
const {headers = {}} = err;
|
||||
raVal = headers['retry-after']
|
||||
}
|
||||
// if (err instanceof Response) {
|
||||
// const {headers = {}} = err;
|
||||
// raVal = headers['retry-after']
|
||||
// }
|
||||
const {
|
||||
response: {
|
||||
headers, // returned in superagent error
|
||||
} = {},
|
||||
retryAfter: ra // possible custom property we have set
|
||||
} = err;
|
||||
|
||||
if (ra !== undefined) {
|
||||
raVal = ra;
|
||||
} else if (headers !== null && typeof headers === 'object') {
|
||||
raVal = headers['retry-after'];
|
||||
}
|
||||
|
||||
if (raVal === undefined || raVal === null) {
|
||||
return raVal;
|
||||
}
|
||||
|
||||
// first try to parse as float
|
||||
let retryAfter = Number.parseFloat(raVal);
|
||||
if (!isNaN(retryAfter)) {
|
||||
return retryAfter; // got a number!
|
||||
}
|
||||
// try to parse as date
|
||||
retryAfter = dayjs(retryAfter);
|
||||
if (!dayjs.isDayjs(retryAfter)) {
|
||||
return undefined; // could not parse string if not in ISO 8601 format
|
||||
}
|
||||
// otherwise we got a date! now get the difference the specified retry-after date and now in seconds
|
||||
const diff = retryAfter.diff(dayjs(), 'second');
|
||||
|
||||
if (diff <= 0) {
|
||||
// if diff is in the past returned undefined as its irrelevant now
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
export const spreadDelay = (retries, multiplier) => {
|
||||
if(retries === 0) {
|
||||
return [];
|
||||
}
|
||||
let r;
|
||||
let s = [];
|
||||
for(r = 0; r < retries; r++) {
|
||||
s.push(((r+1) * multiplier) * 1000);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
+59
-20
@@ -1,26 +1,65 @@
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css" integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ==" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css" integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg==" crossorigin="anonymous" />
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@catalyst-elements/catalyst-toggle-switch@0.7.0/catalyst-toggle-switch.min.js"></script>
|
||||
<style>
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<title>Multi Scrobbler - Status</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Recently Played (<%= name %>)</h2>
|
||||
<% if (sourceType === 'spotify') { %>
|
||||
This list was returned from the Spotify Api <b>now.</b> Sometimes Spotify "backlogs" user activity so your recent tracks may not show up for minutes (or hours). ¯\_(ツ)_/¯
|
||||
<% } %>
|
||||
<ul>
|
||||
<% plays.forEach(function (play){ %>
|
||||
<li><pre><%-play%></pre></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="flex items-center flex-grow no-underline">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+" style="max-width:100px; max-height:30px;"/>
|
||||
<span class="px-4 break-normal">
|
||||
Multi Scrobbler
|
||||
</span>
|
||||
</a>
|
||||
<div>
|
||||
Dark mode <catalyst-toggle-switch id="toggle-switch"></catalyst-toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<h3>Recently Played (<%= name %>)</h3>
|
||||
</div>
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<% if (sourceType === 'spotify') { %>
|
||||
This list was returned from the Spotify Api <b>now.</b> Sometimes Spotify "backlogs" user activity so your recent tracks may not show up for minutes (or hours). ¯\_(ツ)_/¯
|
||||
<% } %>
|
||||
<ul>
|
||||
<% plays.forEach(function (play){ %>
|
||||
<li><%-play%></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let toggleSwitch = document.querySelector('#toggle-switch');
|
||||
toggleSwitch.addEventListener('change', (event) => {
|
||||
document.body.classList.toggle('dark')
|
||||
toggleSwitch.checked ? localStorage.setItem('ms-dark', 'yes') : localStorage.setItem('ms-dark', 'no')
|
||||
});
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
if (localStorage.getItem('ms-dark') === 'yes') {
|
||||
document.body.classList.add('dark')
|
||||
toggleSwitch.checked = true
|
||||
localStorage.setItem('ms-dark', 'yes')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+100
-68
@@ -1,75 +1,107 @@
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.facetContainer {
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
}
|
||||
.facetItem {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
.disabled {
|
||||
color: currentColor;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css" integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ==" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css" integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg==" crossorigin="anonymous" />
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/@catalyst-elements/catalyst-toggle-switch@0.7.0/catalyst-toggle-switch.min.js"></script>
|
||||
<style>
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<title>Multi Scrobbler</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Sources</h2>
|
||||
<div class="facetContainer">
|
||||
<% sources.forEach(function (source){ %>
|
||||
<div class="facetItem">
|
||||
<h3><%= source.display %> - <%= source.name %></h3>
|
||||
<ul>
|
||||
<li><b>Status: <%= source.status %></b></li>
|
||||
<li>Tracks Discovered (since app started): <%= source.tracksDiscovered %></li>
|
||||
<% if (source.canPoll === true) { %>
|
||||
<li>Click <a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="recent?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>to see recently played tracks returned by API</a></li>
|
||||
<% if (source.hasAuth) { %>
|
||||
<li>Click <a target="_blank" href="source/auth?name=<%= source.name %>&type=<%= source.type %>">to (re)authenticate and (re)start polling</a></li>
|
||||
<% } %>
|
||||
<li>Click <a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="poll?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>to restart polling</a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<h2>Clients</h2>
|
||||
<div class="facetContainer">
|
||||
<% clients.forEach(function (client){ %>
|
||||
<div class="facetItem">
|
||||
<h3><%= client.display %> - <%= client.name %></h3>
|
||||
<ul>
|
||||
<li><b>Status: <%= client.status %></b></li>
|
||||
<li>Tracks Scrobbled (since app started): <%= client.tracksDiscovered %></li>
|
||||
<% if (client.hasAuth) { %>
|
||||
<li>Click <a target="_blank" href="client/auth?name=<%= client.name %>&type=<%= client.type %>">to (re)authenticate or initialize</a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'yes' ? document.body.classList.add('dark') : document.body.classList.remove('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="flex items-center flex-grow no-underline">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+" style="max-width:100px; max-height:30px;"/>
|
||||
<span class="px-4 break-normal">
|
||||
Multi Scrobbler
|
||||
</span>
|
||||
</a>
|
||||
<div>
|
||||
Dark mode <catalyst-toggle-switch id="toggle-switch"></catalyst-toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-3 gap-3 ">
|
||||
<% sources.forEach(function (source){ %>
|
||||
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<h3>(Source) <%= source.display %> - <%= source.name %></h3>
|
||||
</div>
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div><b>Status: <%= source.status %></b></div>
|
||||
<div>Tracks Discovered (since app started): <%= source.tracksDiscovered %></div>
|
||||
<% if (source.canPoll === true) { %>
|
||||
<div><a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="recent?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>See recently played tracks returned by API</a></div>
|
||||
<% if (source.hasAuth) { %>
|
||||
<div><a target="_blank" href="source/auth?name=<%= source.name %>&type=<%= source.type %>">(Re)authenticate and (re)start polling</a></div>
|
||||
<% } %>
|
||||
<div><a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="poll?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>Restart polling</a></div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-3">
|
||||
<% clients.forEach(function (client){ %>
|
||||
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<h3>(Client) <%= client.display %> - <%= client.name %></h3>
|
||||
</div>
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<ul>
|
||||
<div><b>Status: <%= client.status %></b></div>
|
||||
<div>Tracks Scrobbled (since app started): <%= client.tracksDiscovered %></div>
|
||||
<% if (client.hasAuth) { %>
|
||||
<div>Click <a target="_blank" href="client/auth?name=<%= client.name %>&type=<%= client.type %>">to (re)authenticate or initialize</a></div>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<div class="grid ">
|
||||
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
<h2>Log (Most Recent)</h2>
|
||||
</div>
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div>Level : <%- logs.level %> </div>
|
||||
<div>Sort : <%- logs.sort %></div>
|
||||
<div>Limit : <%- logs.limit %></div>
|
||||
<br />
|
||||
<% logs.output.forEach(function (logEntry){ %>
|
||||
<%-logEntry%>
|
||||
<% }) %>
|
||||
</div>
|
||||
<div class="w-full flex-auto flex min-h-0 overflow-auto">
|
||||
<div class="w-full relative flex-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Log (Most Recent)</h2>
|
||||
<ul>
|
||||
<li>Level : <%- logs.level %> </li>
|
||||
<li>Sort : <%- logs.sort %></li>
|
||||
<li>Limit : <%- logs.limit %></li>
|
||||
</ul>
|
||||
<pre>
|
||||
<% logs.output.forEach(function (logEntry){ %>
|
||||
<%=logEntry%><% }) %>
|
||||
</pre>
|
||||
<script>
|
||||
let toggleSwitch = document.querySelector('#toggle-switch');
|
||||
toggleSwitch.addEventListener('change', (event) => {
|
||||
document.body.classList.toggle('dark')
|
||||
toggleSwitch.checked ? localStorage.setItem('ms-dark', 'yes') : localStorage.setItem('ms-dark', 'no')
|
||||
});
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
if (localStorage.getItem('ms-dark') === 'yes') {
|
||||
document.body.classList.add('dark')
|
||||
toggleSwitch.checked = true
|
||||
localStorage.setItem('ms-dark', 'yes')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user