Compare commits

...

16 Commits

Author SHA1 Message Date
Tom
2b2cc20a1d Added support for multiple trackers & scrobblers. 2024-12-06 18:19:49 +00:00
Tom
7ef6ebb076 Docker runs the app in production. 2024-12-06 18:18:17 +00:00
Tom
748d9de02a Added basic docker support. 2024-12-06 07:07:19 +00:00
Tom
0dcbd0ad2e Removed old implementation of trackers. 2024-12-05 23:15:12 +00:00
Tom
9335e3e32f Added buffer to scrobblers. 2024-12-05 22:34:00 +00:00
Tom
934689763b Fixed timestamp & duration of scrobble. 100% playback automatically scrobbles the song. 2024-12-05 20:27:09 +00:00
Tom
0bfbc36952 Improved time upkeep for playing and pausing. 2024-12-05 18:52:36 +00:00
Tom
5100d18ac6 Added scrobbling functionality. Changes all around to support scrobbling, such as named trackers and configuration changes. 2024-12-05 17:32:37 +00:00
Tom
b30dc3396a Renamed tracker file names. 2024-12-05 15:17:43 +00:00
Tom
e5e0853d03 Added Recorder class. Removed poll.js file. Recorder class is now how this keeps track of what to scrobble. 2024-12-05 08:36:22 +00:00
Tom
888e954fd7 Fixed session data class. 2024-12-05 08:27:45 +00:00
Tom
b621527495 Fixed Song data class. 2024-12-05 08:27:03 +00:00
Tom
49633b7ee6 Fixed issues with tracker classes 2024-12-05 08:26:31 +00:00
Tom
6e43d681da Added song data class. Added trackers. 2024-12-05 04:45:38 +00:00
Tom
539b9a5055 Updated polling with sessions. Changed scrobble condition to play duration only. Fixed percentage scrobble condition. Changed default duration and percentage to 240 & 50, respectively. 2024-12-05 01:10:09 +00:00
Tom
478bd76aeb Added session & session manager 2024-12-05 01:05:58 +00:00
21 changed files with 696 additions and 327 deletions

3
.gitignore vendored
View File

