Compare commits

...
33 Commits
Author SHA1 Message Date
FoxxMD 71a8ad2418 Fix typo on users specification for jellyfin source setup 2021-07-05 10:43:44 -04:00
FoxxMD 49bd1c8836 Refactor initialization and authentication stages for client/source building
* Break up into different functions and add properties to concrete classes signalling its capabilities
* More detailed logging based on which fails/succeeds
* Simplify client/source init/auth step (no more need for case switch specifics other than creating object)
2021-04-14 16:22:05 -04:00
FoxxMD fc3dd62858 Refactor source building and initialization to log failures but continue
* Instead of stopping the whole application for one misconfigured config just log the issue and continue with any valid/working configs.
* Add better logging for type.json issues (add index)
* Add deprecation warnings for single-user mode structures in type.json (to be removed in 0.4)
2021-04-14 11:28:55 -04:00
FoxxMD ff114a0ae5 Refactor client building and initialization to log failures but continue
* Instead of stopping the whole application for one misconfigured config just log the issue and continue with any valid/working configs.
* Add better logging for type.json issues (add index)
* Add deprecation warnings for single-user mode structures in type.json (to be removed in 0.4)
2021-04-14 11:14:57 -04:00
FoxxMD 8bfcb6cd9a Showcase last.fm as a source in readme 2021-04-14 10:19:23 -04:00
FoxxMD 8154e30939 Add last.fm as source to readme 2021-04-13 17:05:46 -04:00
FoxxMD 506440825f Implement Last.fm as a source
For #23
2021-04-13 17:04:40 -04:00
FoxxMD 646723fcf8 Add ui screenshot to readme 2021-03-17 10:34:51 -04:00
FoxxMD a560a49aac Fix typo 2021-03-16 15:44:38 -04:00
FoxxMD de763d68f3 Add Jellyfin source #15
* Implement Jellyfin source similar to Plex, but using MemorySource
* Add configuration docs and json example
2021-03-16 15:36:51 -04:00
FoxxMD f731c25332 Return new plays when memory source adds them to stateful
Will make it easier for non-polling sources to see what is new from state
2021-03-16 15:33:41 -04:00
FoxxMD f738eb92e8 Merge branch 'develop' into feat-jellyfin 2021-03-16 14:39:26 -04:00
FoxxMD b9f4f43d30 Refactor subsonic source to use MemorySource
* Should stabilize subsonic as a source and prevents duplicate scrobbles
* Reduce polling interval to 10 seconds for more accurate recently played data and restrict max backoff time to 30 seconds
2021-03-16 14:38:59 -04:00
FoxxMD e42f00604e Refactor polling source backoff approach
Use time-based approach rather than check count approach so active period is not sensitive to interval value
2021-03-16 14:37:15 -04:00
FoxxMD 74eed98b9b Fix filter method 2021-03-16 14:25:55 -04:00
FoxxMD cef6f5864a Implement "in memory" recently played tracking for sources that don't support a source of truth
If sources don't support a scrobble action or don't return a sane "recently played" data source we need to keep track of these plays ourselves. Do it in memory based on currently playing and return that as source "source of truth" for recently played.
2021-03-16 14:01:17 -04:00
FoxxMD d5e816b1d3 Use a persistent datetime based on source instantiation or last track play as client refresh check threshold
We don't need to refresh clients on every source poll, only when first creating a source or when a new track play is discovered.
2021-03-16 13:59:43 -04:00
FoxxMD dcc4201019 Improve parsing of last.fm track responses and handling recent tracks
* Include some metadata (mbid, url, nowplaying)
* Check for more artist properties since responses seem inconsistent now
* Check for timestamp to handle nowplaying use case
* Handle invalid scrobbles from last.fm on recents refresh WRT nowplaying and missing timestamps #22
2021-03-16 10:57:44 -04:00
FoxxMD 9c093a8455 POC jellyfin implementation #15
* Hopefully can get artist from webhook payload in the future so we can actually use this
* Add basic instructions for using Webhook plugin in configuration docs
2021-03-12 11:24:33 -05:00
FoxxMD 9a56a3ee4d Merge branch 'develop' into feat-jellyfin
# Conflicts:
#	index.js
2021-03-12 10:24:54 -05:00
Matt Foxx f2b0714dea Merge pull request #21 from christophernewton/master
Feature: Fix padding on client box
2021-03-02 09:19:19 -05:00
Chris Newton 0a1357acc7 feat: fix padding on client boxes 2021-03-02 08:54:47 +11:00
Matt Foxx 403af711eb Merge pull request #20 from christophernewton/master
Frontend update
2021-03-01 16:36:07 -05:00
Chris Newton 31cd72cd15 feat: fixed typo in debug output, added active state for links 2021-03-02 08:24:32 +11:00
Chris Newton 55afe876e0 feat: fixed typo in readme for docker 2021-02-27 14:31:41 +11:00
Chris Newton 49fadd8c9a feat: added styles, dark mode to frontend 2021-02-27 14:30:39 +11:00
Chris Newton 58157ddfee wip: updating styles for front end 2021-02-26 16:54:08 +11:00
FoxxMD 2a9cd87848 Update package properties 2021-02-25 09:20:37 -05:00
FoxxMD 7b31285f89 Fix missing throw error from polling try-catch
Without it the polling continues forever on loop! oops
2021-02-19 09:14:10 -05:00
FoxxMD 89db858289 Fix import error for Errors from spotify-web-api-node 2021-02-19 09:13:16 -05:00
FoxxMD 2fec6aff6e Implement request and polling retries
* Hierarchical retries and delay options for sources and clients (override general config => individual config)
* Logging for retry attempts
* Respect Retry-After header on responses if present
2021-02-18 15:01:21 -05:00
FoxxMD 9beadfaf0f Bump dependencies to fix spotify-web-api-node crash
spotify-web-api-node#340
2021-02-18 10:04:12 -05:00
FoxxMD 855d3f6144 Jellyfin initial code 2021-01-04 12:06:45 -05:00
30 changed files with 2763 additions and 440 deletions
+10 -2
View File
@@ -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?**
+28
View File
@@ -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');
}
}
+164
View File
@@ -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

+15 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+9
View File
@@ -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
+10
View File
@@ -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
}
}
]
+4 -1
View File
@@ -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)
}
}
]
+1
View File
@@ -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)
}
}
]
+2 -1
View File
@@ -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)
}
}
]
+2 -1
View File
@@ -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)
}
}
]
+37
View File
@@ -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)
+10
View File
@@ -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
}
},
{
+94 -39
View File
@@ -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);
+1317 -14
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -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
View File
@@ -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;
}
}
}
+165
View File
@@ -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)
}
}
}
}
+79
View File
@@ -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);
}
}
+76
View File
@@ -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'));
}
}
+3 -1
View File
@@ -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
View File
@@ -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);
}
}
+34 -3
View File
@@ -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
View File
@@ -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;
}
}
+67
View File
@@ -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
View File
@@ -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
View File
@@ -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>