Compare commits

..

No commits in common. "0bfbc369520ba3b97ab008c8cb07289ce76fb323" and "e5e0853d039a5800fc08a283f834eeae1f60acd6" have entirely different histories.

11 changed files with 92 additions and 227 deletions

8
app.js
View File

@ -4,17 +4,15 @@ const helmet = require("helmet");
const logger = require("./services/logging"); const logger = require("./services/logging");
const rateLimit = require("express-rate-limit"); const rateLimit = require("express-rate-limit");
const sessions = require("./services/session-manager"); const sessions = require("./services/session-manager");
const PlexTracker = require("./services/trackers/plex-tracker"); const PlexTracker = require("./services/trackers/PlexTracker");
const SpotifyTracker = require("./services/trackers/spotify-tracker"); const SpotifyTracker = require("./services/trackers/SpotifyTracker");
const Recorder = require("./services/recorder"); const Recorder = require("./services/recorder");
const MalojaScrobbler = require("./services/scrobblers/maloja-scrobbler");
const maloja = new MalojaScrobbler(config.maloja);
const spotify = new SpotifyTracker(config.spotify); const spotify = new SpotifyTracker(config.spotify);
(async () => await spotify.loadCredentials())(); (async () => await spotify.loadCredentials())();
const plex = new PlexTracker(config.plex); const plex = new PlexTracker(config.plex);
const recorder = new Recorder(sessions, [plex, spotify], [maloja], config.scrobble, logger); const recorder = new Recorder(sessions, [plex, spotify], config.scrobble, logger);
setInterval(() => recorder.record(), 5000); setInterval(() => recorder.record(), 5000);

View File

@ -2,28 +2,10 @@ const schema = {
type: 'object', type: 'object',
required: [], required: [],
properties: { properties: {
maloja: {
type: 'object',
required: ['name', 'url', 'token'],
properties: {
name: {
type: 'string'
},
url: {
type: 'string'
},
token: {
type: 'string'
},
}
},
plex: { plex: {
type: 'object', type: 'object',
required: ['name', 'url', 'token', 'scrobblers'], required: ['url', 'token'],
properties: { properties: {
name: {
type: 'string'
},
url: { url: {
type: 'string' type: 'string'
}, },
@ -73,13 +55,6 @@ const schema = {
}, },
} }
} }
},
scrobblers: {
type: 'array',
items: {
type: 'string'
},
minItems: 1
} }
} }
}, },
@ -103,11 +78,8 @@ const schema = {
spotify: { spotify: {
type: 'object', type: 'object',
required: ['name', 'client_id', 'client_secret', 'redirect_uri', 'scrobblers'], required: ['client_id', 'client_secret', 'redirect_uri'],
properties: { properties: {
name: {
type: 'string'
},
client_id: { client_id: {
type: 'string' type: 'string'
}, },
@ -116,13 +88,6 @@ const schema = {
}, },
redirect_uri: { redirect_uri: {
type: 'string' type: 'string'
},
scrobblers: {
type: 'array',
items: {
type: 'string'
},
minItems: 1
} }
} }
}, },

View File

