Compare commits

...
11 Commits
Author SHA1 Message Date
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
12 changed files with 646 additions and 51 deletions
+6 -1
View File
@@ -4,12 +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 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)
@@ -20,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
+2
View File
@@ -5,11 +5,13 @@ export default class AbstractScrobbleClient {
name;
type;
initialized = false;
recentScrobbles = [];
scrobbledPlayObjs = [];
newestScrobbleTime;
oldestScrobbleTime = dayjs();
tracksScrobbled = 0;
lastScrobbleCheck = dayjs();
refreshEnabled;
+393
View File
@@ -0,0 +1,393 @@
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import dayjs from 'dayjs';
import LastFm from 'lastfm-node-client';
import {
buildTrackString,
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, tries = 1) => {
try {
return await func(this.client);
} catch (e) {
const {
message,
} = e;
// for now check for exceptional errors by matching error code text
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
if(undefined !== retryError) {
if(tries <= 2) {
const delay = tries * 3;
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
await sleep(delay * 1000);
return this.callApi(func, tries + 1);
} else {
this.logger.warn('Could not recover!');
throw e;
}
}
throw e;
}
}
getAuthUrl = () => {
const redir = `${this.config.redirectUri}?state=${this.name}`;
return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}`
}
authenticate = async (token) => {
const sessionRes = await this.client.authGetSession({token});
const {
session: {
key: sessionKey,
name, // username
} = {}
} = sessionRes;
this.client.sessionKey = sessionKey;
await writeFile(this.workingCredsPath, JSON.stringify({
sessionKey,
}));
}
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;
}
}
+2 -1
View File
@@ -106,13 +106,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');
+76 -20
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,12 +26,12 @@ 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');
}
@@ -56,12 +65,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`);
}
@@ -158,6 +184,17 @@ ${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;
}
@@ -188,25 +225,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;
}
+13
View File
@@ -0,0 +1,13 @@
[
{
"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
}
}
]
+23
View File
@@ -157,3 +157,26 @@ See [`subsonic.json.example`](../config/subsonic.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)
+24
View File
@@ -6,6 +6,7 @@ Scenario:
* 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
@@ -106,6 +107,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",
}
}
]
}
@@ -202,3 +211,18 @@ In `CONFIG_DIR/maloja.json`:
}
]
```
In `CONFIG_DIR/lastfm.json`:
```json5
[
{
"name": "maryLFM",
"data": {
"apiKey": "maryApiKey",
"secret": "marySecret",
}
}
]
```
+85 -28
View File
@@ -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',
@@ -152,7 +152,7 @@ app.use(bodyParser.json());
const sourceData = scrobbleSources.sources.map((x) => {
const {type, tracksDiscovered = 0, name, canPoll = false, polling = false} = x;
const base = {type, display: capitalize(type), tracksDiscovered, name, canPoll, hasAuth: false};
if(canPoll) {
if (canPoll) {
base.status = polling ? 'Running' : 'Idle';
} else {
base.status = tracksDiscovered > 0 ? 'Received Data' : 'Awaiting Data'
@@ -164,7 +164,30 @@ app.use(bodyParser.json());
...base,
hasAuth: true,
authed,
status: authed ? base.status : 'Auth Interaction Required' ,
status: authed ? base.status : 'Auth Interaction Required',
}
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,
hasAuth: true,
authed,
status: authed ? base.status : 'Auth Interaction Required',
}
default:
return base;
@@ -172,6 +195,7 @@ app.use(bodyParser.json());
})
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(' | '),
@@ -225,22 +249,39 @@ app.use(bodyParser.json());
res.send('OK');
});
app.use('/auth', sourceCheckMiddle);
app.getAsync('/auth', 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})`);
}
});
@@ -315,27 +356,43 @@ 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.poll(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 source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
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) {
switch (source.type) {
case 'spotify':
if (source.spotifyApi !== undefined) {
if (source.spotifyApi.getAccessToken() === undefined) {
@@ -346,7 +403,7 @@ app.use(bodyParser.json());
}
break;
default:
if(source.poll !== undefined) {
if (source.poll !== undefined) {
source.poll(scrobbleClients);
}
}
+5
View File
@@ -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",
+1
View File
@@ -27,6 +27,7 @@
"dayjs": "^1.9.6",
"ejs": "^3.1.5",
"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",
+16 -1
View File
@@ -37,7 +37,7 @@
<% 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="auth?name=<%= source.name %>&type=<%= source.type %>">to (re)authenticate and (re)start polling</a></li>
<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>
<% } %>
@@ -45,6 +45,21 @@
</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>