Compare commits
14 Commits
539b9a5055
...
main
Author | SHA1 | Date | |
---|---|---|---|
2b2cc20a1d | |||
7ef6ebb076 | |||
748d9de02a | |||
0dcbd0ad2e | |||
9335e3e32f | |||
934689763b | |||
0bfbc36952 | |||
5100d18ac6 | |||
b30dc3396a | |||
e5e0853d03 | |||
888e954fd7 | |||
b621527495 | |||
49633b7ee6 | |||
6e43d681da |
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,4 +3,5 @@ logs/
|
|||||||
config/*
|
config/*
|
||||||
!config/configuration.js
|
!config/configuration.js
|
||||||
!config.schema.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"]
|
21
app.js
21
app.js
@ -3,11 +3,25 @@ const express = require('express');
|
|||||||
const helmet = require("helmet");
|
const helmet = require("helmet");
|
||||||
const logger = require("./services/logging");
|
const logger = require("./services/logging");
|
||||||
const rateLimit = require("express-rate-limit");
|
const rateLimit = require("express-rate-limit");
|
||||||
|
const sessions = require("./services/session-manager");
|
||||||
|
const PlexTracker = require("./services/trackers/plex-tracker");
|
||||||
|
const SpotifyTracker = require("./services/trackers/spotify-tracker");
|
||||||
|
const Recorder = require("./services/recorder");
|
||||||
|
const MalojaScrobbler = require("./services/scrobblers/maloja-scrobbler");
|
||||||
|
|
||||||
const poll = require("./services/poll");
|
|
||||||
setInterval(poll, 5000);
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || config.web.port || 9111;
|
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);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@ -27,6 +41,7 @@ const limiter = rateLimit({
|
|||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(limiter);
|
app.use(limiter);
|
||||||
|
|
||||||
|
const PORT = config.web.port || 9011;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
logger.info("Listening to port " + PORT + ".");
|
logger.info("Listening to port " + PORT + ".");
|
||||||
});
|
});
|
@ -2,58 +2,89 @@ const schema = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
required: [],
|
required: [],
|
||||||
properties: {
|
properties: {
|
||||||
|
maloja: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['name', 'url', 'token'],
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
token: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
plex: {
|
plex: {
|
||||||
type: 'object',
|
type: 'array',
|
||||||
required: ['url', 'token'],
|
items: {
|
||||||
properties: {
|
type: 'object',
|
||||||
url: {
|
required: ['name', 'url', 'token', 'scrobblers'],
|
||||||
type: 'string'
|
properties: {
|
||||||
},
|
name: {
|
||||||
token: {
|
type: 'string'
|
||||||
type: 'string'
|
},
|
||||||
},
|
url: {
|
||||||
filters: {
|
type: 'string'
|
||||||
type: 'array',
|
},
|
||||||
minItems: 0,
|
token: {
|
||||||
items: {
|
type: 'string'
|
||||||
type: 'object',
|
},
|
||||||
properties: {
|
filters: {
|
||||||
library: {
|
type: 'array',
|
||||||
type: 'array',
|
minItems: 0,
|
||||||
items: {
|
items: {
|
||||||
type: 'string'
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
library: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
minItems: 0,
|
||||||
},
|
},
|
||||||
minItems: 0,
|
ip: {
|
||||||
},
|
type: 'array',
|
||||||
ip: {
|
items: {
|
||||||
type: 'array',
|
type: 'string'
|
||||||
items: {
|
},
|
||||||
type: 'string'
|
minItems: 0,
|
||||||
},
|
},
|
||||||
minItems: 0,
|
deviceId: {
|
||||||
},
|
type: 'array',
|
||||||
deviceId: {
|
items: {
|
||||||
type: 'array',
|
type: 'string'
|
||||||
items: {
|
},
|
||||||
type: 'string'
|
minItems: 0,
|
||||||
},
|
},
|
||||||
minItems: 0,
|
platform: {
|
||||||
},
|
type: 'array',
|
||||||
platform: {
|
items: {
|
||||||
type: 'array',
|
type: 'string'
|
||||||
items: {
|
},
|
||||||
type: 'string'
|
minItems: 0,
|
||||||
},
|
},
|
||||||
minItems: 0,
|
product: {
|
||||||
},
|
type: 'array',
|
||||||
product: {
|
items: {
|
||||||
type: 'array',
|
type: 'string'
|
||||||
items: {
|
},
|
||||||
type: 'string'
|
minItems: 0,
|
||||||
},
|
},
|
||||||
minItems: 0,
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
scrobblers: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
minItems: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,17 +108,30 @@ const schema = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
spotify: {
|
spotify: {
|
||||||
type: 'object',
|
type: 'array',
|
||||||
required: ['client_id', 'client_secret', 'redirect_uri'],
|
items: {
|
||||||
properties: {
|
type: 'object',
|
||||||
client_id: {
|
required: ['name', 'client_id', 'client_secret', 'redirect_uri', 'scrobblers'],
|
||||||
type: 'string'
|
properties: {
|
||||||
},
|
name: {
|
||||||
client_secret: {
|
type: 'string'
|
||||||
type: 'string'
|
},
|
||||||
},
|
client_id: {
|
||||||
redirect_uri: {
|
type: 'string'
|
||||||
type: 'string'
|
},
|
||||||
|
client_secret: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
redirect_uri: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
scrobblers: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
minItems: 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
const Ajv = require("ajv");
|
const Ajv = require("ajv");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const docker = require("../utils/docker");
|
||||||
const logger = require("../services/logging");
|
const logger = require("../services/logging");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
|
const { exit } = require("process");
|
||||||
|
|
||||||
const configurationBase = {
|
const configurationBase = {
|
||||||
plex: {
|
plex: {
|
||||||
|
name: null,
|
||||||
url: null,
|
url: null,
|
||||||
token: null,
|
token: null,
|
||||||
filters: [] // { library, ip, deviceId, platform, product }
|
filters: [], // { library, ip, deviceId, platform, product }
|
||||||
|
scrobblers: []
|
||||||
},
|
},
|
||||||
scrobble: {
|
scrobble: {
|
||||||
minimum: {
|
minimum: {
|
||||||
@ -16,9 +20,11 @@ const configurationBase = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
spotify: {
|
spotify: {
|
||||||
|
name: null,
|
||||||
client_id: null,
|
client_id: null,
|
||||||
client_secret: null,
|
client_secret: null,
|
||||||
redirect_uri: null
|
redirect_uri: null,
|
||||||
|
scrobblers: []
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
host: null,
|
host: null,
|
||||||
@ -26,12 +32,12 @@ const configurationBase = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const configurationFile = yaml.load(fs.readFileSync('config/config.yml'), yaml.JSON_SCHEMA);
|
const isDocker = docker.isRunningOnDocker();
|
||||||
const configuration = { ...configurationBase, ...configurationFile }
|
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 ajv = new Ajv({ allErrors: true });
|
||||||
const schema = require("./config.schema");
|
const schema = require("./config.schema");
|
||||||
const { exit } = require("process");
|
|
||||||
const validation = ajv.compile(schema);
|
const validation = ajv.compile(schema);
|
||||||
const valid = validation(configurationFile);
|
const valid = validation(configurationFile);
|
||||||
|
|
||||||
@ -40,4 +46,8 @@ if (!valid) {
|
|||||||
(async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })();
|
(async () => { await new Promise(resolve => setTimeout(resolve, 1000)); exit(1); })();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configuration = { ...configurationBase, ...configurationFile }
|
||||||
|
|
||||||
|
configuration.web.port ||= process.env.WEB_PORT;
|
||||||
|
|
||||||
module.exports = configuration;
|
module.exports = configuration;
|
@ -1,43 +1,31 @@
|
|||||||
class Session {
|
class Session {
|
||||||
|
#id = null;
|
||||||
|
#started = null;
|
||||||
|
#current = null;
|
||||||
|
lastScrobbleTimestamp = 0;
|
||||||
|
lastUpdateTimestamp = 0;
|
||||||
|
pauseDuration = 0;
|
||||||
|
playDuration = 0;
|
||||||
|
|
||||||
constructor(id) {
|
constructor(id) {
|
||||||
this._id = id;
|
this.#id = id;
|
||||||
this._started = Date.now();
|
this.#started = Date.now();
|
||||||
this._current = null;
|
|
||||||
this._previous = null;
|
|
||||||
this._lastScrobble = null;
|
|
||||||
this._pauseDuration = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get id() {
|
get id() {
|
||||||
return this._id;
|
return this.#id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get playing() {
|
get playing() {
|
||||||
return this._current;
|
return this.#current;
|
||||||
}
|
}
|
||||||
|
|
||||||
set playing(value) {
|
set playing(value) {
|
||||||
this._current = value;
|
this.#current = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
get started() {
|
get started() {
|
||||||
return this._started;
|
return this.#started;
|
||||||
}
|
|
||||||
|
|
||||||
get lastScrobbleTimestamp() {
|
|
||||||
return this._lastScrobble;
|
|
||||||
}
|
|
||||||
|
|
||||||
set lastScrobbleTimestamp(value) {
|
|
||||||
this._lastScrobble = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get pauseDuration() {
|
|
||||||
return this._pauseDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
set pauseDuration(value) {
|
|
||||||
this._pauseDuration = value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
29
models/song.js
Normal file
29
models/song.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
class Song {
|
||||||
|
id = null;
|
||||||
|
name = null;
|
||||||
|
album = null;
|
||||||
|
artists = [];
|
||||||
|
year = 0;
|
||||||
|
duration = 0;
|
||||||
|
progress = 0;
|
||||||
|
session = null;
|
||||||
|
state = null;
|
||||||
|
source = null;
|
||||||
|
provider = null;
|
||||||
|
|
||||||
|
constructor(id, name, album, artists, year, duration, progress, session, state, source, provider) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.album = album;
|
||||||
|
this.artists = artists;
|
||||||
|
this.year = year;
|
||||||
|
this.duration = duration;
|
||||||
|
this.progress = progress;
|
||||||
|
this.session = session;
|
||||||
|
this.state = state;
|
||||||
|
this.source = source;
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Song;
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"async-await-queue": "^2.1.4",
|
||||||
"axios": "^1.7.8",
|
"axios": "^1.7.8",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
@ -59,6 +60,12 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
@ -3,6 +3,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "app.js",
|
"main": "app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "NODE_ENV=production node app.js",
|
||||||
|
"dev": "NODE_ENV=development node app.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
@ -10,6 +12,7 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"async-await-queue": "^2.1.4",
|
||||||
"axios": "^1.7.8",
|
"axios": "^1.7.8",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"express-rate-limit": "^7.4.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 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';
|
const environment = process.env.NODE_ENV || 'development';
|
||||||
if (environment == "production") {
|
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
|
|
||||||
}
|
|
126
services/poll.js
126
services/poll.js
@ -1,126 +0,0 @@
|
|||||||
const plex = require("./plex");
|
|
||||||
const logger = require("./logging")
|
|
||||||
const config = require("../config/configuration");
|
|
||||||
const Session = require("../models/session");
|
|
||||||
const sessions = require("../services/session-manager");
|
|
||||||
const spotify = require("./spotify");
|
|
||||||
|
|
||||||
let lastTick = Date.now();
|
|
||||||
|
|
||||||
async function poll() {
|
|
||||||
const now = Date.now();
|
|
||||||
const timeDiff = now - lastTick;
|
|
||||||
const playing = [];
|
|
||||||
|
|
||||||
await spotify.loadCredentials();
|
|
||||||
await spotify.refreshTokenIfNeeded();
|
|
||||||
const spotifyTrack = await spotify.getCurrentlyPlaying();
|
|
||||||
if (spotifyTrack != null)
|
|
||||||
playing.push(spotifyTrack);
|
|
||||||
|
|
||||||
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 current of playing) {
|
|
||||||
let session = sessions.get(current.sessionKey);
|
|
||||||
if (session == null) {
|
|
||||||
session = new Session(current.sessionKey);
|
|
||||||
sessions.add(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = session.playing;
|
|
||||||
session.playing = current;
|
|
||||||
if (previous == null) {
|
|
||||||
logger.info(current, "A new session has started.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.playing.state == "paused" || previous.state == "paused") {
|
|
||||||
session.pauseDuration += timeDiff - (session.playing.playtime - previous.playtime);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkIfCanScrobble(session, previous, now, timeDiff)) {
|
|
||||||
logger.info(previous, "Scrobble");
|
|
||||||
session.pauseDuration = 0;
|
|
||||||
session.lastScrobbleTimestamp = now - (timeDiff - (previous.duration - previous.playtime));
|
|
||||||
} else if (session.playing.playtime < previous.playtime && session.playing.mediaKey != previous.mediaKey) {
|
|
||||||
session.pauseDuration = 0;
|
|
||||||
if (session.playing.playtime < timeDiff)
|
|
||||||
session.lastScrobbleTimestamp = now - session.playing.playtime;
|
|
||||||
else
|
|
||||||
session.lastScrobbleTimestamp = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = sessions.getSessionIds();
|
|
||||||
for (let sessionId of ids) {
|
|
||||||
if (playing.some(p => p.sessionKey == sessionId))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
session.playing = null;
|
|
||||||
if (checkIfCanScrobble(session, session.playing, now, timeDiff)) {
|
|
||||||
logger.info(session.playing, "Scrobble");
|
|
||||||
}
|
|
||||||
sessions.remove(sessionId);
|
|
||||||
logger.debug("Deleted old session (" + sessionId + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTick = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilter(track, filters) {
|
|
||||||
if (!filters || filters.length == 0)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
for (let filter of filters) {
|
|
||||||
if (filter.library && !filter.library.some(l => l == track.library))
|
|
||||||
continue;
|
|
||||||
if (filter.ip && !filter.ip.some(l => l == track.ip))
|
|
||||||
continue;
|
|
||||||
if (filter.deviceId && !filter.deviceId.some(l => l == track.deviceId))
|
|
||||||
continue;
|
|
||||||
if (filter.platform && !filter.platform.some(l => l == track.platform))
|
|
||||||
continue;
|
|
||||||
if (filter.product && !filter.product.some(l => l == track.product))
|
|
||||||
continue;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkIfCanScrobble(session, previous, now, timeDiff) {
|
|
||||||
if (!previous)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
let filters = [];
|
|
||||||
if (previous.source == 'plex')
|
|
||||||
filters = config.plex.filters;
|
|
||||||
|
|
||||||
if (!applyFilter(previous, filters)) {
|
|
||||||
logger.debug(previous, 'No filters got triggered. Ignoring.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrobbleDuration = config.scrobble.minimum.duration || 240;
|
|
||||||
const scrobblePercent = config.scrobble.minimum.percent || 50;
|
|
||||||
|
|
||||||
const current = session.playing;
|
|
||||||
const durationPlayed = now - (session.lastScrobbleTimestamp ?? session.started) - session.pauseDuration;
|
|
||||||
const newPlayback = current == null || current.playtime < previous.playtime && current.playtime < timeDiff;
|
|
||||||
const canBeScrobbled = durationPlayed > scrobbleDuration * 1000 || durationPlayed / previous.duration > scrobblePercent / 100.0;
|
|
||||||
|
|
||||||
return newPlayback && canBeScrobbled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInt(value) {
|
|
||||||
return !isNaN(value) &&
|
|
||||||
parseInt(Number(value)) == value &&
|
|
||||||
!isNaN(parseInt(value, 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = poll;
|
|
137
services/recorder.js
Normal file
137
services/recorder.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
const AggregateTracker = require("./trackers/aggregate-tracker");
|
||||||
|
const Session = require("../models/session");
|
||||||
|
|
||||||
|
class Recorder {
|
||||||
|
#sessions = null;
|
||||||
|
#trackers = null;
|
||||||
|
#originalTrackers = [];
|
||||||
|
#scrobblers = [];
|
||||||
|
#config = null;
|
||||||
|
#logger = null;
|
||||||
|
|
||||||
|
constructor(sessions, trackers, scrobblers, config, logger) {
|
||||||
|
this.#sessions = sessions;
|
||||||
|
this.#trackers = new AggregateTracker('aggregate', trackers);
|
||||||
|
this.#originalTrackers = trackers;
|
||||||
|
this.#scrobblers = scrobblers;
|
||||||
|
this.#config = config;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
async record() {
|
||||||
|
const now = Date.now();
|
||||||
|
const media = await this.#trackers.poll();
|
||||||
|
const contexts = media.map(m => this.#fetchContext(m));
|
||||||
|
|
||||||
|
// Find sessions that ended and that are deemable of a scrobble.
|
||||||
|
const sessionIds = this.#sessions.getSessionIds();
|
||||||
|
const stopped = sessionIds.filter(sessionId => !contexts.some(context => sessionId == context.session.id))
|
||||||
|
.map(sessionId => this.#sessions.get(sessionId))
|
||||||
|
.map(session => this.#fetchContext(session.playing));
|
||||||
|
const contextEnded = stopped.filter(context => this.#canScrobble(context.session, null, context.session.playing));
|
||||||
|
|
||||||
|
for (let context of contextEnded)
|
||||||
|
context.session.playDuration = now - (context.session.lastScrobbleTimestamp || context.session.started) - context.session.pauseDuration;
|
||||||
|
|
||||||
|
// Find ongoing sessions that have moved on to the next song.
|
||||||
|
const finishedPlaying = contexts.filter(context => this.#listen(context, now));
|
||||||
|
|
||||||
|
// Scrobble
|
||||||
|
const scrobbling = finishedPlaying.concat(contextEnded);
|
||||||
|
for (let context of scrobbling) {
|
||||||
|
await this.#scrobble(context);
|
||||||
|
|
||||||
|
if (context.session.playing == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
context.session.playDuration = context.extraDuration;
|
||||||
|
context.session.pauseDuration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove dead sessions.
|
||||||
|
for (let context of stopped) {
|
||||||
|
this.#sessions.remove(context.session.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#fetchContext(media) {
|
||||||
|
const tracker = this.#originalTrackers.find(t => t.provider == media.provider && t.name == media.source);
|
||||||
|
let session = this.#sessions.get(media.session);
|
||||||
|
if (session == null) {
|
||||||
|
session = new Session(media.session);
|
||||||
|
this.#sessions.add(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session, media, tracker, extraDuration: 0, scrobble: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
#listen(context, timestamp) {
|
||||||
|
const session = context.session;
|
||||||
|
const current = context.media;
|
||||||
|
const previous = context.session.playing;
|
||||||
|
session.playing = current;
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
this.#logger.info(current, "A new session has started.");
|
||||||
|
session.lastUpdateTimestamp = timestamp;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = current.progress != previous.progress || current.id != previous.id || current.state != previous.state;
|
||||||
|
if (!updated)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const timeDiff = timestamp - session.lastUpdateTimestamp;
|
||||||
|
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.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;
|
||||||
|
return canScrobble;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canScrobble(session, current, previous) {
|
||||||
|
if (previous == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const scrobbleDuration = this.#config.minimum.duration || 240;
|
||||||
|
const scrobblePercent = this.#config.minimum.percent || 50;
|
||||||
|
|
||||||
|
const newPlayback = current == null || current.progress < previous.progress;
|
||||||
|
const canBeScrobbled = session.playDuration > scrobbleDuration * 1000 || session.playDuration / previous.duration > scrobblePercent / 100.0;
|
||||||
|
|
||||||
|
return newPlayback && canBeScrobbled || session.playDuration >= previous.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #scrobble(context) {
|
||||||
|
this.#logger.info(context.scrobble, "Scrobble");
|
||||||
|
|
||||||
|
for (var scrobblerName of context.tracker.scrobblerNames) {
|
||||||
|
const scrobbler = this.#scrobblers.find(s => s.name == scrobblerName);
|
||||||
|
if (scrobbler == null) {
|
||||||
|
this.#logger.error(`Cannot find scrobbler by name of '${scrobblerName}'.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Recorder;
|
46
services/scrobblers/maloja-scrobbler.js
Normal file
46
services/scrobblers/maloja-scrobbler.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const Scrobbler = require("./scrobbler");
|
||||||
|
|
||||||
|
class MalojaScrobbler extends Scrobbler {
|
||||||
|
#config = null;
|
||||||
|
#counter = 0;
|
||||||
|
|
||||||
|
constructor(config, logger) {
|
||||||
|
super(logger);
|
||||||
|
this.#config = config;
|
||||||
|
|
||||||
|
if (!config.name)
|
||||||
|
throw new Error("Invalid name for Maloja scrobber.");
|
||||||
|
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}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
get counter() {
|
||||||
|
return this.#counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.#config.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(duration / 1000),
|
||||||
|
length: Math.round(song.duration / 1000),
|
||||||
|
time: Math.round(start / 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#counter++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MalojaScrobbler;
|
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
|
|
||||||
}
|
|
30
services/trackers/aggregate-tracker.js
Normal file
30
services/trackers/aggregate-tracker.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
class AggregateTracker {
|
||||||
|
#name = null;
|
||||||
|
#trackers = []
|
||||||
|
provider = null;
|
||||||
|
|
||||||
|
constructor(name, trackers) {
|
||||||
|
this.#name = name;
|
||||||
|
this.#trackers = trackers;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.#name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scrobblerNames() {
|
||||||
|
return this.#trackers.map(t => t.scrobblerNames)
|
||||||
|
.flat()
|
||||||
|
.filter((v, i, a) => a.indexOf(v) == i);
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll() {
|
||||||
|
let media = []
|
||||||
|
for (let tracker of this.#trackers)
|
||||||
|
media = media.concat(await tracker.poll());
|
||||||
|
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AggregateTracker;
|
71
services/trackers/plex-tracker.js
Normal file
71
services/trackers/plex-tracker.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const Song = require("../../models/song");
|
||||||
|
|
||||||
|
class PlexTracker {
|
||||||
|
#config = null;
|
||||||
|
#cache = [];
|
||||||
|
provider = "plex";
|
||||||
|
|
||||||
|
constructor(config) {
|
||||||
|
this.#config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.#config.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scrobblerNames() {
|
||||||
|
return this.#config.scrobblers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(useCache = false) {
|
||||||
|
if (!this.#config.token || !this.#config.url)
|
||||||
|
return [];
|
||||||
|
if (useCache)
|
||||||
|
return this.#cache;
|
||||||
|
|
||||||
|
const response = await axios.get(this.#config.url + "/status/sessions", {
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Plex-Token": this.#config.token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data.MediaContainer?.Metadata) {
|
||||||
|
this.#cache = [];
|
||||||
|
return this.#cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = response.data.MediaContainer?.Metadata.filter(m => this.#filter(m));
|
||||||
|
this.#cache = filtered.map(m => this.#transform(m, this.#config.name));
|
||||||
|
return this.#cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter(data) {
|
||||||
|
if (!this.#config.filters || this.#config.filters.length == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
for (let filter of this.#config.filters) {
|
||||||
|
if (filter.library && !filter.library.some(l => l == data.librarySectionTitle))
|
||||||
|
continue;
|
||||||
|
if (filter.ip && !filter.ip.some(l => l == data.address))
|
||||||
|
continue;
|
||||||
|
if (filter.deviceId && !filter.deviceId.some(l => l == data.machineIdentifier))
|
||||||
|
continue;
|
||||||
|
if (filter.platform && !filter.platform.some(l => l == data.Player.platform))
|
||||||
|
continue;
|
||||||
|
if (filter.product && !filter.product.some(l => l == data.Player.product))
|
||||||
|
continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#transform(data, source) {
|
||||||
|
const id = data.guid.substring(data.guid.lastIndexOf('/') + 1);
|
||||||
|
const artists = data.grandparentTitle.split(',').map(a => a.trim());
|
||||||
|
return new Song(id, data.title, data.parentTitle, artists, data.parentYear, data.duration, data.viewOffset, data.sessionKey, data.Player.state, source, "plex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PlexTracker;
|
107
services/trackers/spotify-tracker.js
Normal file
107
services/trackers/spotify-tracker.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
const axios = require("axios");
|
||||||
|
const logger = require("../logging");
|
||||||
|
const fs = require("fs/promises");
|
||||||
|
const fss = require("fs");
|
||||||
|
const Song = require("../../models/song");
|
||||||
|
|
||||||
|
class SpotifyTracker {
|
||||||
|
#config = null;
|
||||||
|
#token = null;
|
||||||
|
#cache = [];
|
||||||
|
#auth = null;
|
||||||
|
provider = "spotify";
|
||||||
|
|
||||||
|
constructor(config, token = null) {
|
||||||
|
this.#config = config;
|
||||||
|
this.#token = token;
|
||||||
|
this.#auth = new Buffer.from(config.client_id + ':' + config.client_secret).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.#config.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scrobblerNames() {
|
||||||
|
return this.#config.scrobblers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(useCache = false) {
|
||||||
|
if (this.#token == null)
|
||||||
|
return [];
|
||||||
|
if (useCache)
|
||||||
|
return this.#cache;
|
||||||
|
|
||||||
|
if (this.#token.expires_at < Date.now() + 300)
|
||||||
|
await this.#refreshTokenIfNeeded();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get("https://api.spotify.com/v1/me/player/currently-playing",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Bearer " + this.#token.access_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data) {
|
||||||
|
this.#cache = [];
|
||||||
|
return this.#cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#cache = [this.#transform(response.data, this.#config.name)];
|
||||||
|
return this.#cache;
|
||||||
|
} catch (ex) {
|
||||||
|
logger.error(ex, "Failed to get currently playing data from Spotify.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCredentials() {
|
||||||
|
if (!fss.existsSync("credentials.spotify.json")) {
|
||||||
|
logger.info("No Spotify credentials found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile("credentials.spotify.json", "utf-8");
|
||||||
|
this.#token = JSON.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#transform(data, source) {
|
||||||
|
const item = data.item;
|
||||||
|
const artists = item.artists.map(a => a.name);
|
||||||
|
const year = null;
|
||||||
|
const state = data.is_playing ? "playing" : "paused";
|
||||||
|
return new Song(item.id, item.name, item.album.name, artists, year, item.duration_ms, data.progress_ms, "spotify", state, source, "spotify");
|
||||||
|
}
|
||||||
|
|
||||||
|
async #refreshTokenIfNeeded() {
|
||||||
|
if (!this.#token || this.#token.expires_at && this.#token.expires_at - Date.now() > 900)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const response = await axios.post("https://accounts.spotify.com/api/token",
|
||||||
|
{
|
||||||
|
client_id: this.#config.client_id,
|
||||||
|
refresh_token: this.#token.refresh_token,
|
||||||
|
grant_type: "refresh_token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Basic " + this.#auth,
|
||||||
|
"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"] = this.#token.refresh_token;
|
||||||
|
|
||||||
|
this.#token = data;
|
||||||
|
await fs.writeFile("credentials.spotify.json", JSON.stringify(data));
|
||||||
|
logger.debug("Updated access token for Spotify.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SpotifyTracker;
|
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