@ -5,11 +5,9 @@ const yaml = require("js-yaml");
const configurationBase = { const configurationBase = {
plex: { plex: {
name: null,
url: null, url: null,
token: null, token: null,
filters: [], // { library, ip, deviceId, platform, product } filters: [] // { library, ip, deviceId, platform, product }
scrobblers: []
}, },
scrobble: { scrobble: {
minimum: { minimum: {
@ -18,11 +16,9 @@ const configurationBase = {
}, },
}, },
spotify: { spotify: {
name: null,
client_id: null, client_id: null,
client_secret: null, client_secret: null,
redirect_uri: null, redirect_uri: null
scrobblers: []
}, },
web: { web: {
host: null, host: null,
@ -31,6 +27,7 @@ const configurationBase = {
}; };
const configurationFile = yaml.load(fs.readFileSync('config/config.yml'), yaml.JSON_SCHEMA); const configurationFile = yaml.load(fs.readFileSync('config/config.yml'), yaml.JSON_SCHEMA);
const configuration = { ...configurationBase, ...configurationFile }
const ajv = new Ajv({ allErrors: true }); const ajv = new Ajv({ allErrors: true });
const schema = require("./config.schema"); const schema = require("./config.schema");
@ -43,5 +40,4 @@ if (!valid) {
(async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })(); (async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })();
} }
const configuration = { ...configurationBase, ...configurationFile }
module.exports = configuration; module.exports = configuration;

View File

@ -2,10 +2,8 @@ class Session {
#id = null; #id = null;
#started = null; #started = null;
#current = null; #current = null;
lastScrobbleTimestamp = 0; #lastScrobble = null;
lastUpdateTimestamp = 0; #pauseDuration = 0;
pauseDuration = 0;
playDuration = 0;
constructor(id) { constructor(id) {
this.#id = id; this.#id = id;
@ -27,6 +25,22 @@ class Session {
get started() { get started() {
return this.#started; return this.#started;
} }
get lastScrobbleTimestamp() {
return this.#lastScrobble;
}
set lastScrobbleTimestamp(value) {
this.#lastScrobble = value;
}
get pauseDuration() {
return this.#pauseDuration;
}
set pauseDuration(value) {
this.#pauseDuration = value;
}
} }
module.exports = Session; module.exports = Session;

View File

@ -9,9 +9,8 @@ class Song {
session = null; session = null;
state = null; state = null;
source = null; source = null;
provider = null;
constructor(id, name, album, artists, year, duration, progress, session, state, source, provider) { constructor(id, name, album, artists, year, duration, progress, session, state, source) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.album = album; this.album = album;
@ -22,7 +21,6 @@ class Song {
this.session = session; this.session = session;
this.state = state; this.state = state;
this.source = source; this.source = source;
this.provider = provider;
} }
} }

View File

@ -1,130 +1,98 @@
const AggregateTracker = require("./trackers/aggregate-tracker"); const AggregateTracker = require("./trackers/AggregateTracker");
const Session = require("../models/session"); const Session = require("../models/session");
class Recorder { class Recorder {
#sessions = null; #sessions = null;
#trackers = null; #trackers = null;
#originalTrackers = [];
#scrobblers = [];
#config = null; #config = null;
#logger = null; #logger = null;
#lastTick = null;
constructor(sessions, trackers, scrobblers, config, logger) { constructor(sessions, trackers, config, logger) {
this.#sessions = sessions; this.#sessions = sessions;
this.#trackers = new AggregateTracker('aggregate', trackers); this.#trackers = new AggregateTracker(trackers);
this.#originalTrackers = trackers;
this.#scrobblers = scrobblers;
this.#config = config; this.#config = config;
this.#logger = logger; this.#logger = logger;
this.#lastTick = Date.now();
} }
async record() { async record() {
const now = Date.now(); const now = Date.now();
const timeDiff = now - this.#lastTick;
const media = await this.#trackers.poll(); const media = await this.#trackers.poll();
const contexts = media.map(m => this.#fetchContext(m)); const data = media.map(m => this.#fetchSession(m));
// Find sessions that ended and that are deemable of a scrobble. // Find sessions that ended and that are deemable of a scrobble.
const sessionIds = this.#sessions.getSessionIds(); const sessionIds = this.#sessions.getSessionIds();
const stopped = sessionIds.filter(sessionId => !contexts.some(context => sessionId == context.session.id)) const stopped = sessionIds.filter(sessionId => !data.some(d => sessionId == d.session.id)).map(s => this.#sessions.get(s));
.map(sessionId => this.#sessions.get(sessionId)) const sessionEnded = stopped.filter(s => this.#canScrobble(s, null, s.playing, now));
.map(session => this.#fetchContext(session.playing));
const contextEnded = stopped.filter(context => this.#canScrobble(context.session, null, context.session.playing));
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. // Find ongoing sessions that have moved on to the next song.
const finishedPlaying = contexts.filter(context => this.#listen(context, now)); const finishedPlaying = data.filter(d => this.#listen(d.session, d.media, d.session.playing, now, timeDiff));
// Scrobble const scrobbling = finishedPlaying.concat(sessionEnded);
const scrobbling = finishedPlaying.concat(contextEnded); for (let track of scrobbling)
for (let context of scrobbling) { this.#scrobble(track);
await this.#scrobble(context);
if (context.session.playing == null)
continue;
context.session.playDuration = context.extraDuration;
context.session.pauseDuration = 0;
}
// Remove dead sessions. // Remove dead sessions.
for (let context of stopped) { for (let sessionId of stopped)
this.#sessions.remove(context.session.id); this.#sessions.remove(sessionId);
}
this.#lastTick = now;
} }
#fetchContext(media) { #fetchSession(media) {
const tracker = this.#originalTrackers.find(t => t.provider == media.provider && t.name == media.source);
let session = this.#sessions.get(media.session); let session = this.#sessions.get(media.session);
if (session == null) { if (session == null) {
session = new Session(media.session); session = new Session(media.session);
this.#sessions.add(session); this.#sessions.add(session);
} }
return { session, media, tracker, extraDuration: 0 } return { session: session, media: media }
} }
#listen(context, timestamp) { #listen(session, current, previous, timestamp, timeDiff) {
const session = context.session;
const current = context.media;
const previous = context.session.playing;
session.playing = current; session.playing = current;
if (!previous) { if (previous == null) {
this.#logger.info(current, "A new session has started."); this.#logger.info(current, "A new session has started.");
session.lastUpdateTimestamp = timestamp;
return false; return false;
} }
const updated = current.progress != previous.progress || current.id != previous.id || current.state != previous.state; if (session.playing.state == "paused" || previous.state == "paused") {
if (!updated) session.pauseDuration += timeDiff - (session.playing.progress - previous.progress);
return false; }
const timeDiff = timestamp - session.lastUpdateTimestamp; if (this.#canScrobble(session, current, previous, timestamp)) {
const progressDiff = Math.max(0, Math.min(current.progress - previous.progress, timeDiff)); session.pauseDuration = 0;
session.playDuration += progressDiff; session.lastScrobbleTimestamp = timestamp - (timeDiff - (previous.duration - previous.progress));
session.pauseDuration += timeDiff - progressDiff; return true;
} else if (current.progress < previous.progress && session.playing.id != previous.id) {
const canScrobble = this.#canScrobble(session, current, previous); session.pauseDuration = 0;
if (canScrobble || current.id != previous.id) { if (current.progress < timeDiff)
context.extraDuration = Math.min(current.progress, timeDiff - (previous.duration - previous.progress)); session.lastScrobbleTimestamp = timestamp - session.playing.progress;
else
session.lastScrobbleTimestamp = timestamp; session.lastScrobbleTimestamp = timestamp;
} }
return false;
session.lastUpdateTimestamp = timestamp;
return canScrobble;
} }
#canScrobble(session, current, previous) { #canScrobble(session, current, previous, timestamp) {
if (previous == null) if (previous == null)
return false; return false;
const scrobbleDuration = this.#config.minimum.duration || 240; const scrobbleDuration = this.#config.minimum.duration || 240;
const scrobblePercent = this.#config.minimum.percent || 50; 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 newPlayback = current == null || current.progress < previous.progress;
const canBeScrobbled = session.playDuration > scrobbleDuration * 1000 || session.playDuration / previous.duration > scrobblePercent / 100.0; const canBeScrobbled = durationPlayed > scrobbleDuration * 1000 || durationPlayed / previous.duration > scrobblePercent / 100.0;
return newPlayback && canBeScrobbled; return newPlayback && canBeScrobbled;
} }
async #scrobble(context) { #scrobble(media) {
this.#logger.info(context, "Scrobble"); this.#logger.info(media, "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.");
}
}
} }
} }

