Compare commits
6 Commits
0bfbc36952
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b2cc20a1d | |||
| 7ef6ebb076 | |||
| 748d9de02a | |||
| 0dcbd0ad2e | |||
| 9335e3e32f | |||
| 934689763b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,4 +3,5 @@ logs/
|
||||
config/*
|
||||
!config/configuration.js
|
||||
!config.schema.js
|
||||
credentials.*
|
||||
credentials.*
|
||||
*.yml
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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"]
|
||||
17
app.js
17
app.js
@@ -10,11 +10,16 @@ const Recorder = require("./services/recorder");
|
||||
const MalojaScrobbler = require("./services/scrobblers/maloja-scrobbler");
|
||||
|
||||
|
||||
const maloja = new MalojaScrobbler(config.maloja);
|
||||
const spotify = new SpotifyTracker(config.spotify);
|
||||
(async () => await spotify.loadCredentials())();
|
||||
const plex = new PlexTracker(config.plex);
|
||||
const recorder = new Recorder(sessions, [plex, spotify], [maloja], config.scrobble, logger);
|
||||
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);
|
||||
|
||||
@@ -36,7 +41,7 @@ const limiter = rateLimit({
|
||||
app.use(helmet());
|
||||
app.use(limiter);
|
||||
|
||||
const PORT = process.env.PORT || config.web.port || 9111;
|
||||
const PORT = config.web.port || 9011;
|
||||
app.listen(PORT, () => {
|
||||
logger.info("Listening to port " + PORT + ".");
|
||||
});
|
||||
@@ -3,83 +3,89 @@ const schema = {
|
||||
required: [],
|
||||
properties: {
|
||||
maloja: {
|
||||
type: 'object',
|
||||
required: ['name', 'url', 'token'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string'
|
||||
},
|
||||
url: {
|
||||
type: 'string'
|
||||
},
|
||||
token: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['name', 'url', 'token'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string'
|
||||
},
|
||||
url: {
|
||||
type: 'string'
|
||||
},
|
||||
token: {
|
||||
type: 'string'
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
plex: {
|
||||
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: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['name', 'url', 'token', 'scrobblers'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -102,27 +108,30 @@ const schema = {
|
||||
},
|
||||
|
||||
spotify: {
|
||||
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: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['name', 'client_id', 'client_secret', 'redirect_uri', 'scrobblers'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1
|
||||
client_id: {
|
||||
type: 'string'
|
||||
},
|
||||
client_secret: {
|
||||
type: 'string'
|
||||
},
|
||||
redirect_uri: {
|
||||
type: 'string'
|
||||
},
|
||||
scrobblers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 configurationBase = {
|
||||
plex: {
|
||||
@@ -30,11 +32,12 @@ const configurationBase = {
|
||||
}
|
||||
};
|
||||
|
||||
const configurationFile = yaml.load(fs.readFileSync('config/config.yml'), yaml.JSON_SCHEMA);
|
||||
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);
|
||||
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
const schema = require("./config.schema");
|
||||
const { exit } = require("process");
|
||||
const validation = ajv.compile(schema);
|
||||
const valid = validation(configurationFile);
|
||||
|
||||
@@ -44,4 +47,7 @@ if (!valid) {
|
||||
}
|
||||
|
||||
const configuration = { ...configurationBase, ...configurationFile }
|
||||
|
||||
configuration.web.port ||= process.env.WEB_PORT;
|
||||
|
||||
module.exports = configuration;
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"async-await-queue": "^2.1.4",
|
||||
"axios": "^1.7.8",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
@@ -59,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",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"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": "",
|
||||
@@ -10,6 +12,7 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"async-await-queue": "^2.1.4",
|
||||
"axios": "^1.7.8",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -62,7 +62,7 @@ class Recorder {
|
||||
this.#sessions.add(session);
|
||||
}
|
||||
|
||||
return { session, media, tracker, extraDuration: 0 }
|
||||
return { session, media, tracker, extraDuration: 0, scrobble: null }
|
||||
}
|
||||
|
||||
#listen(context, timestamp) {
|
||||
@@ -85,11 +85,15 @@ class Recorder {
|
||||
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.min(current.progress, timeDiff - (previous.duration - previous.progress));
|
||||
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;
|
||||
@@ -106,11 +110,11 @@ class Recorder {
|
||||
const newPlayback = current == null || current.progress < previous.progress;
|
||||
const canBeScrobbled = session.playDuration > scrobbleDuration * 1000 || session.playDuration / previous.duration > scrobblePercent / 100.0;
|
||||
|
||||
return newPlayback && canBeScrobbled;
|
||||
return newPlayback && canBeScrobbled || session.playDuration >= previous.duration;
|
||||
}
|
||||
|
||||
async #scrobble(context) {
|
||||
this.#logger.info(context, "Scrobble");
|
||||
this.#logger.info(context.scrobble, "Scrobble");
|
||||
|
||||
for (var scrobblerName of context.tracker.scrobblerNames) {
|
||||
const scrobbler = this.#scrobblers.find(s => s.name == scrobblerName);
|
||||
@@ -120,7 +124,9 @@ class Recorder {
|
||||
}
|
||||
|
||||
try {
|
||||
await scrobbler.scrobble(context.media, Date.now() - Math.min(context.media.duration, context.session.playDuration));
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const axios = require("axios");
|
||||
const Scrobbler = require("./scrobbler");
|
||||
|
||||
class MalojaScrobbler {
|
||||
class MalojaScrobbler extends Scrobbler {
|
||||
#config = null;
|
||||
#counter = 0;
|
||||
|
||||
constructor(config) {
|
||||
constructor(config, logger) {
|
||||
super(logger);
|
||||
this.#config = config;
|
||||
|
||||
if (!config.name)
|
||||
@@ -12,7 +14,7 @@ class MalojaScrobbler {
|
||||
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}'.`)
|
||||
throw new Error(`Invalid token for Maloja scrobbler '${this.name}'.`);
|
||||
}
|
||||
|
||||
get counter() {
|
||||
@@ -23,17 +25,18 @@ class MalojaScrobbler {
|
||||
return this.#config.name;
|
||||
}
|
||||
|
||||
async scrobble(song, progress, start) {
|
||||
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(progress / 1000),
|
||||
duration: Math.round(duration / 1000),
|
||||
length: Math.round(song.duration / 1000),
|
||||
//time: start
|
||||
time: Math.round(start / 1000)
|
||||
});
|
||||
|
||||
this.#counter++;
|
||||
|
||||
31
services/scrobblers/scrobbler.js
Normal file
31
services/scrobblers/scrobbler.js
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
13
utils/docker.js
Normal file
13
utils/docker.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const fs = require("fs");
|
||||
|
||||
function isRunningOnDocker() {
|
||||
try {
|
||||
return fs.existsSync("/.dockerenv");
|
||||
} catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
isRunningOnDocker
|
||||
}
|
||||
Reference in New Issue
Block a user