Compare commits

...

4 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
10 changed files with 158 additions and 252 deletions

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ logs/
config/*
!config/configuration.js
!config.schema.js
credentials.*
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"]

17
app.js
View File

@@ -10,11 +10,16 @@ const Recorder = require("./services/recorder");
const MalojaScrobbler = require("./services/scrobblers/maloja-scrobbler");
const maloja = new MalojaScrobbler(config.maloja, logger);
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 + ".");
});

View File

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

View File

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

View File

@@ -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": "",

View File

@@ -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") {

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,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
View File

@@ -0,0 +1,13 @@
const fs = require("fs");
function isRunningOnDocker() {
try {
return fs.existsSync("/.dockerenv");
} catch { }
return false;
}
module.exports = {
isRunningOnDocker
}