View File

@ -1,43 +0,0 @@
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;

View File

@ -0,0 +1,17 @@
class AggregateTracker {
#trackers = []
constructor(trackers) {
this.#trackers = trackers;
}
async poll() {
let media = []
for (let tracker of this.#trackers)
media = media.concat(await tracker.poll());
return media;
}
}
module.exports = AggregateTracker;

View File

@ -4,20 +4,11 @@ const Song = require("../../models/song");
class PlexTracker { class PlexTracker {
#config = null; #config = null;
#cache = []; #cache = [];
provider = "plex";
constructor(config) { constructor(config) {
this.#config = config; this.#config = config;
} }
get name() {
return this.#config.name;
}
get scrobblerNames() {
return this.#config.scrobblers;
}
async poll(useCache = false) { async poll(useCache = false) {
if (!this.#config.token || !this.#config.url) if (!this.#config.token || !this.#config.url)
return []; return [];
@ -37,7 +28,7 @@ class PlexTracker {
} }
const filtered = response.data.MediaContainer?.Metadata.filter(m => this.#filter(m)); const filtered = response.data.MediaContainer?.Metadata.filter(m => this.#filter(m));
this.#cache = filtered.map(m => this.#transform(m, this.#config.name)); this.#cache = filtered.map(m => this.#transform(m));
return this.#cache; return this.#cache;
} }
@ -61,10 +52,9 @@ class PlexTracker {
return false; return false;
} }
#transform(data, source) { #transform(data) {
const id = data.guid.substring(data.guid.lastIndexOf('/') + 1); const id = data.guid.substring(data.guid.lastIndexOf('/') + 1);
const artists = data.grandparentTitle.split(',').map(a => a.trim()); return new Song(id, data.title, data.parentTitle, data.grandparentTitle, data.parentYear, data.duration, data.viewOffset, data.sessionKey, data.Player.state, "plex");
return new Song(id, data.title, data.parentTitle, artists, data.parentYear, data.duration, data.viewOffset, data.sessionKey, data.Player.state, source, "plex");
} }
} }

