diff --git a/app.js b/app.js index 7d31a9f..55dedd2 100644 --- a/app.js +++ b/app.js @@ -7,12 +7,14 @@ const sessions = require("./services/session-manager"); const PlexTracker = require("./services/trackers/plex-tracker"); const SpotifyTracker = require("./services/trackers/spotify-tracker"); const Recorder = require("./services/recorder"); +const MalojaScrobbler = require("./services/scrobblers/maloja-scrobbler"); +const maloja = new MalojaScrobbler(config.maloja); const spotify = new SpotifyTracker(config.spotify); (async () => await spotify.loadCredentials())(); const plex = new PlexTracker(config.plex); -const recorder = new Recorder(sessions, [plex, spotify], config.scrobble, logger); +const recorder = new Recorder(sessions, [plex, spotify], [maloja], config.scrobble, logger); setInterval(() => recorder.record(), 5000); diff --git a/config/config.schema.js b/config/config.schema.js index 6ab4c25..090ecd0 100644 --- a/config/config.schema.js +++ b/config/config.schema.js @@ -2,10 +2,28 @@ const schema = { type: 'object', required: [], properties: { + maloja: { + type: 'object', + required: ['name', 'url', 'token'], + properties: { + name: { + type: 'string' + }, + url: { + type: 'string' + }, + token: { + type: 'string' + }, + } + }, plex: { type: 'object', - required: ['url', 'token'], + required: ['name', 'url', 'token', 'scrobblers'], properties: { + name: { + type: 'string' + }, url: { type: 'string' }, @@ -55,6 +73,13 @@ const schema = { }, } } + }, + scrobblers: { + type: 'array', + items: { + type: 'string' + }, + minItems: 1 } } }, @@ -78,8 +103,11 @@ const schema = { spotify: { type: 'object', - required: ['client_id', 'client_secret', 'redirect_uri'], + required: ['name', 'client_id', 'client_secret', 'redirect_uri', 'scrobblers'], properties: { + name: { + type: 'string' + }, client_id: { type: 'string' }, @@ -88,6 +116,13 @@ const schema = { }, redirect_uri: { type: 'string' + }, + scrobblers: { + type: 'array', + items: { + type: 'string' + }, + minItems: 1 } } }, diff --git a/config/configuration.js b/config/configuration.js index 351250d..10788b6 100644 --- a/config/configuration.js +++ b/config/configuration.js @@ -5,9 +5,11 @@ const yaml = require("js-yaml"); const configurationBase = { plex: { + name: null, url: null, token: null, - filters: [] // { library, ip, deviceId, platform, product } + filters: [], // { library, ip, deviceId, platform, product } + scrobblers: [] }, scrobble: { minimum: { @@ -16,9 +18,11 @@ const configurationBase = { }, }, spotify: { + name: null, client_id: null, client_secret: null, - redirect_uri: null + redirect_uri: null, + scrobblers: [] }, web: { host: null, @@ -27,7 +31,6 @@ const configurationBase = { }; const configurationFile = yaml.load(fs.readFileSync('config/config.yml'), yaml.JSON_SCHEMA); -const configuration = { ...configurationBase, ...configurationFile } const ajv = new Ajv({ allErrors: true }); const schema = require("./config.schema"); @@ -40,4 +43,5 @@ if (!valid) { (async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })(); } +const configuration = { ...configurationBase, ...configurationFile } module.exports = configuration; \ No newline at end of file diff --git a/models/session.js b/models/session.js index 18d45b3..b02627c 100644 --- a/models/session.js +++ b/models/session.js @@ -3,7 +3,8 @@ class Session { #started = null; #current = null; #lastScrobble = null; - #pauseDuration = 0; + pauseDuration = 0; + playDuration = 0; constructor(id) { this.#id = id; @@ -33,14 +34,6 @@ class Session { set lastScrobbleTimestamp(value) { this.#lastScrobble = value; } - - get pauseDuration() { - return this.#pauseDuration; - } - - set pauseDuration(value) { - this.#pauseDuration = value; - } } module.exports = Session; \ No newline at end of file diff --git a/models/song.js b/models/song.js index d973294..ca276d3 100644 --- a/models/song.js +++ b/models/song.js @@ -9,8 +9,9 @@ class Song { session = null; state = null; source = null; + provider = null; - constructor(id, name, album, artists, year, duration, progress, session, state, source) { + constructor(id, name, album, artists, year, duration, progress, session, state, source, provider) { this.id = id; this.name = name; this.album = album; @@ -21,6 +22,7 @@ class Song { this.session = session; this.state = state; this.source = source; + this.provider = provider; } } diff --git a/services/recorder.js b/services/recorder.js index 5842e22..ea3f6eb 100644 --- a/services/recorder.js +++ b/services/recorder.js @@ -4,13 +4,17 @@ const Session = require("../models/session"); class Recorder { #sessions = null; #trackers = null; + #originalTrackers = []; + #scrobblers = []; #config = null; #logger = null; #lastTick = null; - constructor(sessions, trackers, config, logger) { + constructor(sessions, trackers, scrobblers, config, logger) { this.#sessions = sessions; - this.#trackers = new AggregateTracker(trackers); + this.#trackers = new AggregateTracker('aggregate', trackers); + this.#originalTrackers = trackers; + this.#scrobblers = scrobblers; this.#config = config; this.#logger = logger; this.#lastTick = Date.now(); @@ -20,48 +24,59 @@ class Recorder { const now = Date.now(); const timeDiff = now - this.#lastTick; const media = await this.#trackers.poll(); - const data = media.map(m => this.#fetchSession(m)); + const contexts = media.map(m => this.#fetchContext(m)); // Find sessions that ended and that are deemable of a scrobble. const sessionIds = this.#sessions.getSessionIds(); - const stopped = sessionIds.filter(sessionId => !data.some(d => sessionId == d.session.id)).map(s => this.#sessions.get(s)); - const sessionEnded = stopped.filter(s => this.#canScrobble(s, null, s.playing, now)); + const stopped = sessionIds.filter(sessionId => !contexts.some(context => sessionId == context.session.id)) + .map(sessionId => this.#sessions.get(sessionId)) + .map(session => this.#fetchContext(session.playing)); + const contextEnded = stopped.filter(context => this.#canScrobble(context.session, null, context.session.playing, now)); + + for (let context of contextEnded) + context.session.playDuration = now - (context.session.lastScrobbleTimestamp || context.session.started) - context.session.pauseDuration; // Find ongoing sessions that have moved on to the next song. - const finishedPlaying = data.filter(d => this.#listen(d.session, d.media, d.session.playing, now, timeDiff)); + const finishedPlaying = contexts.filter(context => this.#listen(context, now, timeDiff)); - const scrobbling = finishedPlaying.concat(sessionEnded); - for (let track of scrobbling) - this.#scrobble(track); + // Scrobble + const scrobbling = finishedPlaying.concat(contextEnded); + for (let context of scrobbling) + await this.#scrobble(context); // Remove dead sessions. - for (let sessionId of stopped) - this.#sessions.remove(sessionId); + for (let context of stopped) { + this.#sessions.remove(context.session.id); + } this.#lastTick = now; } - #fetchSession(media) { + #fetchContext(media) { + const tracker = this.#originalTrackers.find(t => t.provider == media.provider && t.name == media.source); let session = this.#sessions.get(media.session); if (session == null) { session = new Session(media.session); this.#sessions.add(session); } - return { session: session, media: media } + return { session, media, tracker } } - #listen(session, current, previous, timestamp, timeDiff) { + #listen(context, timestamp, timeDiff) { + const session = context.session; + const current = context.media; + const previous = context.session.playing; session.playing = current; + session.playDuration = timestamp - (session.lastScrobbleTimestamp || session.started) - session.pauseDuration; - if (previous == null) { + if (!previous) { this.#logger.info(current, "A new session has started."); return false; } - if (session.playing.state == "paused" || previous.state == "paused") { + if (session.playing.state == "paused" || previous.state == "paused") session.pauseDuration += timeDiff - (session.playing.progress - previous.progress); - } if (this.#canScrobble(session, current, previous, timestamp)) { session.pauseDuration = 0; @@ -84,15 +99,28 @@ class Recorder { const scrobbleDuration = this.#config.minimum.duration || 240; const scrobblePercent = this.#config.minimum.percent || 50; - const durationPlayed = timestamp - (session.lastScrobbleTimestamp || session.started) - session.pauseDuration; const newPlayback = current == null || current.progress < previous.progress; - const canBeScrobbled = durationPlayed > scrobbleDuration * 1000 || durationPlayed / previous.duration > scrobblePercent / 100.0; + const canBeScrobbled = session.playDuration > scrobbleDuration * 1000 || session.playDuration / previous.duration > scrobblePercent / 100.0; return newPlayback && canBeScrobbled; } - #scrobble(media) { - this.#logger.info(media, "Scrobble"); + async #scrobble(context) { + this.#logger.info(context, "Scrobble"); + + for (var scrobblerName of context.tracker.scrobblerNames) { + const scrobbler = this.#scrobblers.find(s => s.name == scrobblerName); + if (scrobbler == null) { + this.#logger.error(`Cannot find scrobbler by name of '${scrobblerName}'.`); + continue; + } + + try { + await scrobbler.scrobble(context.media, Date.now() - Math.min(context.media.duration, context.session.playDuration)); + } catch (ex) { + this.#logger.error(ex, "Could not send to maloja."); + } + } } } diff --git a/services/scrobblers/maloja-scrobbler.js b/services/scrobblers/maloja-scrobbler.js new file mode 100644 index 0000000..24c9675 --- /dev/null +++ b/services/scrobblers/maloja-scrobbler.js @@ -0,0 +1,43 @@ +const axios = require("axios"); + +class MalojaScrobbler { + #config = null; + #counter = 0; + + constructor(config) { + this.#config = config; + + if (!config.name) + throw new Error("Invalid name for Maloja scrobber."); + if (!config.url) + throw new Error(`Invalid url for Maloja scrobbler '${this.name}'.`); + if (!config.token) + throw new Error(`Invalid token for Maloja scrobbler '${this.name}'.`) + } + + get counter() { + return this.#counter; + } + + get name() { + return this.#config.name; + } + + async scrobble(song, progress, start) { + const url = new URL(this.#config.url); + url.pathname += "/apis/mlj_1/newscrobble"; + url.search = "?key=" + this.#config.token; + await axios.post(url.toString(), { + title: song.name, + album: song.album, + artists: song.artists, + duration: Math.round(progress / 1000), + length: Math.round(song.duration / 1000), + //time: start + }); + + this.#counter++; + } +} + +module.exports = MalojaScrobbler; \ No newline at end of file diff --git a/services/trackers/aggregate-tracker.js b/services/trackers/aggregate-tracker.js index ef479f1..2641143 100644 --- a/services/trackers/aggregate-tracker.js +++ b/services/trackers/aggregate-tracker.js @@ -1,10 +1,23 @@ class AggregateTracker { + #name = null; #trackers = [] + provider = null; - constructor(trackers) { + constructor(name, trackers) { + this.#name = name; this.#trackers = trackers; } + get name() { + return this.#name; + } + + get scrobblerNames() { + return this.#trackers.map(t => t.scrobblerNames) + .flat() + .filter((v, i, a) => a.indexOf(v) == i); + } + async poll() { let media = [] for (let tracker of this.#trackers) diff --git a/services/trackers/plex-tracker.js b/services/trackers/plex-tracker.js index 2041acf..5b8aab4 100644 --- a/services/trackers/plex-tracker.js +++ b/services/trackers/plex-tracker.js @@ -4,11 +4,20 @@ const Song = require("../../models/song"); class PlexTracker { #config = null; #cache = []; + provider = "plex"; constructor(config) { this.#config = config; } + get name() { + return this.#config.name; + } + + get scrobblerNames() { + return this.#config.scrobblers; + } + async poll(useCache = false) { if (!this.#config.token || !this.#config.url) return []; @@ -28,7 +37,7 @@ class PlexTracker { } const filtered = response.data.MediaContainer?.Metadata.filter(m => this.#filter(m)); - this.#cache = filtered.map(m => this.#transform(m)); + this.#cache = filtered.map(m => this.#transform(m, this.#config.name)); return this.#cache; } @@ -52,9 +61,10 @@ class PlexTracker { return false; } - #transform(data) { + #transform(data, source) { const id = data.guid.substring(data.guid.lastIndexOf('/') + 1); - return new Song(id, data.title, data.parentTitle, data.grandparentTitle, data.parentYear, data.duration, data.viewOffset, data.sessionKey, data.Player.state, "plex"); + const artists = data.grandparentTitle.split(',').map(a => a.trim()); + return new Song(id, data.title, data.parentTitle, artists, data.parentYear, data.duration, data.viewOffset, data.sessionKey, data.Player.state, source, "plex"); } } diff --git a/services/trackers/spotify-tracker.js b/services/trackers/spotify-tracker.js index db52a6e..144985b 100644 --- a/services/trackers/spotify-tracker.js +++ b/services/trackers/spotify-tracker.js @@ -7,16 +7,24 @@ const Song = require("../../models/song"); class SpotifyTracker { #config = null; #token = null; - #cache = null; + #cache = []; #auth = null; + provider = "spotify"; constructor(config, token = null) { this.#config = config; this.#token = token; - this.#cache = null; this.#auth = new Buffer.from(config.client_id + ':' + config.client_secret).toString('base64'); } + get name() { + return this.#config.name; + } + + get scrobblerNames() { + return this.#config.scrobblers; + } + async poll(useCache = false) { if (this.#token == null) return []; @@ -40,7 +48,7 @@ class SpotifyTracker { return this.#cache; } - this.#cache = [this.#transform(response.data)]; + this.#cache = [this.#transform(response.data, this.#config.name)]; return this.#cache; } catch (ex) { logger.error(ex, "Failed to get currently playing data from Spotify."); @@ -58,12 +66,12 @@ class SpotifyTracker { this.#token = JSON.parse(content); } - #transform(data) { + #transform(data, source) { const item = data.item; const artists = item.artists.map(a => a.name); const year = null; const state = data.is_playing ? "playing" : "paused"; - return new Song(item.id, item.name, item.album.name, artists, year, item.duration_ms, data.progress_ms, "spotify", state, "spotify"); + return new Song(item.id, item.name, item.album.name, artists, year, item.duration_ms, data.progress_ms, "spotify", state, source, "spotify"); } async #refreshTokenIfNeeded() {