mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd7e971e71 | ||
|
|
1aee74bfc1 | ||
|
|
123c171d07 | ||
|
|
764b490cfd | ||
|
|
aec3398edb | ||
|
|
e552820b4d | ||
|
|
1bc8edb07c | ||
|
|
085e61db5c | ||
|
|
1df6a8d6f7 | ||
|
|
d0e1e83ebd | ||
|
|
e0a2ada59b |
@@ -4,12 +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 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
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ export default class AbstractScrobbleClient {
|
||||
|
||||
name;
|
||||
type;
|
||||
initialized = false;
|
||||
|
||||
recentScrobbles = [];
|
||||
scrobbledPlayObjs = [];
|
||||
newestScrobbleTime;
|
||||
oldestScrobbleTime = dayjs();
|
||||
tracksScrobbled = 0;
|
||||
|
||||
lastScrobbleCheck = dayjs();
|
||||
refreshEnabled;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user