mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
247 lines
9.6 KiB
JavaScript
247 lines
9.6 KiB
JavaScript
import dayjs from "dayjs";
|
|
import {EventEmitter} from "events";
|
|
import {buildTrackString, readJson, sleep, writeFile, makeSingle} from "../utils.js";
|
|
import SpotifyWebApi from "spotify-web-api-node";
|
|
|
|
export default class SpotifySource {
|
|
|
|
logger;
|
|
spotifyApi;
|
|
interval;
|
|
localUrl;
|
|
workingCredsPath;
|
|
configDir;
|
|
|
|
spotifyPoller;
|
|
pollerRunning = false;
|
|
|
|
emitter;
|
|
discoveredTracks = 0;
|
|
|
|
constructor(logger, config = {}) {
|
|
this.logger = logger;
|
|
const {
|
|
localUrl,
|
|
configDir,
|
|
} = config;
|
|
|
|
this.configDir = configDir;
|
|
this.workingCredsPath = `${configDir}/currentCreds.json`;
|
|
this.localUrl = localUrl;
|
|
this.spotifyPoller = makeSingle(pollSpotify);
|
|
this.emitter = new EventEmitter();
|
|
this.emitter.addListener('spotifyTrackDiscovered', this.handleDiscoveredTrack);
|
|
}
|
|
|
|
static formatPlayObj(obj) {
|
|
const {
|
|
track: {
|
|
artists = [],
|
|
name,
|
|
id,
|
|
duration_ms,
|
|
} = {},
|
|
played_at
|
|
} = obj;
|
|
let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(',');
|
|
return {
|
|
data: {
|
|
artist: artistString,
|
|
track: name,
|
|
playDate: dayjs(played_at),
|
|
},
|
|
meta: {
|
|
trackLength: duration_ms / 1000,
|
|
}
|
|
}
|
|
}
|
|
|
|
handleDiscoveredTrack = (e) => {
|
|
this.discoveredTracks++;
|
|
}
|
|
|
|
buildSpotifyApi = async (spotifyObj) => {
|
|
|
|
let spotifyCreds = {};
|
|
try {
|
|
spotifyCreds = await readJson(this.workingCredsPath);
|
|
} catch (e) {
|
|
this.logger.warn('Current spotify access token was not parsable or file does not exist (this could be normal)');
|
|
}
|
|
|
|
let spotifyConfig = spotifyObj;
|
|
if (spotifyObj === undefined) {
|
|
try {
|
|
spotifyConfig = await readJson(`${this.configDir}/spotify.json`);
|
|
} catch (e) {
|
|
this.logger.warn('No spotify config file or could not be read (normal if using ENV vars only)');
|
|
}
|
|
}
|
|
|
|
const {
|
|
accessToken = process.env.SPOTIFY_ACCESS_TOKEN,
|
|
clientId = process.env.SPOTIFY_CLIENT_ID,
|
|
clientSecret = process.env.SPOTIFY_CLIENT_SECRET,
|
|
redirectUri = process.env.SPOTIFY_REDIRECT_URI,
|
|
refreshToken = process.env.SPOTIFY_REFRESH_TOKEN,
|
|
interval = 60,
|
|
} = spotifyConfig || {};
|
|
|
|
if (interval < 15) {
|
|
console.warn('Interval should be above 30 seconds...😬');
|
|
}
|
|
this.interval = interval;
|
|
|
|
const rdUri = redirectUri || `${this.localUrl}/callback`;
|
|
|
|
|
|
const {token = accessToken, refreshToken: rt = refreshToken} = spotifyCreds;
|
|
|
|
const apiConfig = {
|
|
clientId,
|
|
clientSecret,
|
|
accessToken: token,
|
|
redirectUri: rdUri,
|
|
refreshToken: rt,
|
|
}
|
|
|
|
if (Object.values(apiConfig).every(x => x === undefined)) {
|
|
this.logger.info('No values found for Spotify configuration, assuming user does not want to set it up', {label: 'Spotify'});
|
|
} else {
|
|
if (token === undefined) {
|
|
let ready = true;
|
|
if (clientId === undefined) {
|
|
this.logger.warn('No access token exists and clientId is not defined', {label: 'Spotify'});
|
|
ready = false;
|
|
}
|
|
if (clientSecret === undefined) {
|
|
this.logger.warn('No access token exists and clientSecret is not defined', {label: 'Spotify'})
|
|
ready = false;
|
|
}
|
|
if (ready === false) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.spotifyApi = new SpotifyWebApi(apiConfig);
|
|
}
|
|
}
|
|
|
|
pollSpotify = (clients) => {
|
|
if (this.spotifyApi === undefined) {
|
|
this.logger.warn('Cannot poll spotify without valid credentials configuration', {label: 'Spotify'})
|
|
return;
|
|
}
|
|
this.pollerRunning = true;
|
|
return this.spotifyPoller(this.logger, this.spotifyApi, this.interval, this.workingCredsPath, clients, this.emitter)
|
|
.catch((e) => {
|
|
this.logger.error('Error occurred while polling spotify, polling has been stopped', {label: 'Spotify'});
|
|
this.logger.error(e, {label: 'Spotify'});
|
|
})
|
|
.finally(() => {
|
|
this.pollerRunning = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
const pollSpotify = function* (logger, spotifyApi, interval = 60, credsPath, clients, emitter) {
|
|
logger.info('Starting spotify polling', {label: 'Spotify'});
|
|
let lastTrackPlayedAt = undefined;
|
|
let checkCount = 0;
|
|
while (true) {
|
|
let data = {};
|
|
logger.debug('Refreshing recently played', {label: 'Spotify'})
|
|
data = yield spotifyApi.getMyRecentlyPlayedTracks({
|
|
limit: 20
|
|
});
|
|
if (data instanceof Error) {
|
|
if (data.statusCode === 401) {
|
|
if (spotifyApi.getRefreshToken() === undefined) {
|
|
logger.error('Access token was not valid and no refresh token was present, bailing out of polling', {label: 'Spotify'});
|
|
return Promise.resolve();
|
|
}
|
|
logger.debug('Access token was not valid, attempting to refresh', {label: 'Spotify'});
|
|
|
|
const tokenResponse = yield spotifyApi.refreshAccessToken();
|
|
const {
|
|
body: {
|
|
access_token,
|
|
// spotify may return a new refresh token
|
|
// if it doesn't then continue to use the last refresh token we received
|
|
refresh_token = spotifyApi.getRefreshToken(),
|
|
} = {}
|
|
} = tokenResponse;
|
|
spotifyApi.setAccessToken(access_token);
|
|
yield writeFile(credsPath, JSON.stringify({
|
|
token: access_token,
|
|
refreshToken: refresh_token,
|
|
}));
|
|
data = yield spotifyApi.getMyRecentlyPlayedTracks({
|
|
limit: 20
|
|
});
|
|
if (data instanceof Error) {
|
|
logger.error('Refreshing access token encountered an error', {label: 'Spotify'});
|
|
logger.error(data, {label: 'Spotify'});
|
|
return Promise.resolve(data);
|
|
}
|
|
} else {
|
|
logger.error('Refreshing access token encountered an error', {label: 'Spotify'});
|
|
logger.error(data, {label: 'Spotify'});
|
|
return Promise.reject(data);
|
|
}
|
|
}
|
|
checkCount++;
|
|
let newLastPLayedAt = undefined;
|
|
const now = dayjs();
|
|
for (const playData of data.body.items) {
|
|
const playObj = SpotifySource.formatPlayObj(playData);
|
|
const {data: {playDate} = {}} = playObj;
|
|
if (lastTrackPlayedAt === undefined) {
|
|
lastTrackPlayedAt = playDate;
|
|
}
|
|
// compare play time to most recent track played_at scrobble
|
|
if (playDate.unix() > lastTrackPlayedAt.unix()) {
|
|
logger.info(`New Track => ${buildTrackString(playObj)}`, {label: 'Spotify'});
|
|
emitter.emit('spotifyTrackDiscovered', playObj);
|
|
// so we always get just the most recent played_at
|
|
if (newLastPLayedAt === undefined) {
|
|
newLastPLayedAt = playDate;
|
|
}
|
|
const closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5;
|
|
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', {label: 'Spotify'});
|
|
yield sleep(10 * 1000);
|
|
}
|
|
const scrobbleResult = yield clients.scrobble(playObj, {forceRefresh: closeToInterval});
|
|
if (scrobbleResult instanceof Error) {
|
|
return Promise.reject(scrobbleResult);
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
if (newLastPLayedAt !== undefined) {
|
|
lastTrackPlayedAt = newLastPLayedAt;
|
|
}
|
|
}
|
|
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 interval (${sleepTime}s)`, {label: 'Spotify'});
|
|
yield sleep(sleepTime * 1000);
|
|
}
|
|
};
|