Compare commits

...
24 Commits
Author SHA1 Message Date
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 dd7e971e71 Fix how scrobbled tracks are returned to source
Returns tracks array should only show that was a track was scrobbled or not (by existing in the array) -- and only be in the array once.
2021-01-12 09:47:47 -05:00
FoxxMD 1aee74bfc1 Fix scrobbled tracks statistic for scrobble clients 2021-01-11 10:11:40 -05:00
FoxxMD 123c171d07 Fix case insensitive check 2021-01-05 17:10:18 -05:00
FoxxMD 764b490cfd Fix includes usage 2021-01-05 10:20:35 -05:00
FoxxMD aec3398edb Return a proper response on lastfm cb 2021-01-04 17:21:52 -05:00
FoxxMD e552820b4d Add missing session property for lastfm config 2021-01-04 17:07:54 -05:00
FoxxMD 1bc8edb07c Update documentation for last.fm
* Add last.fm configuration docs, kitchen sink, and json example
* Update readme
2021-01-04 17:02:36 -05:00
FoxxMD 085e61db5c Refactor app for lastfm client and multiple clients (!)
* Pass config dir to client handler
* Rename auth routes to be source/client specific
* Pass client info to status page to enable displaying client stats and actions (auth)
* Handle auth callback from lastfm
2021-01-04 16:35:38 -05:00
FoxxMD 1df6a8d6f7 Add lastfm to scrobble clients handler with some refactoring
* Refactor scrobble clients to be more granular on error handling for scrobble call
* use initialized param on clients to additionally check if they should be used
* Pass config dir to constructor so we can use it any in the class (for lastfm)
* Use error property to determine if we she keep trying to scrobble plays after caught error
2021-01-04 16:34:13 -05:00
FoxxMD d0e1e83ebd Implement Last.fm scrobble client
* Authentication is user-interaction required with saved session file
* Use initialized to signal auth is done and client is ready to scrobble/get tracks
* Add some retry attempts based on error returned from api
2021-01-04 16:27:23 -05:00
FoxxMD e0a2ada59b Add lastfm node client 2021-01-04 16:25:30 -05:00
FoxxMD 0508551dc7 Add airsonic advanced guidance to docs #10 2020-12-09 11:02:37 -05:00
FoxxMD f4a310cda9 Fix variable typo 2020-12-09 11:00:32 -05:00
FoxxMD 6ac8938644 Add subsonic docs #10 2020-12-09 10:51:02 -05:00
FoxxMD f41b3eea55 Implement subsonic ENV based config 2020-12-09 10:38:53 -05:00
FoxxMD db34d92329 Relax time thresholds for finding existing tracks when source is from Subsonic
Due to inaccuracy of subsonic DT need to relax threshold from 10 seconds (or same) to 60 seconds since granularity is only down to a minute #10
2020-12-09 10:27:51 -05:00
FoxxMD a60a0f89ad Filter out recently played subsonic tracks so that only tracks played for a minute or more are valid #10 2020-12-09 10:26:55 -05:00
FoxxMD 1de9c5cdc1 Refactor polling behavior
* Use plain ol async because it gets the job done and i don't need no fancy generator/yield. Can wait until loop is done to detect signal to stop polling
* Refactor polling into AbstractSource, remove duplicated code for Spotify/Subsonic sources
* Add 'canPoll' property to Sources
* Stagger polling invocation on app start so log messages don't get jumbled
* Refactor auth/poll/recent endpoints to be generic (based on source properties)
* Refactor status page to show functionality based on canAuth/canPoll rather than type
2020-12-08 16:49:10 -05:00
FoxxMD 299e37e32a Implement subsonic polling and scrobbling 2020-12-08 15:36:57 -05:00
FoxxMD 4921f963a8 Implement basic Subsonic source with api wrapper and authentication #10 2020-12-08 14:52:51 -05:00
24 changed files with 1291 additions and 308 deletions
+10 -2
View File
@@ -4,9 +4,15 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler)
A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja) (and other clients, eventually!)
A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and other clients (eventually!)
* Supports scrobbling from [Spotify](/docs/configuration.md#spotify), [Plex](/docs/configuration.md#plex), and [Tautulli](/docs/configuration.md#tautulli)
* Supports scrobbling for many sources
* [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/))
* Supports scrobbling to many clients
* [Maloja](/docs/configuration.md#maloja)
* [Last.fm](/docs/configuration.md#lastfm)
* Supports configuring for single or multiple users (scrobbling for your friends and family!)
* Web server interface for stats, basic control, and detailed logs
* Smart handling of credentials (persistent, authorization through app)
@@ -17,6 +23,8 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git
* **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use!
* **Open-source** -- Get peace of mind knowing exactly how your personal data is being handled.
* **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.
## Installation
+13 -1
View File
@@ -5,11 +5,13 @@ export default class AbstractScrobbleClient {
name;
type;
initialized = false;
recentScrobbles = [];
scrobbledPlayObjs = [];
newestScrobbleTime;
oldestScrobbleTime = dayjs();
tracksScrobbled = 0;
lastScrobbleCheck = dayjs();
refreshEnabled;
@@ -101,6 +103,9 @@ export default class AbstractScrobbleClient {
data: {
playDate
} = {},
meta: {
source,
} = {}
} = playObj;
const dtInvariantMatches = this.scrobbledPlayObjs.filter(x => playObjDataMatch(playObj, x.play));
@@ -114,9 +119,16 @@ export default class AbstractScrobbleClient {
play: {
data: {
playDate: sPlayDate
} = {}
} = {},
meta: {
source: playSource
} = {},
} = {},
} = x;
// need to account for inaccurate DT from subsonic
if(source === 'Subsonic' && playSource === 'Subsonic') {
return playDate.isSame(sPlayDate) || playDate.diff(sPlayDate, 'minute') <= 1;
}
return playDate.isSame(sPlayDate);
});
+398
View File
@@ -0,0 +1,398 @@
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import dayjs from 'dayjs';
import LastFm from 'lastfm-node-client';
import {
buildTrackString, parseRetryAfterSecsFromObj,
playObjDataMatch,
readJson,
setIntersection, sleep,
sortByPlayDate,
truncateStringToLength, 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 LastfmScrobbler extends AbstractScrobbleClient {
client;
redirectUri;
workingCredsPath;
initialized = false;
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! Client will most likely fail when trying to scrobble");
}
this.workingCredsPath = `${configDir}/currentCreds-lastfm-${name}.json`;
this.client = new LastFm(apiKey, secret, session);
}
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, 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;
}
} 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;
}
}
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 {
recenttracks: {
track: list = [],
}
} = resp;
this.recentScrobbles = list.map(x => LastfmScrobbler.formatPlayObj(x)).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);
this.newestScrobbleTime = newestScrobbleTime;
this.oldestScrobbleTime = oldestScrobbleTime;
this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play));
}
}
this.lastScrobbleCheck = dayjs();
}
cleanSourceSearchTitle = (playObj) => {
const {
data: {
track,
} = {},
} = playObj;
return track.toLocaleLowerCase().trim();
}
alreadyScrobbled = (playObj, log = false) => {
return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined;
}
existingScrobble = (playObj, logMatch = false) => {
const tr = truncateStringToLength(27);
const scoreTrackOpts = {include: ['track', 'time'], transformers: {track: t => tr(t).padEnd(30)}};
// return early if we don't care about checking existing
if (false === this.checkExistingScrobbles) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
}
return undefined;
}
let existingScrobble;
let closestMatch = {score: 0, breakdowns: ['None']};
// then check if we have already recorded this
const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj);
// if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
if (existingExactSubmitted !== undefined) {
existingScrobble = existingExactSubmitted.scrobble;
closestMatch = {
score: 1,
breakdowns: ['Exact Match found in previously successfully scrobbled']
}
}
// if not though then we need to check recent scrobbles from scrobble api.
// this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
// if no recent scrobbles found then assume we haven't submitted it
// (either user doesnt want to check history or there is no history to check!)
if (this.recentScrobbles.length === 0) {
if (this.verboseOptions.match.onNoMatch) {
this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`);
}
return undefined;
}
if (existingScrobble === undefined) {
// we have have found an existing submission but without an exact date
// in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison
const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined;
const {
data: {
artists: sourceArtists = [],
playDate
} = {},
meta: {
trackLength,
source,
} = {},
} = playObj;
// clean source title so it matches title from the scrobble api response as closely as we can get it
let cleanSourceTitle = this.cleanSourceSearchTitle(playObj);
existingScrobble = this.recentScrobbles.find((x) => {
const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
let closeTime = false;
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
let scrobblePlayStartDiff;
if (scrobblePlayDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
if (closeTime === false && trackLength !== undefined) {
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
if (scrobblePlayStartDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
}
let titleMatch;
const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim();
// because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces
// and compare against length of scrobble title
const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter(x => x !== ''));
const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms);
titleMatch = commonTerms.size / sourceTitleTerms.size;
let artistMatch;
const lowerSourceArtists = sourceArtists.map(x => x.toLocaleLowerCase());
const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase());
artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length;
const artistScore = .2 * artistMatch;
const titleScore = .3 * titleMatch;
const timeScore = .5 * (closeTime ? 1 : 0);
const referenceScore = .5 * (referenceMatch ? 1 : 0);
const score = artistScore + titleScore + timeScore;
let scoreBreakdowns = [
`Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`,
`Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`,
`Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`,
`Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`,
`Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
];
const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}`
const scoreInfo = {
score,
scrobble: x,
breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence]
}
if (closestMatch.score <= score && score > 0) {
closestMatch = scoreInfo
}
return score >= .7;
});
}
if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) {
const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`;
this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`);
}
return existingScrobble;
}
scrobble = async (playObj) => {
const {
data: {
artists,
album,
track,
duration,
playDate
} = {},
data = {},
meta: {
source,
newFromSource = false,
} = {}
} = playObj;
const sType = newFromSource ? 'New' : 'Backlog';
try {
const response = await this.callApi(client => client.trackScrobble(
{
artist: artists.join(', '),
duration,
track,
album,
timestamp: playDate.unix(),
}));
const {
scrobbles: {
'@attr': {
accepted = 0,
ignored = 0,
code,
},
scrobble: {
track: {
'#text': trackName,
} = {},
timestamp,
ignoredMessage: {
code: ignoreCode,
'#text': ignoreMsg,
} = {},
...rest
} = {}
} = {},
} = response;
if(code === 5) {
this.initialized = false;
throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client');
}
this.addScrobbledTrack(playObj, {...rest, date: { uts: timestamp}, name: trackName});
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`);
}
if(ignoreMsg !== '') {
this.logger.warn(`Service ignored this scrobble 😬 => (Code ${ignoreCode}) ${ignoreMsg}`)
}
// last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe
await sleep(1000);
} catch (e) {
this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj)});
throw e;
}
return true;
}
}
+36 -6
View File
@@ -1,7 +1,15 @@
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"];
@@ -52,10 +60,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: {
@@ -106,13 +125,14 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} = resp;
if (bodyStatus.toLocaleLowerCase() === 'ok') {
this.logger.info('Test connection succeeded!');
this.initialized = true;
return true;
}
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');
@@ -222,6 +242,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
} = {},
meta: {
trackLength,
source,
} = {},
} = playObj;
@@ -234,18 +255,19 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x;
const playDiffThreshold = source === 'Subsonic' ? 60 : 10;
let closeTime = false;
// check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks)
let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix());
let scrobblePlayStartDiff;
if (scrobblePlayDiff < 10) {
if (scrobblePlayDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
// also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations
if (closeTime === false && trackLength !== undefined) {
scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength));
if (scrobblePlayStartDiff < 10) {
if (scrobblePlayStartDiff <= playDiffThreshold) {
//this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`);
closeTime = true;
}
@@ -334,7 +356,15 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
key: apiKey,
time: playDate.unix(),
}));
this.addScrobbledTrack(playObj, response.body.track);
const {body: {
track: {
time: mTime = playDate.unix(),
duration: mDuration = duration,
album: mAlbum = album,
...rest
}
} = {}} = response;
this.addScrobbledTrack(playObj, {...rest, album: mAlbum, time: mTime, duration: mDuration});
if (newFromSource) {
this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`);
} else {
+92 -24
View File
@@ -1,15 +1,24 @@
import dayjs from "dayjs";
import {createLabelledLogger, isValidConfigStructure, readJson, returnDuplicateStrings} from "../utils.js";
import {
createLabelledLogger,
isValidConfigStructure,
playObjDataMatch,
readJson,
returnDuplicateStrings
} from "../utils.js";
import MalojaScrobbler from "./MalojaScrobbler.js";
import LastfmScrobbler from "./LastfmScrobbler.js";
export default class ScrobbleClients {
clients = [];
logger;
configDir;
clientTypes = ['maloja'];
clientTypes = ['maloja','lastfm'];
constructor() {
constructor(configDir) {
this.configDir = configDir;
this.logger = createLabelledLogger('scrobblers', 'Scrobblers');
}
@@ -17,17 +26,22 @@ export default class ScrobbleClients {
return this.clients.find(x => x.name === name);
}
buildClientsFromConfig = async (configDir = undefined) => {
buildClientsFromConfig = async () => {
let configs = [];
let configFile;
try {
configFile = await readJson(`${configDir}/config.json`, {throwOnNotFound: false});
configFile = await readJson(`${this.configDir}/config.json`, {throwOnNotFound: false});
} catch (e) {
throw new Error('config.json could not be parsed');
}
let clientDefaults = {};
if (configFile !== undefined) {
const {clients: mainConfigClientConfigs = []} = configFile;
const {
clients: mainConfigClientConfigs = [],
clientDefaults: cd = {},
} = configFile;
clientDefaults = cd;
if (!mainConfigClientConfigs.every(x => x !== null && typeof x === 'object')) {
throw new Error('All clients from config.json must be objects');
}
@@ -56,12 +70,29 @@ export default class ScrobbleClients {
})
}
break;
case 'lastfm':
const lfm = {
apiKey: process.env.LASTFM_API_KEY,
secret: process.env.LASTFM_SECRET,
redirectUri: process.env.LASTFM_REDIRECT_URI,
session: process.env.LASTFM_SESSION,
};
if (!Object.values(lfm).every(x => x === undefined)) {
configs.push({
type: 'lastfm',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: lfm
})
}
break;
default:
break;
}
let rawClientConfigs;
try {
rawClientConfigs = await readJson(`${configDir}/${clientType}.json`, {throwOnNotFound: false});
rawClientConfigs = await readJson(`${this.configDir}/${clientType}.json`, {throwOnNotFound: false});
} catch (e) {
throw new Error(`${clientType}.json config file could not be parsed`);
}
@@ -136,16 +167,18 @@ ${sources.join('\n')}`);
name
}));
for (const c of finalConfigs) {
await this.addClient(c);
await this.addClient(c, clientDefaults);
}
}
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};
switch (type) {
case 'maloja':
this.logger.debug(`(${name}) Attempting Maloja initialization...`);
@@ -158,11 +191,27 @@ ${sources.join('\n')}`);
this.clients.push(mj)
}
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`)
}
break;
default:
break;
}
}
/**
* @param {*} data
* @param {{scrobbleFrom, scrobbleTo, forceRefresh: boolean}|{scrobbleFrom, scrobbleTo}} options
* @returns {Array}
*/
scrobble = async (data, options = {}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
@@ -183,25 +232,44 @@ ${sources.join('\n')}`);
this.logger.debug(`Client '${client.name}' was filtered out by '${scrobbleFrom}'`);
continue;
}
try {
if(client.initialized === false) {
this.logger.debug(`Client '${client.name}' is not yet initialized (check authorization?)`);
continue;
}
if (forceRefresh || client.scrobblesLastCheckedAt().unix() < checkTime.unix()) {
await client.refreshScrobbles();
try {
await client.refreshScrobbles();
} catch(e) {
this.logger.error(`Encountered error while refreshing scrobbles for ${client.name}`);
this.logger.error(e);
}
}
for (const playObj of playObjs) {
const {
meta: {
newFromSource = false,
} = {}
} = playObj;
if (client.timeFrameIsValid(playObj, newFromSource) && !client.alreadyScrobbled(playObj, newFromSource)) {
tracksScrobbled.push(playObj);
await client.scrobble(playObj);
try {
const {
meta: {
newFromSource = false,
} = {}
} = playObj;
if (client.timeFrameIsValid(playObj, newFromSource) && !client.alreadyScrobbled(playObj, newFromSource)) {
await client.scrobble(playObj)
client.tracksScrobbled++;
// since this is what we return to the source only add to tracksScrobbled if not already in array
// (source should only know that a track was scrobbled (binary) -- doesn't care if it was scrobbled more than once
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
tracksScrobbled.push(playObj);
}
}
} catch(e) {
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
this.logger.error(e);
// for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue
if(e.continueScrobbling !== true) {
break;
}
}
}
} catch (e) {
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
this.logger.error(e);
}
}
return tracksScrobbled;
}
+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
+14
View File
@@ -0,0 +1,14 @@
[
{
"name": "myLastFm", // required, a name to identify your Client
"data": {
"apiKey": "string", // required, Lastfm api key
"secret": "string", // required, Lastfm shared secret
"session": "string", // optional, session id returned from a complete auth flow.
// 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)
}
}
]
+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "default", // optional, friendly name for logs
"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
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
}
}
]
+46
View File
@@ -120,6 +120,29 @@ Check the [instructions](plex.md) on how to setup a notification agent.
See [`tautulli.json.example`](../config/tautulli.json.example)
## [Subsonic](http://www.subsonic.org/)
Can use this source for any application that implements the [Subsonic API](http://www.subsonic.org/pages/api.jsp) (such as [Airsonic](https://airsonic.github.io/))
**Known Issues:**
* "Time played at" is somewhat inaccurate since the api only reports "played X minutes ago" so...
* All scrobble times are therefore "on the minute" and you may experience occasional duplicate scrobbles
* "played X minutes ago" sometimes is also not reported correctly
* Multiple artists are reported as one value and cannot be separated
* If using [Airsonic Advanced](https://github.com/airsonic-advanced/airsonic-advanced) the password used (under **Credentials**) must be **Decodable**
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
| `SUBSONIC_USER` | Yes | | |
| `SUBSONIC_PASSWORD` | Yes | | |
| `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server |
### JSON-Based
See [`subsonic.json.example`](../config/subsonic.json.example)
# Clients
## [Maloja](https://github.com/krateng/maloja)
@@ -134,3 +157,26 @@ See [`tautulli.json.example`](../config/tautulli.json.example)
### JSON-Based
See [`maloja.json.example`](../config/maloja.json.example)
## [Last.fm](https://www.last.fm)
[Register for an API account here.](https://www.last.fm/api/account/create)
The Callback URL is actually specified by multi-scrobbler but to keep things consistent you should use
```
http://localhost:9078/lastfm/callback
```
or replace `localhost:9078` with your own base URL
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|----------------------------|-----------|---------|-------------------------------|
| `LASTFM_API_KEY` | Yes | | Api Key from your API Account |
| `LASTFM_SECRET` | Yes | | Shared secret from your API Account |
| `LASTFM_REDIRECT_URI` | No | `http://localhost:{PORT}/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it |
| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. |
### JSON-Based
See [`lastfm.json.example`](../config/lastfm.json.example)
+46 -1
View File
@@ -5,6 +5,8 @@ Scenario:
* You want to scrobble plays for yourself (Foxx), Fred, and Mary
* Each person has their own Maloja server
* Each person has their own Spotify account
* You have your own Airsonic (subsonic) server you to scrobble from
* Mary has her own Last.fm account she also wants to scrobble to
* Fred has his own Spotify application and provides you with just his access and refresh token because he doesn't trust you (wtf Fred)
* Fred has a Plex server and wants to scrobble everything he plays
* Mary uses Fred's Plex server but only wants to scrobble her plays from the `podcast` library
@@ -16,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",
@@ -24,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
}
},
{
@@ -69,7 +81,17 @@ Using just one config file located at `CONFIG_DIR/config.json`:
"data": {
"libraries": ["party"],
}
}
},
{
"type": "subsonic",
"name": "foxxAirsonic",
"clients": ["foxxMaloja"],
"data": {
"user": "foxx",
"password": "foxxPassword",
"url": "https://airsonic.foxx.example"
}
},
],
"clients": [
{
@@ -95,6 +117,14 @@ Using just one config file located at `CONFIG_DIR/config.json`:
"url": "https://maloja.mary.example",
"apiKey": "maryApiKey"
}
},
{
"type": "lastfm",
"name": "maryLFM",
"data": {
"apiKey": "maryApiKey",
"secret": "marySecret",
}
}
]
}
@@ -191,3 +221,18 @@ In `CONFIG_DIR/maloja.json`:
}
]
```
In `CONFIG_DIR/lastfm.json`:
```json5
[
{
"name": "maryLFM",
"data": {
"apiKey": "maryApiKey",
"secret": "marySecret",
}
}
]
```
+117 -53
View File
@@ -14,7 +14,7 @@ import {
capitalize,
labelledFormat,
longestString,
readJson,
readJson, sleep,
truncateStringToLength
} from "./utils.js";
import Clients from './clients/ScrobbleClients.js';
@@ -111,15 +111,15 @@ app.use(bodyParser.json());
/*
* setup clients
* */
const scrobbleClients = new Clients();
await scrobbleClients.buildClientsFromConfig(configDir);
const scrobbleClients = new Clients(configDir);
await scrobbleClients.buildClientsFromConfig();
if (scrobbleClients.clients.length === 0) {
logger.warn('No scrobble clients were configured!')
}
const scrobbleSources = new ScrobbleSources(localUrl, configDir);
let deprecatedConfigs = [];
if(spotify !== undefined) {
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.`)
deprecatedConfigs.push({
type: 'spotify',
@@ -129,7 +129,7 @@ app.use(bodyParser.json());
data: spotify
});
}
if(plex !== undefined) {
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.`)
deprecatedConfigs.push({
type: 'plex',
@@ -150,30 +150,52 @@ app.use(bodyParser.json());
slicedLog.reverse();
}
const sourceData = scrobbleSources.sources.map((x) => {
const {type, discoveredTracks = 0, name} = x;
const base = {type, display: capitalize(type), discoveredTracks, name};
const {type, tracksDiscovered = 0, name, canPoll = false, polling = false} = x;
const base = {type, display: capitalize(type), tracksDiscovered, name, canPoll, hasAuth: false};
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;
let status = authed ? 'Yes' : 'Auth Interaction Required';
if(authed) {
status = x.pollerRunning ? 'Running' : 'Idle';
}
return {
...base,
hasAuth: true,
authed,
status,
status: authed ? base.status : 'Auth Interaction Required',
}
case 'plex':
case 'tautulli':
default:
return base;
}
});
const clientData = scrobbleClients.clients.map((x) => {
const {type, tracksScrobbled = 0, name} = x;
const base = {
type,
display: capitalize(type),
tracksDiscovered: tracksScrobbled,
name,
hasAuth: false,
status: tracksScrobbled > 0 ? 'Received Data' : 'Awaiting Data'
};
switch (x.type) {
case 'lastfm':
const authed = x.initialized;
return {
...base,
status: discoveredTracks > 0 ? 'Received Data' : 'Awaiting Data'
hasAuth: true,
authed,
status: authed ? base.status : 'Auth Interaction Required',
}
default:
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(' | '),
@@ -227,46 +249,63 @@ app.use(bodyParser.json());
res.send('OK');
});
app.use('/authSpotify', sourceCheckMiddle);
app.getAsync('/authSpotify', async function (req, res) {
app.use('/client/auth', clientCheckMiddle);
app.getAsync('/client/auth', async function (req, res) {
const {
scrobbleClient,
} = req;
switch (scrobbleClient.type) {
case 'lastfm':
res.redirect(scrobbleClient.getAuthUrl());
break;
default:
return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`);
}
});
app.use('/source/auth', sourceCheckMiddle);
app.getAsync('/source/auth', async function (req, res) {
const {
scrobbleSource: source,
sourceName: name,
} = req;
if (source.type !== 'spotify') {
return res.status(400).send(`Specified source is not spotify (${source.type})`);
}
if (source.spotifyApi === undefined) {
res.status(400).send('Spotify configuration is not valid');
} else {
logger.info('Redirecting to spotify authorization url');
res.redirect(source.createAuthUrl());
switch (source.type) {
case 'spotify':
if (source.spotifyApi === undefined) {
res.status(400).send('Spotify configuration is not valid');
} else {
logger.info('Redirecting to spotify authorization url');
res.redirect(source.createAuthUrl());
}
break;
default:
return res.status(400).send(`Specified source does not have auth implemented (${source.type})`);
}
});
app.use('/pollSpotify', sourceCheckMiddle);
app.getAsync('/pollSpotify', async function (req, res) {
app.use('/poll', sourceCheckMiddle);
app.getAsync('/poll', async function (req, res) {
const {
scrobbleSource: source,
} = req;
if (source.type !== 'spotify') {
return res.status(400).send(`Specified source is not spotify (${source.type})`);
if (!source.canPoll) {
return res.status(400).send(`Specified source cannot poll (${source.type})`);
}
source.pollSpotify(scrobbleClients);
source.poll(scrobbleClients);
res.send('OK');
});
app.use('/spotify/recent', sourceCheckMiddle);
app.getAsync('/spotify/recent', async function (req, res) {
app.use('/recent', sourceCheckMiddle);
app.getAsync('/recent', async function (req, res) {
const {
scrobbleSource: source,
} = req;
if (source.type !== 'spotify') {
return res.status(400).send(`Specified source is not spotify (${source.type})`);
if (!source.canPoll) {
return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`);
}
const result = await source.getRecentlyPlayed({formatted: true});
@@ -281,7 +320,7 @@ app.use(bodyParser.json());
} = {}
} = x;
const buildOpts = {
include: ['time', 'timeFromNow'],
include: ['time', 'timeFromNow', 'track', 'artist'],
transformers: {
artists: a => artistTruncFunc(a.join(' / ')).padEnd(33),
track: t => t.padEnd(trackLength)
@@ -292,7 +331,7 @@ app.use(bodyParser.json());
}
return buildTrackString(x, buildOpts);
});
res.render('spotify/recent', {plays, name: source.name});
res.render('recent', {plays, name: source.name, sourceType: source.type});
});
app.getAsync('/logs/settings/update', async function (req, res) {
@@ -317,35 +356,60 @@ app.use(bodyParser.json());
});
app.getAsync(/.*callback$/, async function (req, res) {
logger.info('Received auth code callback from Spotify', {label: 'Spotify'});
const {
query: {
state
} = {}
} = req;
const source = scrobbleSources.getByName(state);
const tokenResult = await source.handleAuthCodeCallback(req.query);
let responseContent = 'OK';
if (tokenResult === true) {
source.pollSpotify(scrobbleClients);
if (req.url.includes('lastfm')) {
const {
query: {
token
} = {}
} = req;
const client = scrobbleClients.getByName(state);
try {
await client.authenticate(token);
await client.initialize();
return res.send('OK');
} catch (e) {
return res.send(e.message);
}
} else {
responseContent = tokenResult;
logger.info('Received auth code callback from Spotify', {label: 'Spotify'});
const source = scrobbleSources.getByName(state);
const tokenResult = await source.handleAuthCodeCallback(req.query);
let responseContent = 'OK';
if (tokenResult === true) {
source.poll(scrobbleClients);
} else {
responseContent = tokenResult;
}
return res.send(responseContent);
}
return res.send(responseContent);
});
let anyNotReady = false;
for (const spotifySource of scrobbleSources.sources.filter(x => x.type === 'spotify')) {
if (spotifySource.spotifyApi !== undefined) {
if (spotifySource.spotifyApi.getAccessToken() === undefined) {
anyNotReady = true;
} else {
spotifySource.pollSpotify(scrobbleClients);
}
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
switch (source.type) {
case 'spotify':
if (source.spotifyApi !== undefined) {
if (source.spotifyApi.getAccessToken() === undefined) {
anyNotReady = true;
} else {
source.poll(scrobbleClients);
}
}
break;
default:
if (source.poll !== undefined) {
source.poll(scrobbleClients);
}
}
}
if (anyNotReady) {
logger.info(`Some spotify sources are not ready, open ${localUrl} to continue`);
logger.info(`Some sources are not ready, open ${localUrl} to continue`);
}
app.set('views', './views');
+17 -12
View File
@@ -303,9 +303,9 @@
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
"dayjs": {
"version": "1.9.6",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.9.6.tgz",
"integrity": "sha512-HngNLtPEBWRo8EFVmHFmSXAjtCX8rGNqeXQI0Gh7wCTSqwaKgPIDqu9m07wABVopNwzvOeCb+2711vQhDlcIXw=="
"version": "1.10.4",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz",
"integrity": "sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw=="
},
"debug": {
"version": "4.2.0",
@@ -368,9 +368,9 @@
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
},
"ejs": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.5.tgz",
"integrity": "sha512-dldq3ZfFtgVTJMLjOe+/3sROTzALlL9E34V4/sDtUd/KlBSS0s6U1/+WPE1B4sj9CXHJpL1M6rhNJnc9Wbal9w==",
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
"integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
"requires": {
"jake": "^10.6.1"
}
@@ -481,9 +481,9 @@
}
},
"filelist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.1.tgz",
"integrity": "sha512-8zSK6Nu0DQIC08mUC46sWGXi+q3GGpKydAG36k+JDba6VRpkevvOWUW5a/PhShij4+vHT9M+ghgG7eM+a9JDUQ==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
"integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
"requires": {
"minimatch": "^3.0.4"
}
@@ -627,6 +627,11 @@
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
},
"lastfm-node-client": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/lastfm-node-client/-/lastfm-node-client-2.2.0.tgz",
"integrity": "sha512-nhHxrRPaNKIJnEuRov68HGjQRAFel2UWnG86ieQAklaVOdBciC6DO0vajZjzPZGqwvUAxQzDHPsp2ceDOtKW2w=="
},
"logform": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz",
@@ -896,9 +901,9 @@
}
},
"spotify-web-api-node": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/spotify-web-api-node/-/spotify-web-api-node-5.0.0.tgz",
"integrity": "sha512-UbGp9LsydVX0EJSZMdJQmWGcBTbAK8Ei3uctAOGMuPtadRhj1zxvEwljtevoOqDTLAlyrm8VUdsswQ4l5j+E8Q==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/spotify-web-api-node/-/spotify-web-api-node-5.0.2.tgz",
"integrity": "sha512-r82dRWU9PMimHvHEzL0DwEJrzFk+SMCVfq249SLt3I7EFez7R+jeoKQd+M1//QcnjqlXPs2am4DFsGk8/GCsrA==",
"requires": {
"superagent": "^6.1.0"
}
+4 -3
View File
@@ -24,12 +24,13 @@
"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"
+4 -3
View File
@@ -1,7 +1,8 @@
export const makeSourceCheckMiddle = sources => (req, res, next) => {
const {
query: {
name
name,
type
} = {}
} = req;
@@ -9,10 +10,10 @@ export const makeSourceCheckMiddle = sources => (req, res, next) => {
return res.status(404).send('Source name must be defined');
}
const source = sources.getByName(name);
const source = sources.getByNameAndType(name, type);
if (source === undefined) {
return res.status(404).send(`No source with the name: ${name}`);
return res.status(404).send(`No source with the name [${name}] and type [${type}`);
}
req.sourceName = name;
+157 -1
View File
@@ -1,5 +1,5 @@
import dayjs from "dayjs";
import {capitalize, createLabelledLogger} from "../utils.js";
import {buildTrackString, capitalize, createLabelledLogger, sleep} from "../utils.js";
export default class AbstractSource {
@@ -11,6 +11,11 @@ export default class AbstractSource {
clients;
logger;
canPoll = false;
polling = false;
pollRetries = 0;
tracksDiscovered = 0;
constructor(type, name, config = {}, clients = []) {
this.type = type;
this.name = name;
@@ -19,4 +24,155 @@ export default class AbstractSource {
this.config = config;
this.clients = clients;
}
getRecentlyPlayed = async (options = {}) => {
return [];
}
// by default if the track was recently played it is valid
// this is useful for sources where the track doesn't have complete information like Subsonic
// TODO make this more descriptive? or move it elsewhere
recentlyPlayedTrackIsValid = (playObj) => {
return true;
}
poll = async (allClients) => {
await this.startPolling(allClients);
}
startPolling = async (allClients) => {
// 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
*/
doPolling = async (allClients) => {
if (this.polling === true) {
return;
}
this.logger.info('Polling started');
let lastTrackPlayedAt = dayjs();
let checkCount = 0;
try {
this.polling = true;
while (true) {
if(this.polling === false) {
this.logger.info('Stopped polling due to user input');
break;
}
let playObjs = [];
this.logger.debug('Refreshing recently played')
playObjs = await this.getRecentlyPlayed({formatted: true});
checkCount++;
let newTracksFound = false;
let closeToInterval = false;
const now = dayjs();
const playInfo = playObjs.reduce((acc, playObj) => {
if(this.recentlyPlayedTrackIsValid(playObj)) {
const {data: {playDate} = {}} = playObj;
if (playDate.unix() > lastTrackPlayedAt.unix()) {
newTracksFound = true;
this.logger.info(`New Track => ${buildTrackString(playObj)}`);
if (closeToInterval === false) {
closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5;
}
return {
plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}],
lastTrackPlayedAt: playDate
}
}
return {
...acc,
plays: [...acc.plays, playObj]
}
}
return acc;
}, {plays: [], lastTrackPlayedAt});
playObjs = playInfo.plays;
lastTrackPlayedAt = playInfo.lastTrackPlayedAt;
if (closeToInterval) {
// because the interval check was so close to the play date we are going to delay client calls for a few secs
// this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups)
// additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully
this.logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first');
await sleep(10 * 1000);
}
if (newTracksFound === false) {
if (playObjs.length === 0) {
this.logger.debug(`No new tracks found and no tracks returned from API`);
} else {
this.logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`);
}
} else {
checkCount = 0;
}
const scrobbleResult = await allClients.scrobble(playObjs, {
forceRefresh: closeToInterval,
scrobbleFrom: this.identifier,
scrobbleTo: this.clients
});
if (scrobbleResult.length > 0) {
checkCount = 0;
this.tracksDiscovered += scrobbleResult.length;
}
const {interval = 30} = 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
// 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);
}
}
// sleep for interval
this.logger.debug(`Sleeping for ${sleepTime}s`);
await sleep(sleepTime * 1000);
}
} catch (e) {
this.logger.error('Error occurred while polling');
this.logger.error(e);
this.polling = false;
throw e;
}
}
}
+3 -5
View File
@@ -7,8 +7,6 @@ export default class PlexSource extends AbstractSource {
libraries;
servers;
discoveredTracks = 0;
constructor(name, config, clients, type = 'plex') {
super(type, name, config, clients);
const {user, libraries, servers} = config
@@ -47,9 +45,9 @@ export default class PlexSource extends AbstractSource {
}
if (user === undefined && libraries === undefined && servers === undefined) {
this.logger.warn('Initialized, but with no filters! All tracks from all users on all servers and libraries will be scrobbled.');
this.logger.warn('Initializing, but with no filters! All tracks from all users on all servers and libraries will be scrobbled.');
} else {
this.logger.info(`Initialized 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.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(', ')}`);
}
}
@@ -166,7 +164,7 @@ export default class PlexSource extends AbstractSource {
try {
await allClients.scrobble(playObj, {scrobbleTo: this.clients, scrobbleFrom: this.identifier});
// only gets hit if we scrobbled ok
this.discoveredTracks++;
this.tracksDiscovered++;
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
+45 -7
View File
@@ -2,6 +2,7 @@ import {createLabelledLogger, isValidConfigStructure, readJson} from "../utils.j
import SpotifySource from "./SpotifySource.js";
import PlexSource from "./PlexSource.js";
import TautulliSource from "./TautulliSource.js";
import {SubsonicSource} from "./SubsonicSource.js";
export default class ScrobbleSources {
@@ -10,7 +11,7 @@ export default class ScrobbleSources {
configDir;
localUrl;
sourceTypes = ['spotify', 'plex', 'tautulli'];
sourceTypes = ['spotify', 'plex', 'tautulli', 'subsonic'];
constructor(localUrl, configDir = process.cwd()) {
this.configDir = configDir;
@@ -26,6 +27,10 @@ export default class ScrobbleSources {
return this.sources.filter(x => x.type === type);
}
getByNameAndType = (name, type) => {
return this.sources.find(x => x.name === name && x.type === type);
}
buildSourcesFromConfig = async (additionalConfigs = []) => {
let configs = additionalConfigs;
@@ -35,8 +40,13 @@ export default class ScrobbleSources {
} catch (e) {
throw new Error('config.json could not be parsed');
}
let sourceDefaults = {};
if (configFile !== undefined) {
const {sources: mainConfigSourcesConfigs = []} = configFile;
const {
sources: mainConfigSourcesConfigs = [],
sourceDefaults: sd = {},
} = configFile;
sourceDefaults = sd;
if (!mainConfigSourcesConfigs.every(x => x !== null && typeof x === 'object')) {
throw new Error('All sources from config.json must be objects');
}
@@ -96,6 +106,22 @@ export default class ScrobbleSources {
})
}
break;
case 'subsonic':
const sub = {
user: process.env.SUBSONIC_USER,
password: process.env.SUBSONIC_PASSWORD,
url: process.env.SUBSONIC_URL,
};
if (!Object.values(sub).every(x => x === undefined)) {
configs.push({
type: 'subsonic',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: sub
})
}
break;
default:
break;
}
@@ -170,20 +196,26 @@ export default class ScrobbleSources {
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');
}
}
tempNamedConfigs = tempNamedConfigs.map(({name = 'unnamed', ...x},i) => ({...x, name: hasDups ? `${name}${i+1}` : name}));
for(const c of tempNamedConfigs) {
await this.addSource(c);
tempNamedConfigs = tempNamedConfigs.map(({name = 'unnamed', ...x}, i) => ({
...x,
name: hasDups ? `${name}${i + 1}` : name
}));
for (const c of tempNamedConfigs) {
await this.addSource(c, sourceDefaults);
}
}
}
}
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;
const {type, name, clients = [], data: d = {}} = clientConfig;
// add defaults
const data = {...defaults, ...d};
this.logger.debug(`(${name}) Initializing ${type} source`);
switch (type) {
case 'spotify':
const spotifySource = new SpotifySource(name, {
@@ -202,8 +234,14 @@ export default class ScrobbleSources {
const tautulliSource = await new TautulliSource(name, data, clients);
this.sources.push(tautulliSource);
break;
case 'subsonic':
const ssSource = new SubsonicSource(name, data, clients);
await ssSource.testConnection();
this.sources.push(ssSource);
break;
default:
break;
}
this.logger.info(`(${name}) ${type} source initialized`);
}
}
+24 -130
View File
@@ -1,13 +1,8 @@
import dayjs from "dayjs";
import {EventEmitter} from "events";
import {
buildTrackString,
readJson,
sleep,
writeFile,
makeSingle,
sortByPlayDate,
createLabelledLogger
sortByPlayDate, sleep, parseRetryAfterSecsFromObj,
} from "../utils.js";
import SpotifyWebApi from "spotify-web-api-node";
import AbstractSource from "./AbstractSource.js";
@@ -18,30 +13,28 @@ const state = 'random';
export default class SpotifySource extends AbstractSource {
spotifyApi;
interval;
localUrl;
workingCredsPath;
configDir;
spotifyPoller;
pollerRunning = false;
emitter;
discoveredTracks = 0;
constructor(name, config = {}, clients = []) {
super('spotify', name, config, clients);
const {
localUrl,
configDir,
interval = 60,
} = config;
if (interval < 15) {
this.logger.warn('Interval should be above 30 seconds...😬');
}
this.config.interval = interval;
this.configDir = configDir;
this.workingCredsPath = `${configDir}/currentCreds-${name}.json`;
this.localUrl = localUrl;
this.spotifyPoller = makeSingle(pollSpotify);
this.emitter = new EventEmitter();
this.emitter.addListener('spotifyTrackDiscovered', this.handleDiscoveredTrack);
this.canPoll = true;
}
static formatPlayObj(obj, newFromSource = false) {
@@ -81,14 +74,8 @@ export default class SpotifySource extends AbstractSource {
}
}
handleDiscoveredTrack = (e) => {
this.discoveredTracks++;
}
buildSpotifyApi = async () => {
this.logger.debug('Initializing');
let spotifyCreds = {};
try {
spotifyCreds = await readJson(this.workingCredsPath, {throwOnNotFound: false});
@@ -102,14 +89,8 @@ export default class SpotifySource extends AbstractSource {
clientSecret,
redirectUri,
refreshToken,
interval = 60,
} = this.config || {};
if (interval < 15) {
console.warn('Interval should be above 30 seconds...😬');
}
this.interval = interval;
const rdUri = redirectUri || `${this.localUrl}/callback`;
@@ -156,7 +137,6 @@ export default class SpotifySource extends AbstractSource {
throw new Error('Failed to initialize a Spotify source');
}
this.logger.info('Initialized');
this.spotifyApi = new SpotifyWebApi(apiConfig);
}
@@ -187,21 +167,24 @@ export default class SpotifySource extends AbstractSource {
const func = api => api.getMyRecentlyPlayedTracks({
limit
});
const result = await this.trySpotifyCall(func);
const result = await this.callApi(func);
if (formatted === true) {
return result.body.items.map(x => SpotifySource.formatPlayObj(x)).sort(sortByPlayDate);
}
return result;
}
trySpotifyCall = async (func) => {
callApi = async (func, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 2,
} = this.config;
try {
return await func(this.spotifyApi);
} catch (e) {
if (e.statusCode === 401) {
if (this.spotifyApi.getRefreshToken() === undefined) {
this.logger.error('Access token was not valid and no refresh token was present, bailing out of polling');
return Promise.resolve();
throw new Error('Access token was not valid and no refresh token was present')
}
this.logger.debug('Access token was not valid, attempting to refresh');
@@ -226,113 +209,24 @@ 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;
}
}
}
pollSpotify = (allClients) => {
poll = async (allClients) => {
if (this.spotifyApi === undefined) {
this.logger.warn('Cannot poll spotify without valid credentials configuration')
return;
}
this.pollerRunning = true;
return this.spotifyPoller(this.logger, this, this.interval, this.workingCredsPath, allClients, this.clients, this.identifier, this.emitter)
.catch((e) => {
this.logger.error('Error occurred while polling spotify, polling has been stopped');
this.logger.error(e);
})
.finally(() => {
this.pollerRunning = false;
});
await this.startPolling(allClients);
}
}
const pollSpotify = function* (logger, source, interval = 60, credsPath, clients, clientsToScrobbleTo = [], scrobbleFrom, emitter) {
logger.info('Polling started');
let lastTrackPlayedAt = dayjs();
let checkCount = 0;
while (true) {
let playObjs = [];
logger.debug('Refreshing recently played')
playObjs = yield source.getRecentlyPlayed({formatted: true});
if (playObjs instanceof Error) {
return Promise.reject(playObjs);
}
checkCount++;
let newTracksFound = false;
let closeToInterval = false;
const now = dayjs();
const playInfo = playObjs.reduce((acc, playObj) => {
const {data: {playDate} = {}} = playObj;
if (playDate.unix() > lastTrackPlayedAt.unix()) {
newTracksFound = true;
logger.info(`New Track => ${buildTrackString(playObj)}`);
if (closeToInterval === false) {
closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5;
}
return {
plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}],
lastTrackPlayedAt: playDate
}
}
return {
...acc,
plays: [...acc.plays, playObj]
}
}, {plays: [], lastTrackPlayedAt});
playObjs = playInfo.plays;
lastTrackPlayedAt = playInfo.lastTrackPlayedAt;
if (closeToInterval) {
// because the interval check was so close to the play date we are going to delay client calls for a few secs
// this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups)
// additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully
logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first');
yield sleep(10 * 1000);
}
if (newTracksFound === false) {
if (playObjs.length === 0) {
logger.debug(`No new tracks found and no tracks returned from API`);
} else {
logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`);
}
} else {
checkCount = 0;
}
const scrobbleResult = yield clients.scrobble(playObjs, {forceRefresh: closeToInterval, scrobbleFrom, scrobbleTo: clientsToScrobbleTo});
if (scrobbleResult instanceof Error) {
return Promise.reject(scrobbleResult);
} else if (scrobbleResult.length > 0) {
checkCount = 0;
for (const t of scrobbleResult) {
emitter.emit('spotifyTrackDiscovered', t);
}
}
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
// 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);
}
}
// sleep for interval
logger.debug(`Sleeping for ${sleepTime}s`);
yield sleep(sleepTime * 1000);
}
};
+155
View File
@@ -0,0 +1,155 @@
import AbstractSource from "./AbstractSource.js";
import request from 'superagent';
import crypto from 'crypto';
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import {buildTrackString, parseRetryAfterSecsFromObj, sleep} from "../utils.js";
dayjs.extend(isSameOrAfter);
export class SubsonicSource extends AbstractSource {
constructor(name, config = {}, clients = []) {
super('subsonic', name, config, clients);
const {user, password, url} = this.config;
if (user === undefined) {
throw new Error(`Cannot setup Subsonic source, 'user' is not defined`);
}
if (password === undefined) {
throw new Error(`Cannot setup Subsonic source, 'password' is not defined`);
}
if (url === undefined) {
throw new Error(`Cannot setup Subsonic source, 'url' is not defined`);
}
this.canPoll = true;
}
static formatPlayObj(obj, newFromSource = false) {
const {
id,
title,
album,
artist,
duration, // seconds
minutesAgo,
} = obj;
return {
data: {
artists: [artist],
album,
track: title,
duration,
// subsonic doesn't return an exact datetime, only how many whole minutes ago it was played
// so we need to force the time to be 0 seconds always so that when we compare against scrobbles from client the time isn't off
playDate: minutesAgo === 0 ? dayjs().startOf('minute') : dayjs().startOf('minute').subtract(minutesAgo, 'minute'),
},
meta: {
trackLength: duration,
source: 'Subsonic',
sourceId: id,
newFromSource,
}
}
}
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, 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')
req.query({
u: user,
t: hash,
s: salt,
v: '1.15.0',
c: `multi-scrobbler - ${this.name}`,
f: 'json'
});
try {
const resp = await req;
const {
body: {
"subsonic-response": {
status,
},
"subsonic-response": ssResp = {}
} = {}
} = resp;
if (status === 'failed') {
const err = new Error('Subsonic API returned an error');
err.response = resp;
throw err;
}
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: {
status,
body: {
"subsonic-response": {
status: ssStatus,
error: {
code,
message: ssMessage,
} = {},
} = {},
"subsonic-response": ssResp
} = {},
text,
} = {},
response,
} = e;
let msg = response !== undefined ? `API Call failed: Server Response => ${ssMessage}` : `API Call failed: ${message}`;
const responseMeta = ssResp ?? text;
this.logger.error(msg, {status, response: responseMeta});
throw e;
}
}
testConnection = async () => {
const {url} = this.config;
try {
await this.callApi(request.get(`${url}/rest/ping`));
this.logger.info('Subsonic API Status: ok');
} catch (e) {
this.logger.error(e);
}
}
getRecentlyPlayed = async (options = {}) => {
const {formatted = false} = options;
const {url} = this.config;
const resp = await this.callApi(request.get(`${url}/rest/getNowPlaying`));
const {
nowPlaying: {
entry = []
} = {}
} = resp;
return entry.map(x => formatted ? SubsonicSource.formatPlayObj(x) : x)
}
}
+61 -47
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;
@@ -261,55 +263,67 @@ export const playObjDataMatch = (a, b) => {
return true;
}
/*
* Code below this comes from https://github.com/samthor/promises
* I'm not using the package because the package type isn't module or something
* */
export const parseRetryAfterSecsFromObj = (err) => {
const noop = () => {
};
let raVal;
export function resolvable() {
let resolve;
const promise = new Promise((r) => resolve = r);
return {promise, resolve};
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;
}
async function toPromise(t) {
return t;
}
export const takeoverSymbol = Object.seal({});
const abortSymbol = Object.seal({}); // distinct from takeoverSymbol so folks can't return it
export function makeSingle(generator) {
let previousPromise;
let previousResolve = noop;
return async function (...args) {
previousResolve(abortSymbol);
({promise: previousPromise, resolve: previousResolve} = resolvable());
const localSinglePromise = previousPromise;
const iter = generator(...args);
let resumeValue;
for (; ;) {
const n = iter.next(resumeValue);
if (n.done) {
return n.value; // final return value of passed generator
}
// whatever the generator yielded, _now_ run await on it
try {
resumeValue = await Promise.race([toPromise(n.value), localSinglePromise]);
if (resumeValue === abortSymbol) {
return takeoverSymbol;
}
} catch (e) {
resumeValue = e;
}
// next loop, we give resumeValue back to the generator
}
};
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;
}
@@ -13,8 +13,10 @@
</style>
</head>
<body>
<h2>Spotify Recently Played (<%= name %>)</h2>
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). ¯\_(ツ)_/¯
<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>
+22 -10
View File
@@ -33,21 +33,33 @@
<h3><%= source.display %> - <%= source.name %></h3>
<ul>
<li><b>Status: <%= source.status %></b></li>
<li>Tracks Discovered (since app started): <%= source.discoveredTracks %></li>
<% if (source.type === 'spotify' && source.authed) { %>
<li>Click <a target="_blank" href="spotify/recent?name=<%= source.name %>">to see recently played tracks returned by Spotify</a></li>
<li>Click <a target="_blank" href="authSpotify?name=<%= source.name %>">to re-authenticate and restart polling</a></li>
<li>Click <a target="_blank" href="pollSpotify?name=<%= source.name %>">to restart polling</a></li>
<% } %>
<% if (source.type === 'spotify' && !source.authed) { %>
<li>Click <a class="disabled" target="_blank">to see recently played tracks returned by Spotify</a></li>
<li>Click <a target="_blank" href="authSpotify?name=<%= source.name %>">to re-authenticate and restart polling</a></li>
<li>Click <a class="disabled" target="_blank">to restart polling</a></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>
</div>
<% }) %>
</div>
<h2>Log (Most Recent)</h2>
<ul>