apollo/services/recorder.js

127 lines
4.4 KiB
JavaScript

const AggregateTracker = require("./trackers/aggregate-tracker");
const Session = require("../models/session");
class Recorder {
#sessions = null;
#trackers = null;
#originalTrackers = [];
#scrobblers = [];
#config = null;
#logger = null;
#lastTick = null;
constructor(sessions, trackers, scrobblers, config, logger) {
this.#sessions = sessions;
this.#trackers = new AggregateTracker('aggregate', trackers);
this.#originalTrackers = trackers;
this.#scrobblers = scrobblers;
this.#config = config;
this.#logger = logger;
this.#lastTick = Date.now();
}
async record() {
const now = Date.now();
const timeDiff = now - this.#lastTick;
const media = await this.#trackers.poll();
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 => !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 = contexts.filter(context => this.#listen(context, now, timeDiff));
// Scrobble
const scrobbling = finishedPlaying.concat(contextEnded);
for (let context of scrobbling)
await this.#scrobble(context);
// Remove dead sessions.
for (let context of stopped) {
this.#sessions.remove(context.session.id);
}
this.#lastTick = now;
}
#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, media, tracker }
}
#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) {
this.#logger.info(current, "A new session has started.");
return false;
}
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;
session.lastScrobbleTimestamp = timestamp - (timeDiff - (previous.duration - previous.progress));
return true;
} else if (current.progress < previous.progress && session.playing.id != previous.id) {
session.pauseDuration = 0;
if (current.progress < timeDiff)
session.lastScrobbleTimestamp = timestamp - session.playing.progress;
else
session.lastScrobbleTimestamp = timestamp;
}
return false;
}
#canScrobble(session, current, previous, timestamp) {
if (previous == null)
return false;
const scrobbleDuration = this.#config.minimum.duration || 240;
const scrobblePercent = this.#config.minimum.percent || 50;
const newPlayback = current == null || current.progress < previous.progress;
const canBeScrobbled = session.playDuration > scrobbleDuration * 1000 || session.playDuration / previous.duration > scrobblePercent / 100.0;
return newPlayback && canBeScrobbled;
}
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.");
}
}
}
}
module.exports = Recorder;