Compare commits

...

14 Commits

20 changed files with 645 additions and 369 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ config/*
!config/configuration.js !config/configuration.js
!config.schema.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 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/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(); const app = express();
app.use(express.json()); app.use(express.json());
@ -27,6 +41,7 @@ const limiter = rateLimit({
app.use(helmet()); app.use(helmet());
app.use(limiter); app.use(limiter);
const PORT = config.web.port || 9011;
app.listen(PORT, () => { app.listen(PORT, () => {
logger.info("Listening to port " + PORT + "."); logger.info("Listening to port " + PORT + ".");
}); });

View File

@ -2,58 +2,89 @@ const schema = {
type: 'object', type: 'object',
required: [], required: [],
properties: { properties: {
maloja: {
type: 'array',
items: {
type: 'object',
required: ['name', 'url', 'token'],
properties: {
name: {
type: 'string'
},
url: {
type: 'string'
},
token: {
type: 'string'
},
}
}
},
plex: { plex: {
type: 'object', type: 'array',
required: ['url', 'token'], items: {
properties: { type: 'object',
url: { required: ['name', 'url', 'token', 'scrobblers'],
type: 'string' properties: {
}, name: {
token: { type: 'string'
type: 'string' },
}, url: {
filters: { type: 'string'
type: 'array', },
minItems: 0, token: {
items: { type: 'string'
type: 'object', },
properties: { filters: {
library: { type: 'array',
type: 'array', minItems: 0,
items: { items: {
type: 'string' type: 'object',
properties: {
library: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
}, },
minItems: 0, ip: {
}, type: 'array',
ip: { items: {
type: 'array', type: 'string'
items: { },
type: 'string' minItems: 0,
}, },
minItems: 0, deviceId: {
}, type: 'array',
deviceId: { items: {
type: 'array', type: 'string'
items: { },
type: 'string' minItems: 0,
}, },
minItems: 0, platform: {
}, type: 'array',
platform: { items: {
type: 'array', type: 'string'
items: { },
type: 'string' minItems: 0,
}, },
minItems: 0, product: {
}, type: 'array',
product: { items: {
type: 'array', type: 'string'
items: { },
type: 'string' minItems: 0,
}, },
minItems: 0, }
},
} }
},
scrobblers: {
type: 'array',
items: {
type: 'string'
},
minItems: 1
} }
} }
} }
@ -77,17 +108,30 @@ const schema = {
}, },
spotify: { spotify: {
type: 'object', type: 'array',
required: ['client_id', 'client_secret', 'redirect_uri'], items: {
properties: { type: 'object',
client_id: { required: ['name', 'client_id', 'client_secret', 'redirect_uri', 'scrobblers'],
type: 'string' properties: {
}, name: {
client_secret: { type: 'string'
type: 'string' },
}, client_id: {
redirect_uri: { type: 'string'
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 Ajv = require("ajv");
const fs = require("fs"); const fs = require("fs");
const docker = require("../utils/docker");
const logger = require("../services/logging"); const logger = require("../services/logging");
const yaml = require("js-yaml"); const yaml = require("js-yaml");
const { exit } = require("process");
const configurationBase = { const configurationBase = {
plex: { plex: {
name: null,
url: null, url: null,
token: null, token: null,
filters: [] // { library, ip, deviceId, platform, product } filters: [], // { library, ip, deviceId, platform, product }
scrobblers: []
}, },
scrobble: { scrobble: {
minimum: { minimum: {
@ -16,9 +20,11 @@ const configurationBase = {
}, },
}, },
spotify: { spotify: {
name: null,
client_id: null, client_id: null,
client_secret: null, client_secret: null,
redirect_uri: null redirect_uri: null,
scrobblers: []
}, },
web: { web: {
host: null, host: null,
@ -26,12 +32,12 @@ const configurationBase = {
} }
}; };
const configurationFile = yaml.load(fs.readFileSync('config/config.yml'), yaml.JSON_SCHEMA); const isDocker = docker.isRunningOnDocker();
const configuration = { ...configurationBase, ...configurationFile } 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 ajv = new Ajv({ allErrors: true });
const schema = require("./config.schema"); const schema = require("./config.schema");
const { exit } = require("process");
const validation = ajv.compile(schema); const validation = ajv.compile(schema);
const valid = validation(configurationFile); const valid = validation(configurationFile);
@ -40,4 +46,8 @@ if (!valid) {
(async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })(); (async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })();
} }
const configuration = { ...configurationBase, ...configurationFile }
configuration.web.port ||= process.env.WEB_PORT;
module.exports = configuration; module.exports = configuration;

View File

@ -1,43 +1,31 @@
class Session { class Session {
#id = null;
#started = null;
#current = null;
lastScrobbleTimestamp = 0;
lastUpdateTimestamp = 0;
pauseDuration = 0;
playDuration = 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() {
return this._lastScrobble;
}
set lastScrobbleTimestamp(value) {
this._lastScrobble = value;
}
get pauseDuration() {
return this._pauseDuration;
}
set pauseDuration(value) {
this._pauseDuration = value;
} }
} }

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", "license": "ISC",
"dependencies": { "dependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"async-await-queue": "^2.1.4",
"axios": "^1.7.8", "axios": "^1.7.8",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^7.4.1", "express-rate-limit": "^7.4.1",
@ -59,6 +60,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",

View File

@ -3,6 +3,8 @@
"version": "0.0.0", "version": "0.0.0",
"main": "app.js", "main": "app.js",
"scripts": { "scripts": {
"start": "NODE_ENV=production node app.js",
"dev": "NODE_ENV=development node app.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"author": "", "author": "",
@ -10,6 +12,7 @@
"description": "", "description": "",
"dependencies": { "dependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"async-await-queue": "^2.1.4",
"axios": "^1.7.8", "axios": "^1.7.8",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^7.4.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 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'; const environment = process.env.NODE_ENV || 'development';
if (environment == "production") { 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,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;

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

@ -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.debug("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
}