Added scrobbling detection for Plex via polling.
This commit is contained in:
commit
277df90d56
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
logs/
|
||||||
|
config/*
|
||||||
|
!config/configuration.js
|
35
app.js
Normal file
35
app.js
Normal 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
33
config/configuration.js
Normal 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
1063
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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
7
routes/api.js
Normal 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
7
routes/home.js
Normal 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
4
services/logging.js
Normal 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
52
services/plex.js
Normal 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
75
services/poll.js
Normal 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;
|
Loading…
Reference in New Issue
Block a user