View File

@ -7,24 +7,16 @@ const Song = require("../../models/song");
class SpotifyTracker { class SpotifyTracker {
#config = null; #config = null;
#token = null; #token = null;
#cache = []; #cache = null;
#auth = null; #auth = null;
provider = "spotify";
constructor(config, token = null) { constructor(config, token = null) {
this.#config = config; this.#config = config;
this.#token = token; this.#token = token;
this.#cache = null;
this.#auth = new Buffer.from(config.client_id + ':' + config.client_secret).toString('base64'); 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) { async poll(useCache = false) {
if (this.#token == null) if (this.#token == null)
return []; return [];
@ -48,7 +40,7 @@ class SpotifyTracker {
return this.#cache; return this.#cache;
} }
this.#cache = [this.#transform(response.data, this.#config.name)]; this.#cache = [this.#transform(response.data)];
return this.#cache; return this.#cache;
} catch (ex) { } catch (ex) {
logger.error(ex, "Failed to get currently playing data from Spotify."); logger.error(ex, "Failed to get currently playing data from Spotify.");
@ -66,12 +58,12 @@ class SpotifyTracker {
this.#token = JSON.parse(content); this.#token = JSON.parse(content);
} }
#transform(data, source) { #transform(data) {
const item = data.item; const item = data.item;
const artists = item.artists.map(a => a.name); const artists = item.artists.map(a => a.name);
const year = null; const year = null;
const state = data.is_playing ? "playing" : "paused"; 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, source, "spotify"); return new Song(item.id, item.name, item.album.name, artists, year, item.duration_ms, data.progress_ms, "spotify", state, "spotify");
} }
async #refreshTokenIfNeeded() { async #refreshTokenIfNeeded() {

View File

@ -1,30 +0,0 @@
class AggregateTracker {
#name = null;
#trackers = []
provider = null;
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)
media = media.concat(await tracker.poll());
return media;
}
}
module.exports = AggregateTracker;