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;