Compare commits

...

20 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
Tom
1b91e330c1 Added configuration validation. Minor config change. 2024-12-04 22:28:33 +00:00
Tom
79b27b8e32 Added Spotify tracking. Fixed filters when none given. 2024-12-04 19:01:40 +00:00
Tom
ad624172a6 Logging level changes based on environment 2024-12-04 01:39:35 +00:00
Tom
cfb63f1e3e Fixed scrobble filtering when a session ends. 2024-12-04 01:38:38 +00:00
22 changed files with 877 additions and 233 deletions

5
.gitignore vendored
View File

@ -1,4 +1,7 @@
node_modules/
logs/
config/*
!config/configuration.js
!config/configuration.js
!config.schema.js
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 + ".");
});

154
config/config.schema.js Normal file
View File

@ -0,0 +1,154 @@
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: '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,
},
ip: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
deviceId: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
platform: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
product: {
type: 'array',
items: {
type: 'string'
},
minItems: 0,
},
}
}
},
scrobblers: {
type: 'array',
items: {
type: 'string'
},
minItems: 1
}
}
}
},
scrobble: {
type: 'object',
properties: {
minimum: {
type: 'object',
properties: {
percent: {
type: 'number'
},
duration: {
type: 'number'
}
}
}
}
},
spotify: {
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
}
}
}
},
web: {
type: 'object',
required: [],
properties: {
host: {
type: 'string'
},
port: {
type: 'number'
}
}
}
}
}
module.exports = schema;

View File

@ -1,26 +1,30 @@
const config = require('config');
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 configuration = {
const configurationBase = {
plex: {
name: null,
url: null,
token: null
token: null,
filters: [], // { library, ip, deviceId, platform, product }
scrobblers: []
},
scrobble: {
minimum: {
percent: null,
duration: null
},
plex: {
delay: null,
filters: []
/* A filter will have the following properties:
library: [""],
ip: [""],
deviceId: [""],
platform: [""],
product: [""]
*/
},
},
spotify: {
name: null,
client_id: null,
client_secret: null,
redirect_uri: null,
scrobblers: []
},
web: {
host: null,
@ -28,24 +32,22 @@ const configuration = {
}
};
if (config.has("plex.url"))
configuration.plex.url = config.get("plex.url");
if (config.has("plex.token"))
configuration.plex.token = config.get("plex.token");
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);
if (config.has("scrobble.plex.delay"))
configuration.scrobble.plex.delay = config.get("scrobble.plex.delay");
if (config.has("scrobble.plex.filters"))
configuration.scrobble.plex.filters = config.get("scrobble.plex.filters");
const ajv = new Ajv({ allErrors: true });
const schema = require("./config.schema");
const validation = ajv.compile(schema);
const valid = validation(configurationFile);
if (config.has("scrobble.minimum.duration"))
configuration.scrobble.minimum.duration = config.get("scrobble.minimum.duration");
if (config.has("scrobble.minimum.percent"))
configuration.scrobble.minimum.percent = config.get("scrobble.minimum.percent");
if (!valid) {
logger.error("Configuration is invalid. " + validation.errors.map(e => e.message).join(". ") + ".");
(async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })();
}
if (config.has("web.host"))
configuration.web.host = config.get("web.host");
if (config.has("web.port"))
configuration.web.port = config.get("web.port");
const configuration = { ...configurationBase, ...configurationFile }
configuration.web.port ||= process.env.WEB_PORT;
module.exports = configuration;

48
controllers/home.js Normal file
View File