@ -3,4 +3,5 @@ logs/
config/*
!config/configuration.js
!config.schema.js
credentials.*
credentials.*
*.yml

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:18.19.0
ENV APP_DIR=/app
ENV CONFIG_DIR=/config
ENV LOGS_DIR=/logs
ENV WEB_PORT=9011
RUN mkdir -p ${APP_DIR}/node_modules
WORKDIR ${APP_DIR}
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE ${WEB_PORT}
CMD ["npm", "run", "start"]

21
app.js
View File

@ -3,11 +3,25 @@ const express = require('express');
const helmet = require("helmet");
const logger = require("./services/logging");
const rateLimit = require("express-rate-limit");
const sessions = require("./services/session-manager");
const PlexTracker = require("./services/trackers/plex-tracker");
const SpotifyTracker = require("./services/trackers/spotify-tracker");
const Recorder = require("./services/recorder");
const MalojaScrobbler = require("./services/scrobblers/maloja-scrobbler");
const poll = require("./services/poll");
setInterval(poll, 5000);
const PORT = process.env.PORT || config.web.port || 9111;
let trackers = []
trackers = trackers.concat(config.spotify.map(config => new SpotifyTracker(config)));
trackers = trackers.concat(config.plex.map(config => new PlexTracker(config)));
const uniqueSpotifys = new Set(config.spotify.map(c => c.client_id));
for (let spotify of uniqueSpotifys)
(async () => await spotify.loadCredentials())();
const scrobblers = config.maloja.map(config => new MalojaScrobbler(config, logger));
const recorder = new Recorder(sessions, trackers, scrobblers, config.scrobble, logger);
setInterval(() => recorder.record(), 5000);
const app = express();
app.use(express.json());
@ -27,6 +41,7 @@ const limiter = rateLimit({
app.use(helmet());
app.use(limiter);
const PORT = config.web.port || 9011;
app.listen(PORT, () => {
logger.info("Listening to port " + PORT + ".");
});

View File

@ -2,58 +2,89 @@ const schema = {
type: 'object',
required: [],
properties: {
maloja: {
type: 'array',
items: {
type: 'object',
required: ['name', 'url', 'token'],
properties: {
name: {
type: 'string'
},
url: {
type: 'string'
},
token: {
type: 'string'
},
}
}
},
plex: {
type: 'object',
required: ['url', 'token'],
properties: {
url: {
type: 'string'
},
token: {
type: 'string'
},
filters: {
type: 'array',
minItems: 0,
items: {
type: 'object',
properties: {
library: {
type: 'array',
items: {
type: 'string'
type: 'array',
items: {
type: 'object',
required: ['name', 'url', 'token', 'scrobblers'],
properties: {
name: {
type: 'string'
},
url: {
type: 'string'
},
token: {
type: 'string'
},
filters: {
type: 'array',
minItems: 0,
items: {
type: 'object',
properties: {
library: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
minItems: 0,
},
ip: {
type: 'array',
items: {
type: 'string'
ip: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
minItems: 0,
},
deviceId: {
type: 'array',
items: {
type: 'string'
deviceId: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
minItems: 0,
},
platform: {
type: 'array',
items: {
type: 'string'
platform: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
minItems: 0,
},
product: {
type: 'array',
items: {
type: 'string'
product: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
minItems: 0,
},
}
}
},
scrobblers: {
type: 'array',
items: {
type: 'string'
},
minItems: 1
}
}
}
@ -77,17 +108,30 @@ const schema = {
},
spotify: {
type: 'object',
required: ['client_id', 'client_secret', 'redirect_uri'],
properties: {
client_id: {
type: 'string'
},
client_secret: {
type: 'string'
},
redirect_uri: {
type: 'string'
type: 'array',
items: {
type: 'object',
required: ['name', 'client_id', 'client_secret', 'redirect_uri', 'scrobblers'],
properties: {
name: {
type: 'string'
},
client_id: {
type: 'string'
},
client_secret: {
type: 'string'
},
redirect_uri: {
type: 'string'
},
scrobblers: {
type: 'array',
items: {
type: 'string'
},
minItems: 1
}
}
}
},

View File

@ -1,13 +1,17 @@
const Ajv = require("ajv");
const fs = require("fs");
const docker = require("../utils/docker");
const logger = require("../services/logging");
const yaml = require("js-yaml");
const { exit } = require("process");
const configurationBase = {
plex: {
name: null,
url: null,
token: null,
filters: [] // { library, ip, deviceId, platform, product }
filters: [], // { library, ip, deviceId, platform, product }
scrobblers: []
},
scrobble: {
minimum: {
@ -16,9 +20,11 @@ const configurationBase = {
},
},
spotify: {
name: null,
client_id: null,
client_secret: null,
redirect_uri: null
redirect_uri: null,
scrobblers: []
},
web: {
host: null,
@ -26,12 +32,12 @@ const configurationBase = {
}
};
const configurationFile = yaml.load(fs.readFileSync('config/config.yml'), yaml.JSON_SCHEMA);
const configuration = { ...configurationBase, ...configurationFile }
const isDocker = docker.isRunningOnDocker();
const configPath = isDocker ? `${process.env.CONFIG_DIR}/config.yml` : "config.yml";
const configurationFile = yaml.load(fs.readFileSync(configPath), yaml.JSON_SCHEMA);
const ajv = new Ajv({ allErrors: true });
const schema = require("./config.schema");
const { exit } = require("process");
const validation = ajv.compile(schema);
const valid = validation(configurationFile);
@ -40,4 +46,8 @@ if (!valid) {
(async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })();
}
const configuration = { ...configurationBase, ...configurationFile }
configuration.web.port ||= process.env.WEB_PORT;
module.exports = configuration;

32
models/session.js Normal file
View File

@ -0,0 +1,32 @@
class Session {
#id = null;
#started = null;
#current = null;
lastScrobbleTimestamp = 0;
lastUpdateTimestamp = 0;
pauseDuration = 0;
playDuration = 0;
constructor(id) {
this.#id = id;
this.#started = Date.now();
}
get id() {
return this.#id;
}
get playing() {
return this.#current;
}
set playing(value) {
this.#current = value;
}
get started() {
return this.#started;
}
}
module.exports = Session;

29
models/song.js Normal file
View File

@ -0,0 +1,29 @@
class Song {
id = null;
name = null;
album = null;
artists = [];
year = 0;
duration = 0;
progress = 0;
session = null;
state = null;
source = null;
provider = null;
constructor(id, name, album, artists, year, duration, progress, session, state, source, provider) {
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;
this.provider = provider;
}
}
module.exports = Song;

7
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"ajv": "^8.17.1",
"async-await-queue": "^2.1.4",
"axios": "^1.7.8",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",
@ -59,6 +60,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/async-await-queue": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/async-await-queue/-/async-await-queue-2.1.4.tgz",
"integrity": "sha512-3DpDtxkKO0O/FPlWbk/CrbexjuSxWm1CH1bXlVNVyMBIkKHhT5D85gzHmGJokG3ibNGWQ7pHBmStxUW/z/0LYQ==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",

View File

@ -3,6 +3,8 @@
"version": "0.0.0",
"main": "app.js",
"scripts": {
"start": "NODE_ENV=production node app.js",
"dev": "NODE_ENV=development node app.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
@ -10,6 +12,7 @@
"description": "",
"dependencies": {
"ajv": "^8.17.1",
"async-await-queue": "^2.1.4",
"axios": "^1.7.8",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",

View File

@ -1,5 +1,9 @@
const docker = require("../utils/docker");
const path = require("path");
const pino = require("pino");
const logger = pino(pino.destination({ dest: 'logs/.log', sync: false }));
const directory = docker.isRunningOnDocker() ? process.env.LOGS_DIR : "logs";
const logger = pino(pino.destination({ dest: path.join(directory, '.log'), sync: false }));
const environment = process.env.NODE_ENV || 'development';
if (environment == "production") {

View File

@ -1,53 +0,0 @@
const axios = require("axios");
const config = require("../config/configuration");
let cache = {};
async function getCurrentlyPlaying(cached = false) {
if (!config.plex.token || !config.plex.url) {
return [];
}
const key = config.plex.url + "|" + config.plex.token;
if (cached) {
return cache[key] || [];
}
const response = await axios.get(config.plex.url + "/status/sessions", {
headers: {
"Accept": "application/json",
"X-Plex-Token": config.plex.token
}
});
if (!response.data.MediaContainer?.Metadata) {
cache[key] = [];
return []
}
cache[key] = response.data.MediaContainer.Metadata.map(media => ({
"library": media.librarySectionTitle,
"track": media.title,
"album": media.parentTitle,
"artist": media.grandparentTitle,
"year": media.parentYear,
"duration": media.duration,
"playtime": media.viewOffset,
"lastListenedAt": media.lastViewedAt,
"mediaKey": media.guid.substring(media.guid.lastIndexOf('/') + 1),
"sessionKey": media.sessionKey,
"ip": media.Player.address,
"state": media.Player.state,
"deviceId": media.Player.machineIdentifier,
"platform": media.Player.platform,
"platformVersion": media.Player.platformVersion,
"product": media.Player.product,
"version": media.Player.version,
"source": "plex"
}));
return cache[key];
}
module.exports = {
getCurrentlyPlaying
}

View File

@ -1,110 +0,0 @@
const plex = require("./plex");
const logger = require("./logging")
const config = require("../config/configuration");
const spotify = require("./spotify");
const lastPlaying = {};
const lastScrobbleTimes = {};
async function poll() {
const now = Date.now();
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) {
const previous = lastPlaying[current.sessionKey];
lastPlaying[current.sessionKey] = current;
if (previous == null) {
logger.info(current, "A new session has started.");
continue;
}
if (checkIfCanScrobble(current, previous, now)) {
logger.info(previous, "Scrobble");
lastScrobbleTimes[previous.mediaKey] = now;
}
}
// Scrobble then remove lingering sessions
for (let key in lastPlaying) {
if (!playing.some(p => p.sessionKey == key)) {
const track = lastPlaying[key];
if (checkIfCanScrobble(null, track, now)) {
logger.info(track, "Scrobble");
lastScrobbleTimes[track.mediaKey] = now;
}
delete lastPlaying[key];
logger.debug("Deleted old session.", key);
}
}
}
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(current, previous, now) {
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 = isInt(config.scrobble.minimum.duration) ? Number(config.scrobble.minimum.duration) : 30;
const scrobblePercent = isInt(config.scrobble.minimum.percent) ? Number(config.scrobble.minimum.percent) : 30;
if (previous) {
const newPlayback = current == null || current.playtime < previous.playtime;
const canBeScrobbled = previous.playtime > scrobbleDuration * 1000 || previous.playtime / previous.duration > scrobblePercent;
if (newPlayback && canBeScrobbled) {
const sameSong = current != null && current.mediaKey == previous.mediaKey;
const lastTime = lastScrobbleTimes[previous.mediaKey];
return !sameSong || !lastTime || now - lastTime > scrobbleDuration;
}
}
return false;
}
function isInt(value) {
return !isNaN(value) &&
parseInt(Number(value)) == value &&
!isNaN(parseInt(value, 10));
}
module.exports = poll;

137
services/recorder.js Normal file
View File

@ -0,0 +1,137 @@
const AggregateTracker = require("./trackers/aggregate-tracker");
const Session = require("../models/session");
class Recorder {
#sessions = null;
#trackers = null;
#originalTrackers = [];
#scrobblers = [];
#config = null;
#logger = 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;
}
async record() {
const now = Date.now();
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));
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));
// Scrobble
const scrobbling = finishedPlaying.concat(contextEnded);
for (let context of scrobbling) {
await this.#scrobble(context);
if (context.session.playing == null)
continue;
context.session.playDuration = context.extraDuration;
context.session.pauseDuration = 0;
}
// Remove dead sessions.
for (let context of stopped) {
this.#sessions.remove(context.session.id);
}
}
#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, extraDuration: 0, scrobble: null }
}
#listen(context, timestamp) {
const session = context.session;
const current = context.media;
const previous = context.session.playing;
session.playing = current;
if (!previous) {
this.#logger.info(current, "A new session has started.");
session.lastUpdateTimestamp = timestamp;
return false;
}
const updated = current.progress != previous.progress || current.id != previous.id || current.state != previous.state;
if (!updated)
return false;
const timeDiff = timestamp - session.lastUpdateTimestamp;
const progressDiff = Math.max(0, Math.min(current.progress - previous.progress, timeDiff));
session.playDuration += progressDiff;
session.pauseDuration += timeDiff - progressDiff;
const canScrobble = this.#canScrobble(session, current, previous);
if (canScrobble || current.id != previous.id) {
context.extraDuration = Math.max(Math.min(current.progress, timeDiff - (previous.duration - previous.progress)), 0);
context.extraDuration += Math.max(0, session.playDuration - previous.duration);
session.lastScrobbleTimestamp = timestamp;
if (canScrobble)
context.scrobble = previous;
}
session.lastUpdateTimestamp = timestamp;
return canScrobble;
}
#canScrobble(session, current, previous) {
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 || session.playDuration >= previous.duration;
}
async #scrobble(context) {
this.#logger.info(context.scrobble, "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 {
const duration = context.session.playDuration;
const start = Date.now() - context.session.playDuration - context.session.pauseDuration;
await scrobbler.queue(context.scrobble, duration, start);
} catch (ex) {
this.#logger.error(ex, "Could not send to maloja.");
}
}
}
}
module.exports = Recorder;

View File

@ -0,0 +1,46 @@
const axios = require("axios");
const Scrobbler = require("./scrobbler");
class MalojaScrobbler extends Scrobbler {
#config = null;
#counter = 0;
constructor(config, logger) {
super(logger);
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, duration, 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(duration / 1000),
length: Math.round(song.duration / 1000),
time: Math.round(start / 1000)
});
this.#counter++;
}
}
module.exports = MalojaScrobbler;

View File

@ -0,0 +1,31 @@
const { Queue } = require("async-await-queue");
class Scrobbler {
#queue = null;
#logger = null;
constructor(logger) {
this.#queue = new Queue(1, 300);
this.#logger = logger;
}
async queue(media, duration, start) {
const id = Symbol();
try {
await this.#queue.wait(id, 0);
await this.scrobble(media, duration, start);
} catch (ex) {
this.#logger.console.error(media, "Failed to scrobble: " + ex.message);
} finally {
this.#queue.end(id, duration, start);
}
}
async scrobble(media, duration, start) {
console.log("This should not be running, ever.");
}
}
module.exports = Scrobbler;

View File

@ -0,0 +1,33 @@
class SessionManager {
constructor() {
this._sessions = {};
}
add(session) {
if (!session || !session.id)
return;
this._sessions[session.id] = session;
}
contains(sessionId) {
return this._sessions[sessionId] != null;
}
get(sessionId) {
return this._sessions[sessionId];
}
getSessionIds() {
return Object.keys(this._sessions);
}
remove(sessionId) {
if (!sessionId)
return;
delete this._sessions[sessionId];
}
}
module.exports = new SessionManager();

View File

@ -1,98 +0,0 @@
const axios = require("axios");
const config = require("../config/configuration")
const logger = require("./logging");
const fs = require("fs/promises");
const fss = require("fs")
let token = null;
let cache = {}
async function refreshTokenIfNeeded() {
if (!token || token["expires_at"] && token["expires_at"] - Date.now() > 900) {
return false;
}
try {
const response = await axios.post("https://accounts.spotify.com/api/token",
{
client_id: config.spotify.client_id,
refresh_token: token["refresh_token"],
grant_type: "refresh_token"
},
{
headers: {
"Authorization": "Basic " + new Buffer.from(config.spotify.client_id + ':' + config.spotify.client_secret).toString('base64'),
"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"] = token["refresh_token"];
}
await fs.writeFile("credentials.spotify.json", JSON.stringify(data));
token = data;
logger.info("Updated access token for Spotify.");
return true;
} catch (ex) {
logger.error(ex, "Failed to get Spotify oauth.");
return false;
}
}
async function loadCredentials() {
if (!fss.existsSync("credentials.spotify.json")) {
logger.info("No Spotify credentials found.");
return;
}
const content = await fs.readFile("credentials.spotify.json", "utf-8");
token = JSON.parse(content);
}
async function getCurrentlyPlaying(cached = false) {
if (cached) {
return cache['spotify']
}
try {
const response = await axios.get("https://api.spotify.com/v1/me/player/currently-playing",
{
headers: {
"Authorization": "Bearer " + token["access_token"]
}
}
);
if (!response.data) {
cache['spotify'] = null;
return null;
}
const media = response.data.item;
cache['spotify'] = {
"track": media.name,
"album": media.album.name,
"artist": media.artists.map(a => a.name).join(', '),
"year": media.parentYear,
"duration": media.duration_ms,
"playtime": response.data.progress_ms,
"mediaKey": media.id,
"sessionKey": "spotify",
"state": response.data.is_playing ? "playing" : "paused",
"source": "spotify"
};
return cache['spotify'];
} catch (ex) {
logger.error(ex, "Failed to get currently playing data from Spotify.");
return null;
}
}
module.exports = {
refreshTokenIfNeeded,
loadCredentials,
getCurrentlyPlaying
}

View File

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

View File

@ -0,0 +1,71 @@
const axios = require("axios");
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 [];
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, this.#config.name));
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, source) {
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, artists, data.parentYear, data.duration, data.viewOffset, data.sessionKey, data.Player.state, source, "plex");
}
}
module.exports = PlexTracker;

View File

@ -0,0 +1,107 @@
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 = [];
#auth = null;
provider = "spotify";
constructor(config, token = null) {
this.#config = config;
this.#token = token;
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 [];
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, this.#config.name)];
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, 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, source, "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;

13
utils/docker.js Normal file
View File

@ -0,0 +1,13 @@
const fs = require("fs");
function isRunningOnDocker() {
try {
return fs.existsSync("/.dockerenv");
} catch { }
return false;
}
module.exports = {
isRunningOnDocker
}