Compare commits

...

5 Commits

8 changed files with 330 additions and 143 deletions

14
app.js
View File

@ -3,11 +3,18 @@ const express = require('express');
const helmet = require("helmet"); 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 PlexTracker = require("./services/trackers/PlexTracker");
const SpotifyTracker = require("./services/trackers/SpotifyTracker");
const Recorder = require("./services/recorder");
const poll = require("./services/poll");
setInterval(poll, 5000);
const PORT = process.env.PORT || config.web.port || 9111; 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);
setInterval(() => recorder.record(), 5000);
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@ -27,6 +34,7 @@ const limiter = rateLimit({
app.use(helmet()); app.use(helmet());
app.use(limiter); app.use(limiter);
const PORT = process.env.PORT || config.web.port || 9111;
app.listen(PORT, () => { app.listen(PORT, () => {
logger.info("Listening to port " + PORT + "."); logger.info("Listening to port " + PORT + ".");
}); });

View File

@ -1,43 +1,45 @@
class Session { class Session {
#id = null;
#started = null;
#current = null;
#lastScrobble = null;
#pauseDuration = 0;
constructor(id) { constructor(id) {
this._id = id; this.#id = id;
this._started = Date.now(); this.#started = Date.now();
this._current = null;
this._previous = null;
this._lastScrobble = null;
this._pauseDuration = 0;
} }
get id() { get id() {
return this._id; return this.#id;
} }
get playing() { get playing() {
return this._current; return this.#current;
} }
set playing(value) { set playing(value) {
this._current = value; this.#current = value;
} }
get started() { get started() {
return this._started; return this.#started;
} }
get lastScrobbleTimestamp() { get lastScrobbleTimestamp() {
return this._lastScrobble; return this.#lastScrobble;
} }
set lastScrobbleTimestamp(value) { set lastScrobbleTimestamp(value) {
this._lastScrobble = value; this.#lastScrobble = value;
} }
get pauseDuration() { get pauseDuration() {
return this._pauseDuration; return this.#pauseDuration;
} }
set pauseDuration(value) { set pauseDuration(value) {
this._pauseDuration = value; this.#pauseDuration = value;
} }
} }

27
models/song.js Normal file
View File

@ -0,0 +1,27 @@
class Song {
id = null;
name = null;
album = null;
artists = [];
year = 0;
duration = 0;
progress = 0;
session = null;
state = null;
source = null;
constructor(id, name, album, artists, year, duration, progress, session, state, source) {
this.id = id;
this.name = name;
this.album = album;
this.artists = artists;
this.year = year;
this.duration = duration;
this.progress = progress;
this.session = session;
this.state = state;
this.source = source;
}
}
module.exports = Song;

View File

@ -1,126 +0,0 @@
const plex = require("./plex");
const logger = require("./logging")
const config = require("../config/configuration");
const Session = require("../models/session");
const sessions = require("../services/session-manager");
const spotify = require("./spotify");
let lastTick = Date.now();
async function poll() {
const now = Date.now();
const timeDiff = now - lastTick;
const playing = [];
await spotify.loadCredentials();
await spotify.refreshTokenIfNeeded();
const spotifyTrack = await spotify.getCurrentlyPlaying();
if (spotifyTrack != null)
playing.push(spotifyTrack);
try {
const data = await plex.getCurrentlyPlaying();
playing.push.apply(playing, data);
} catch (ex) {
logger.error(ex, "Could not fetch currently playing data from Plex.");
return;
}
for (let current of playing) {
let session = sessions.get(current.sessionKey);
if (session == null) {
session = new Session(current.sessionKey);
sessions.add(session);
}
const previous = session.playing;
session.playing = current;
if (previous == null) {
logger.info(current, "A new session has started.");
continue;
}
if (session.playing.state == "paused" || previous.state == "paused") {
session.pauseDuration += timeDiff - (session.playing.playtime - previous.playtime);
}
if (checkIfCanScrobble(session, previous, now, timeDiff)) {
logger.info(previous, "Scrobble");
session.pauseDuration = 0;
session.lastScrobbleTimestamp = now - (timeDiff - (previous.duration - previous.playtime));
} else if (session.playing.playtime < previous.playtime && session.playing.mediaKey != previous.mediaKey) {
session.pauseDuration = 0;
if (session.playing.playtime < timeDiff)
session.lastScrobbleTimestamp = now - session.playing.playtime;
else
session.lastScrobbleTimestamp = now;
}
}
const ids = sessions.getSessionIds();
for (let sessionId of ids) {
if (playing.some(p => p.sessionKey == sessionId))
continue;
session.playing = null;
if (checkIfCanScrobble(session, session.playing, now, timeDiff)) {
logger.info(session.playing, "Scrobble");
}
sessions.remove(sessionId);
logger.debug("Deleted old session (" + sessionId + ")");
}
lastTick = now;
}
function applyFilter(track, filters) {
if (!filters || filters.length == 0)
return true;
for (let filter of filters) {
if (filter.library && !filter.library.some(l => l == track.library))
continue;
if (filter.ip && !filter.ip.some(l => l == track.ip))
continue;
if (filter.deviceId && !filter.deviceId.some(l => l == track.deviceId))
continue;
if (filter.platform && !filter.platform.some(l => l == track.platform))
continue;
if (filter.product && !filter.product.some(l => l == track.product))
continue;
return true;
}
return false;
}
function checkIfCanScrobble(session, previous, now, timeDiff) {
if (!previous)
return false;
let filters = [];
if (previous.source == 'plex')
filters = config.plex.filters;
if (!applyFilter(previous, filters)) {
logger.debug(previous, 'No filters got triggered. Ignoring.');
return false;
}
const scrobbleDuration = config.scrobble.minimum.duration || 240;
const scrobblePercent = config.scrobble.minimum.percent || 50;
const current = session.playing;
const durationPlayed = now - (session.lastScrobbleTimestamp ?? session.started) - session.pauseDuration;
const newPlayback = current == null || current.playtime < previous.playtime && current.playtime < timeDiff;
const canBeScrobbled = durationPlayed > scrobbleDuration * 1000 || durationPlayed / previous.duration > scrobblePercent / 100.0;
return newPlayback && canBeScrobbled;
}
function isInt(value) {
return !isNaN(value) &&
parseInt(Number(value)) == value &&
!isNaN(parseInt(value, 10));
}
module.exports = poll;

99
services/recorder.js Normal file
View File

@ -0,0 +1,99 @@
const AggregateTracker = require("./trackers/AggregateTracker");
const Session = require("../models/session");
class Recorder {
#sessions = null;
#trackers = null;
#config = null;
#logger = null;
#lastTick = null;
constructor(sessions, trackers, config, logger) {
this.#sessions = sessions;
this.#trackers = new AggregateTracker(trackers);
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 data = media.map(m => this.#fetchSession(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));
// 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 scrobbling = finishedPlaying.concat(sessionEnded);
for (let track of scrobbling)
this.#scrobble(track);
// Remove dead sessions.
for (let sessionId of stopped)
this.#sessions.remove(sessionId);
this.#lastTick = now;
}
#fetchSession(media) {
let session = this.#sessions.get(media.session);
if (session == null) {
session = new Session(media.session);
this.#sessions.add(session);
}
return { session: session, media: media }
}
#listen(session, current, previous, timestamp, timeDiff) {
session.playing = current;
if (previous == null) {
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 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;
return newPlayback && canBeScrobbled;
}
#scrobble(media) {
this.#logger.info(media, "Scrobble");
}
}
module.exports = Recorder;

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

@ -0,0 +1,61 @@
const axios = require("axios");
const Song = require("../../models/song");
class PlexTracker {
#config = null;
#cache = [];
constructor(config) {
this.#config = config;
}
async poll(useCache = false) {
if (!this.#config.token || !this.#config.url)
return [];
if (useCache)
return this.#cache;
const response = await axios.get(this.#config.url + "/status/sessions", {
headers: {
"Accept": "application/json",
"X-Plex-Token": this.#config.token
}
});
if (!response.data.MediaContainer?.Metadata) {
this.#cache = [];
return this.#cache;
}
const filtered = response.data.MediaContainer?.Metadata.filter(m => this.#filter(m));
this.#cache = filtered.map(m => this.#transform(m));
return this.#cache;
}
#filter(data) {
if (!this.#config.filters || this.#config.filters.length == 0)
return true;
for (let filter of this.#config.filters) {
if (filter.library && !filter.library.some(l => l == data.librarySectionTitle))
continue;
if (filter.ip && !filter.ip.some(l => l == data.address))
continue;
if (filter.deviceId && !filter.deviceId.some(l => l == data.machineIdentifier))
continue;
if (filter.platform && !filter.platform.some(l => l == data.Player.platform))
continue;
if (filter.product && !filter.product.some(l => l == data.Player.product))
continue;
return true;
}
return false;
}
#transform(data) {
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");
}
}
module.exports = PlexTracker;

View File

@ -0,0 +1,99 @@
const axios = require("axios");
const logger = require("../logging");
const fs = require("fs/promises");
const fss = require("fs");
const Song = require("../../models/song");
class SpotifyTracker {
#config = null;
#token = null;
#cache = null;
#auth = null;
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');
}
async poll(useCache = false) {
if (this.#token == null)
return [];
if (useCache)
return this.#cache;
if (this.#token.expires_at < Date.now() + 300)
await this.#refreshTokenIfNeeded();
try {
const response = await axios.get("https://api.spotify.com/v1/me/player/currently-playing",
{
headers: {
"Authorization": "Bearer " + this.#token.access_token
}
}
);
if (!response.data) {
this.#cache = [];
return this.#cache;
}
this.#cache = [this.#transform(response.data)];
return this.#cache;
} catch (ex) {
logger.error(ex, "Failed to get currently playing data from Spotify.");
return [];
}
}
async loadCredentials() {
if (!fss.existsSync("credentials.spotify.json")) {
logger.info("No Spotify credentials found.");
return;
}
const content = await fs.readFile("credentials.spotify.json", "utf-8");
this.#token = JSON.parse(content);
}
#transform(data) {
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");
}
async #refreshTokenIfNeeded() {
if (!this.#token || this.#token.expires_at && this.#token.expires_at - Date.now() > 900)
return false;
const response = await axios.post("https://accounts.spotify.com/api/token",
{
client_id: this.#config.client_id,
refresh_token: this.#token.refresh_token,
grant_type: "refresh_token"
},
{
headers: {
"Authorization": "Basic " + this.#auth,
"Content-Type": "application/x-www-form-urlencoded"
}
}
);
const data = response.data;
data["expires_at"] = Date.now() + data["expires_in"] * 1000;
if (!data["refresh_token"])
data["refresh_token"] = this.#token.refresh_token;
this.#token = data;
await fs.writeFile("credentials.spotify.json", JSON.stringify(data));
logger.debug("Updated access token for Spotify.");
return true;
}
}
module.exports = SpotifyTracker;