Added scrobbling detection for Plex via polling.

This commit is contained in:
Tom 2024-12-03 22:46:43 +00:00
commit 277df90d56
10 changed files with 1301 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
logs/
config/*
!config/configuration.js

35
app.js Normal file
View File

@ -0,0 +1,35 @@
const dotenv = require('dotenv');
const express = require('express');
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const config = require("./config/configuration");
const logger = require("./services/logging");
dotenv.config();
const poll = require("./services/poll");
setInterval(poll, 5000);
const PORT = process.env.PORT || config.web.port || 9111;
const app = express();
app.use(express.json());
app.use("/", require("./routes/home"));
app.use("/api", require("./routes/api"));
const limiter = rateLimit({
legacyHeaders: true,
standardHeaders: true,
windowMs: 15 * 60 * 1000,
limit: 50,
max: 2,
message: "Too many requests; please try again later.",
keyGenerator: (req) => req.ip,
});
app.use(helmet());
app.use(limiter);
app.listen(PORT, () => {
logger.info("Listening to port " + PORT + ".");
});

33
config/configuration.js Normal file
View File

@ -0,0 +1,33 @@
const config = require('config');
const configuration = {
plex: {
url: null,
token: null
},
scrobble: {
percent: null,
duration: null
},
web: {
host: null,
port: null
}
};
if (config.has("plex.url"))
configuration.plex.url = config.get("plex.url");
if (config.has("plex.token"))
configuration.plex.token = config.get("plex.token");
if (config.has("scrobble.duration"))
configuration.scrobble.duration = config.get("scrobble.duration");
if (config.has("scrobble.percent"))
configuration.scrobble.percent = config.get("scrobble.percent");
if (config.has("web.host"))
configuration.web.host = config.get("web.host");
if (config.has("web.port"))
configuration.web.port = config.get("web.port");
module.exports = configuration;

1063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "apollo",
"version": "0.0.0",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"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",
"js-yaml": "^4.1.0",
"pino": "^9.5.0"
}
}

7
routes/api.js Normal file
View File

@ -0,0 +1,7 @@
const router = require("express").Router();
router.get("/", (req, res) => {
res.send({ status: 'testing in progress' });
});
module.exports = router;

7
routes/home.js Normal file
View File

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

4
services/logging.js Normal file
View File

@ -0,0 +1,4 @@
const pino = require("pino");
const logger = pino(pino.destination({ dest: 'logs/.log', sync: false }));
module.exports = logger;

52
services/plex.js Normal file
View File

@ -0,0 +1,52 @@
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,
}));
return cache[key];
}
module.exports = {
getCurrentlyPlaying
}

75
services/poll.js Normal file
View File

@ -0,0 +1,75 @@
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 media of playing) {
if (!lastScrobbleTimes[media.mediaKey]) {
lastScrobbleTimes[media.mediaKey] = 1;
}
const lastTrack = lastPlaying[media.sessionKey];
if (!lastTrack) {
logger.info(media, "A new session has started.");
} else {
if (checkIfCanScrobble(media, lastTrack, now)) {
logger.info(lastTrack, "Scrobble");
lastScrobbleTimes[lastTrack.mediaKey] = now;
}
}
lastPlaying[media.sessionKey] = media;
}
// 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 checkIfCanScrobble(current, last, now) {
const scrobbleDuration = isInt(config.scrobble.duration) ? Number(config.scrobble.duration) : 30;
const scrobblePercent = isInt(config.scrobble.percent) ? Number(config.scrobble.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;