Added scrobbling functionality. Changes all around to support scrobbling, such as named trackers and configuration changes.
This commit is contained in:
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
43
services/scrobblers/maloja-scrobbler.js
Normal file
43
services/scrobblers/maloja-scrobbler.js
Normal file
@ -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;
|
@ -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)
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
Reference in New Issue
Block a user