@ -0,0 +1,48 @@
const axios = require("axios");
const config = require("../config/configuration");
const fs = require("fs/promises");
const logger = require("../services/logging");
const querystring = require('node:querystring');
function authorizeSpotify(req, res) {
res.redirect("https://accounts.spotify.com/authorize?" + querystring.stringify({
response_type: "code",
client_id: config.spotify.client_id,
scope: "user-read-playback-state user-read-currently-playing",
redirect_uri: config.spotify.redirect_uri,
}));
}
async function callback(req, res) {
const code = req.query.code;
try {
const response = await axios.post("https://accounts.spotify.com/api/token",
{
code: code,
redirect_uri: config.spotify.redirect_uri,
grant_type: "authorization_code"
},
{
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;
await fs.writeFile("credentials.spotify.json", JSON.stringify(data));
res.redirect("/");
} catch (ex) {
logger.error(ex, "Failed to get Spotify oauth.");
res.send({ 'error': "Something went wrong with spotify's oauth flow" });
}
}
module.exports = {
authorizeSpotify,
callback
}

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;

87
package-lock.json generated
View File

@ -9,9 +9,9 @@
"version": "0.0.0",
"license": "ISC",
"dependencies": {
"ajv": "^8.17.1",
"async-await-queue": "^2.1.4",
"axios": "^1.7.8",
"config": "^3.3.12",
"dotenv": "^16.4.6",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",
"helmet": "^8.0.0",
@ -32,6 +32,22 @@
"node": ">= 0.6"
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -44,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",
@ -134,18 +156,6 @@
"node": ">= 0.8"
}
},
"node_modules/config": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/config/-/config-3.3.12.tgz",
"integrity": "sha512-Vmx389R/QVM3foxqBzXO8t2tUikYZP64Q6vQxGrsMpREeJc/aWRnPRERXWsYzOHAumx/AOoILWe6nU3ZJL+6Sw==",
"license": "MIT",
"dependencies": {
"json5": "^2.2.3"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -236,18 +246,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.4.6",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.6.tgz",
"integrity": "sha512-JhcR/+KIjkkjiU8yEpaB/USlzVi3i5whwOjpIRNGi9svKEXZSe+Qp6IWAjFjv+2GViAoDRCUv/QLNziQxsLqDg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -356,6 +354,12 @@
"express": "4 || 5 || ^5.0.0-beta.1"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-redact": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
@ -365,6 +369,12 @@
"node": ">=6"
}
},
"node_modules/fast-uri": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz",
"integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==",
"license": "BSD-3-Clause"
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
@ -593,17 +603,11 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/media-typer": {
"version": "0.3.0",
@ -844,6 +848,15 @@
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",

View File

@ -3,15 +3,17 @@
"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": "",
"license": "ISC",
"description": "",
"dependencies": {
"ajv": "^8.17.1",
"async-await-queue": "^2.1.4",
"axios": "^1.7.8",
"config": "^3.3.12",
"dotenv": "^16.4.6",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",
"helmet": "^8.0.0",

View File

@ -1,7 +1,16 @@
const home = require("../controllers/home");
const router = require("express").Router();
router.get("/", async (req, res) => {
res.send("welcome to an empty page.");
});
router.get("/auth/spotify", (req, res) => {
home.authorizeSpotify(req, res);
});
router.get("/callback", (req, res) => {
home.callback(req, res);
});
module.exports = router;

View File

@ -1,4 +1,15 @@
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") {
logger.level = 30
} else {
logger.level = 20
}
module.exports = logger;

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,106 +0,0 @@
const plex = require("./plex");
const logger = require("./logging")
const config = require("../config/configuration");
const lastPlaying = {};
const lastScrobbleTimes = {};
async function poll() {
const now = Date.now();
const playing = [];
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) {
continue;
}
let filters = [];
if (previous.source == 'plex')
filters = config.scrobble.plex.filters;
if (!applyFilter(previous, filters)) {
logger.debug(previous, 'No filters got triggered. Ignoring.');
continue;
}
if (!previous) {
logger.info(current, "A new session has started.");
} else {
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)
return;
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, last, now) {
if (!last)
return;
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 (last) {
const newPlayback = current == null || current.playtime < last.playtime;
const canBeScrobbled = last.playtime > scrobbleDuration * 1000 || last.playtime / last.duration > scrobblePercent;
if (newPlayback && canBeScrobbled) {
const sameSong = current != null && current.mediaKey == last.mediaKey;
const lastTime = lastScrobbleTimes[last.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

@ -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
}