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