mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b31285f89 | ||
|
|
89db858289 | ||
|
|
2fec6aff6e | ||
|
|
9beadfaf0f | ||
|
|
dd7e971e71 | ||
|
|
1aee74bfc1 | ||
|
|
123c171d07 | ||
|
|
764b490cfd | ||
|
|
aec3398edb | ||
|
|
e552820b4d | ||
|
|
1bc8edb07c | ||
|
|
085e61db5c | ||
|
|
1df6a8d6f7 | ||
|
|
d0e1e83ebd | ||
|
|
e0a2ada59b | ||
|
|
0508551dc7 | ||
|
|
f4a310cda9 | ||
|
|
6ac8938644 | ||
|
|
f41b3eea55 | ||
|
|
db34d92329 | ||
|
|
a60a0f89ad | ||
|
|
1de9c5cdc1 | ||
|
|
299e37e32a | ||
|
|
4921f963a8 |
@@ -4,9 +4,15 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"clientDefaults": {
|
||||
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
|
||||
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "spotify", // required, source type
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -4,6 +4,7 @@
|
||||
"data": {
|
||||
"url": "https://domain.tld", // required, the base url of your maloja installation
|
||||
"apiKey": "string" // required, your maloja api key
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"accessToken": "string", // spotify access token -- required if not providing client id/secret
|
||||
"refreshToken": "string", // spotify refresh token -- recommended to provide if not providing client id/secret
|
||||
"redirectUri": "http://localhost:9078/callback",// spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
|
||||
"interval": 60 // optional, how long to wait before calling spotify for new tracks
|
||||
"interval": 60, // optional, how long to wait before calling spotify for new tracks
|
||||
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Generated
+17
-12
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user