mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9e09c3a6 | ||
|
|
7b45ad6e9e | ||
|
|
44ea06020b | ||
|
|
89ea2e5ee1 | ||
|
|
9fb915067e | ||
|
|
55ca8762f2 | ||
|
|
ca8589891a | ||
|
|
5383fb8280 | ||
|
|
569778d69f | ||
|
|
9fd769513d | ||
|
|
aa782d3bf7 | ||
|
|
b0fd0c6959 | ||
|
|
f05ddd2e3e | ||
|
|
63b73a5ea4 |
+5
-1
@@ -5,5 +5,9 @@ Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
.git
|
||||
spotifyCreds.json
|
||||
config/currentCreds.json
|
||||
*.log
|
||||
config/maloja.json
|
||||
config/spotify.json
|
||||
config/config.json
|
||||
/examples
|
||||
|
||||
+10
-1
@@ -1,5 +1,9 @@
|
||||
FROM node:fermium-alpine3.10
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node
|
||||
|
||||
WORKDIR /home/node/app
|
||||
@@ -8,7 +12,7 @@ COPY package*.json ./
|
||||
|
||||
USER node
|
||||
|
||||
RUN npm install
|
||||
RUN npm install --production
|
||||
|
||||
COPY --chown=node:node . .
|
||||
|
||||
@@ -19,6 +23,11 @@ RUN mkdir -p $config_dir
|
||||
VOLUME $config_dir
|
||||
ENV CONFIG_DIR=$config_dir
|
||||
|
||||
ARG log_dir=/home/node/logs
|
||||
RUN mkdir -p $log_dir
|
||||
VOLUME $log_dir
|
||||
ENV LOG_DIR=$log_dir
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
EXPOSE $PORT
|
||||
|
||||
@@ -1,65 +1,99 @@
|
||||
# spotify-scrobbler
|
||||
|
||||
[](https://github.com/FoxxMD/spotify-scrobbler/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://hub.docker.com/repository/docker/foxxmd/spotify-scrobbler)
|
||||
|
||||
A single-user, javascript app to scrobble your recent plays to [Maloja](https://github.com/krateng/maloja) (and other clients, eventually)
|
||||
|
||||
* Includes convenience web server for authorizing your spotify app
|
||||
* Persists obtained credentials to file
|
||||
* Automatically refreshes authorization for unattended use
|
||||
* Implements back off behavior if no listening activity is detected after an interval (after 10 minutes of idle it will back off to a maximum of 5 minutes between checks)
|
||||
* Displays running status and buffered log through web server
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
### Locally
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Setup/Configuration
|
||||
### [Dockerhub](https://hub.docker.com/repository/docker/foxxmd/spotify-scrobbler)
|
||||
|
||||
```
|
||||
foxxmd/spotify-scrobbler:latest
|
||||
```
|
||||
|
||||
## Setup App and Spotify
|
||||
|
||||
All configuration is done through json files or environment variables. Reference the [examples in the config folder](https://github.com/FoxxMD/spotify-scrobbler/tree/master/config) more detailed explanations and structure.
|
||||
|
||||
**A property from a json config will override the environmental variable.**
|
||||
**A property from a json config will override the corresponding environmental variable.**
|
||||
|
||||
### General
|
||||
|
||||
[JSON config example](https://github.com/FoxxMD/spotify-scrobbler/blob/master/config/config.json.example)
|
||||
|
||||
Environment Variables
|
||||
* CONFIG_DIR - Default `./config` - Sets configuration directory to look for all other configuration files (if they are not specified)
|
||||
* CONFIG_PATH - Default `CONFIG_DIR/config.json`
|
||||
* LOG_PATH - Default `true` - If `false` no logs will be written. If `string` will be the directory logs are written to
|
||||
* PORT - Default 9078 - Port to run web server on (for authentication callbacks)
|
||||
These environmental variables do not have a config file equivalent (to make Docker configuration easier)
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|--------------|-------------------------------------------------------------------------------------------|
|
||||
| `CONFIG_DIR` | - | `CWD/config` | Directory to look for all other configuration files |
|
||||
| `LOG_PATH` | - | `CWD/logs` | If `false` no logs will be written. If `string` will be the directory logs are written to |
|
||||
| `PORT` | - | 9078 | Port to run web server on |
|
||||
|
||||
**The app must have permission to write to `CONFIG_DIR` in order to store the current spotify access token.**
|
||||
|
||||
### Spotify
|
||||
|
||||
To access your Spotify history you must [register an application](https://developer.spotify.com/dashboard) to get a Client ID/Secret. Make sure to also whitelist your redirect URI in the application settings.
|
||||
|
||||
[Spotify config example](https://github.com/FoxxMD/spotify-scrobbler/blob/master/config/spotify.json.example)
|
||||
|
||||
Environment Variables
|
||||
* SPOTIFY_CONFIG_PATH - Optional, defaults to `CONFIG_DIR/spotify.json`
|
||||
* SPOTIFY_CLIENT_ID - **Required**
|
||||
* SPOTIFY_CLIENT_SECRET - **Required**
|
||||
* SPOTIFY_ACCESS_TOKEN - Optional if client/secret provided
|
||||
* SPOTIFY_REFRESH_TOKEN - Optional if client/secret provided
|
||||
* SPOTIFY_REDIRECT_URI - Optional, default is `http://localhost:{port}/callback`
|
||||
All variables have a config file equivalent which will overwrite the ENV variable if present (so config file is not required if ENVs present)
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|----------------------------------|----------------------------------------------------|
|
||||
| `SPOTIFY_CLIENT_ID` | Yes | | |
|
||||
| `SPOTIFY_CLIENT_SECRET` | Yes | | |
|
||||
| `SPOTIFY_ACCESS_TOKEN` | - | | Must include either this token or client id/secret |
|
||||
| `SPOTIFY_REFRESH_TOKEN` | - | | |
|
||||
| `SPOTIFY_REDIRECT_URI` | - | `http://localhost:{PORT}/callback` | URI must end in `callback` |
|
||||
|
||||
The app will automatically obtain new access/refresh token if needed and possible. These will override values from configuration.
|
||||
|
||||
## Setup Scrobble Clients
|
||||
|
||||
At least one client (the only one right now...) must be setup in order for the app to work. Client configurations can alternatively be configred in the main `config.json` configuration (see configuration example linked in **General** setup)
|
||||
|
||||
### Maloja
|
||||
|
||||
[Maloja config example](https://github.com/FoxxMD/spotify-scrobbler/blob/master/config/maloja.json.example)
|
||||
|
||||
Environment Variables
|
||||
* MALOJA_CONFIG_PATH - Optional, defaults to `CONFIG_DIR/maloja.json`
|
||||
* MALOJA_URL - **Required** - Base Url of your Maloja installation
|
||||
* MALOJA_API_KEY - **Required** - Api Key for scrobbling
|
||||
All variables have a config file equivalent which will overwrite the ENV variable if present (so config file is not required if ENVs present)
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|---------|-------------------------------|
|
||||
| `MALOJA_URL` | Yes | | Base URL of your installation |
|
||||
| `MALOJA_API_KEY` | Yes | | Api Key |
|
||||
|
||||
## Usage
|
||||
|
||||
Output is provided to stdout/stderr as well as file if specified in configuration.
|
||||
|
||||
On first startup you may need to authroize Spotify by visiting a callback URL. The default url to open is:
|
||||
On first startup you may need to authorize Spotify by visiting a callback URL. The default url to open is:
|
||||
|
||||
```
|
||||
https://localhost:9078/authSpotify
|
||||
```
|
||||
|
||||
Connection status and a buffered log of the last 50 events can be viewed at the root url: `https://localhost:9078`
|
||||
|
||||
### Running Directly
|
||||
|
||||
```
|
||||
@@ -68,16 +102,15 @@ node index.js
|
||||
|
||||
### Docker
|
||||
|
||||
[Docker repository](https://hub.docker.com/repository/docker/foxxmd/spotify-scrobbler)
|
||||
```
|
||||
foxxmd/spotify-scrobbler:latest
|
||||
```
|
||||
| Environmental Variable | Type | Default |
|
||||
|------------------------|--------|-------------------------|
|
||||
| `CONFIG_DIR` | Volume | `/home/node/app/config` |
|
||||
| `LOG_DIR` | Volume | `/home/node/app/logs` |
|
||||
| `PORT` | Port | 9078 |
|
||||
|
||||
Minimal configuration requires you to bind a host directory for the configuration directory in the container:
|
||||
## Examples
|
||||
|
||||
```
|
||||
docker run ... -v /path/on/host/config:/home/node/config ...
|
||||
```
|
||||
[See minimal configuration examples in the examples folder](https://github.com/FoxxMD/spotify-scrobbler/tree/master/examples)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ScrobbleClient from "./ScrobbleClient.js";
|
||||
import request from 'superagent';
|
||||
import format from 'date-fns/format/index.js';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default class MalojaScrobbler extends ScrobbleClient {
|
||||
|
||||
@@ -8,7 +8,8 @@ export default class MalojaScrobbler extends ScrobbleClient {
|
||||
|
||||
refreshScrobbles = async () => {
|
||||
const {url} = this.config;
|
||||
const resp = await request.get(`${url}/apis/mlj_1/scrobbles?since=${format(new Date(), 'yyyy/MM/dd')}&to=${format(new Date(), 'yyyy/MM/dd')}`)
|
||||
const today = dayjs().format('YYYY/MM/DD');
|
||||
const resp = await request.get(`${url}/apis/mlj_1/scrobbles?since=${today}&to=${today}&max=15`);
|
||||
this.recentScrobbles = resp.body.list.slice(0, 10);
|
||||
this.lastScrobbleCheck = new Date();
|
||||
}
|
||||
@@ -32,7 +33,7 @@ export default class MalojaScrobbler extends ScrobbleClient {
|
||||
this.logger.debug(`Scrobble with same name found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`, {label: this.name});
|
||||
return true;
|
||||
}
|
||||
this.logger.debug(`Scrobble with same name found but the start/finish times vs scrobble time diffs were too large to consider dups (Start Diff ${scrobblePlayStartDiff}s) (End Diff ${scrobblePlayDiff}s)`, {label: this.name});
|
||||
this.logger.debug(`Scrobble with same name found but the start/finish times vs scrobble time diffs were too large to consider dups (Start Diff ${scrobblePlayStartDiff.toFixed(0)}s) (End Diff ${scrobblePlayDiff.toFixed(0)}s)`, {label: this.name});
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
{
|
||||
// string specifies a directory location to log to.
|
||||
// boolean FALSE means do not log to file
|
||||
// boolean TRUE means log to working directory
|
||||
"logPath": true, // optional
|
||||
"interval": 60, // optional, number of seconds to wait before checking spotify for new tracks
|
||||
"port": 9078, // optional, port for server to start on
|
||||
"spotify": 'object or string', // can specify full spotify config object here OR a string location of json file OR do not include to use config dir location
|
||||
// array of client configurations
|
||||
"clients": [
|
||||
"interval": 60, // optional, number of seconds to wait before checking spotify for new tracks
|
||||
"spotify": {}, // optional, may specify config here, or in CONFIG_DIR/spotify.json, or as ENV vars
|
||||
|
||||
"clients": [ // may specify clients as objects, or in CONFIG_DIR/{clientType}.json, or as ENV vars
|
||||
// EX CONFIG_DIR/maloja.json
|
||||
{
|
||||
"type": "maloja", // client name
|
||||
"data": "/someLocation/aFile.json" // location to look for json configuration in
|
||||
"data": {} // config data
|
||||
},
|
||||
{
|
||||
"type": "maloja", // client name
|
||||
"data": {
|
||||
// client configuration can also be an object
|
||||
}
|
||||
},
|
||||
"maloja" // client can also be just the type if the configuration is located in the default configuration directory
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"url": "https://domain.tld", // the base url of your maloja installation
|
||||
"apiKey": "string" // your maloja api key
|
||||
"apiKey": "string" // your maloja api key
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"clientId": "string", // spotify client id
|
||||
"clientSecret": "string", // spotify client secret
|
||||
"redirectUri": "http://localhost:9078/callback", // optional, spotify callback url. useful if this installation is behind a reverse proxy
|
||||
"callbackPath": "callback" // optional, if redirectUri does not end with /callback then must specify path here
|
||||
"clientId": "string", // spotify client id
|
||||
"clientSecret": "string", // spotify client secret
|
||||
"redirectUri": "http://localhost:9078/callback", // optional, spotify redirect URI. Specify only if not the default. URI must end in "callback"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Minimal Configuration
|
||||
|
||||
Examples assume you have registered a Spotify application with the default callback url of `http://localhost:9078/callback`.
|
||||
|
||||
If you use another callback url or domain name you will need to specify at a minimum `SPOTIFY_REDIRECT_URI`.
|
||||
|
||||
## Using Environmental Variables
|
||||
|
||||
### Local
|
||||
```
|
||||
SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL=http://domain.tld MALOJA_API_KEY=1234 node index.js
|
||||
```
|
||||
|
||||
### Dockerhub
|
||||
|
||||
Note: I do not recommend running a container without a `config` volume specified or you will need to reauthorize the app everytime the container is rebuilt.
|
||||
|
||||
```
|
||||
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" foxxmd/spotify-scrobbler
|
||||
```
|
||||
|
||||
## Using Configuration
|
||||
|
||||
Reference the [example json configs.](https://github.com/FoxxMD/spotify-scrobbler/tree/master/config)
|
||||
|
||||
### Local
|
||||
|
||||
```
|
||||
node index.js
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```
|
||||
docker run -v /path/on/host/config:/home/node/app/config foxxmd/spotify-scrobbler
|
||||
```
|
||||
@@ -2,46 +2,75 @@ import fs from "fs";
|
||||
import {addAsync} from '@awaitjs/express';
|
||||
import express from 'express';
|
||||
import winston from 'winston';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import {Writable} from 'stream';
|
||||
import 'winston-daily-rotate-file';
|
||||
import {readJson, sleep, writeFile, buildTrackString} from "./utils.js";
|
||||
import SpotifyWebApi from "spotify-web-api-node";
|
||||
import MalojaScrobbler from "./clients/MalojaScrobbler.js";
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
const {format, createLogger, transports} = winston;
|
||||
const {combine, printf, timestamp} = format;
|
||||
|
||||
let output = []
|
||||
const stream = new Writable()
|
||||
stream._write = (chunk, encoding, next) => {
|
||||
output.unshift(chunk.toString());
|
||||
output.unshift(chunk.toString().replace('\n', ''));
|
||||
output = output.slice(0, 51);
|
||||
next()
|
||||
}
|
||||
const streamTransport = new winston.transports.Stream({stream})
|
||||
const streamTransport = new winston.transports.Stream({
|
||||
stream,
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
})
|
||||
|
||||
const logPath = process.env.LOG_DIR || `${process.cwd()}/logs`;
|
||||
const port = process.env.PORT ?? 9078;
|
||||
const localUrl = `http://localhost:${port}`;
|
||||
|
||||
const myFormat = printf(({level, message, label = 'App', timestamp}) => {
|
||||
return `${timestamp} [${label}] ${level}: ${message}`;
|
||||
});
|
||||
|
||||
const logger = createLogger({
|
||||
level: 'debug',
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: combine(
|
||||
timestamp(),
|
||||
timestamp(
|
||||
{
|
||||
format: () => dayjs().local().format(),
|
||||
}
|
||||
),
|
||||
myFormat
|
||||
),
|
||||
transports: [
|
||||
new transports.Console(),
|
||||
new transports.Console({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
}),
|
||||
streamTransport,
|
||||
]
|
||||
});
|
||||
|
||||
if (typeof logPath === 'string') {
|
||||
logger.add(new winston.transports.DailyRotateFile({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'scrobble-current.log',
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
}))
|
||||
}
|
||||
|
||||
const scopes = ['user-read-recently-played', 'user-read-currently-playing'];
|
||||
const state = 'random';
|
||||
let lastTrackPlayedAt = new Date();
|
||||
let lastTrackPlayedAt = undefined;
|
||||
|
||||
const configDir = process.env.CONFIG_DIR || `${process.cwd()}/config`;
|
||||
const configLocation = process.env.CONFIG_PATH || `${configDir}/config.json`;
|
||||
const workingCredentialsPath = `${configDir}/currentCreds.json`;
|
||||
|
||||
const app = addAsync(express());
|
||||
|
||||
@@ -53,58 +82,35 @@ try {
|
||||
// try to read a configuration file
|
||||
let config = {};
|
||||
try {
|
||||
config = await readJson(configLocation);
|
||||
config = await readJson(`${configDir}/config.json`);
|
||||
} catch (e) {
|
||||
logger.warn('Could not read config file');
|
||||
logger.error(e);
|
||||
logger.info('No config file or could not be read (normal if using ENV vars only)');
|
||||
}
|
||||
|
||||
// setup defaults for other configs and general config
|
||||
const {
|
||||
logPath: logPathRaw = process.env.LOG_PATH || true,
|
||||
interval = 60,
|
||||
port = process.env.PORT ?? 9078,
|
||||
spotify: spotifyConfigRaw = process.env.SPOTIFY_CONFIG_PATH || `${configDir}/spotify.json`,
|
||||
spotify,
|
||||
clients = [],
|
||||
} = config || {};
|
||||
|
||||
const localUrl = `http://localhost:${port}`;
|
||||
|
||||
// first thing, if user wants to log to file set it up now
|
||||
if (logPathRaw !== false) {
|
||||
let logPath = `${process.cwd()}/logs`;
|
||||
if (typeof logPathRaw === 'string') {
|
||||
logPath = logPathRaw;
|
||||
}
|
||||
logger.add(new winston.transports.DailyRotateFile({
|
||||
level: 'info', // don't need to add a bunch of noise to files
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'scrobble-current.log',
|
||||
filename: 'scrobble-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
}))
|
||||
}
|
||||
|
||||
if (interval < 15) {
|
||||
console.warn('Interval should be above 30 seconds...😬');
|
||||
}
|
||||
|
||||
let spotifyCreds = {};
|
||||
try {
|
||||
spotifyCreds = await readJson('./spotifyCreds.json');
|
||||
spotifyCreds = await readJson(workingCredentialsPath);
|
||||
} catch (e) {
|
||||
logger.warn('Current spotify access token was not parsable or file does not exist (this could be normal)');
|
||||
}
|
||||
|
||||
let spotifyConfig = spotifyConfigRaw;
|
||||
if (typeof spotifyConfigRaw === 'string') {
|
||||
let spotifyConfig = spotify;
|
||||
if (spotify === undefined) {
|
||||
try {
|
||||
spotifyConfig = await readJson(spotifyConfigRaw);
|
||||
spotifyConfig = await readJson(`${configDir}/spotify.json`);
|
||||
} catch (e) {
|
||||
logger.warn('Could not read spotify config file');
|
||||
logger.error(e);
|
||||
logger.warn('No spotify config file or could not be read (normal if using ENV vars only)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +149,13 @@ try {
|
||||
throw new Error('No scrobble clients were configured');
|
||||
}
|
||||
|
||||
app.getAsync('/', async function (req, res) {
|
||||
res.render('status', {
|
||||
status: spotifyAsyncFunc !== null ? 'Connected' : 'Awaiting Authorization',
|
||||
authUrl: spotifyAsyncFunc !== null ? null : `${localUrl}/authSpotify`,
|
||||
logs: output
|
||||
});
|
||||
})
|
||||
|
||||
app.getAsync('/authSpotify', async function (req, res) {
|
||||
logger.info('Redirecting to spotify authorization url');
|
||||
@@ -154,13 +167,13 @@ try {
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.getAsync(`/callback`, async function (req, res, next) {
|
||||
app.getAsync(/.*callback$/, async function (req, res, next) {
|
||||
const {error, code} = req.query;
|
||||
if (error === undefined) {
|
||||
const tokenResponse = await spotifyApi.authorizationCodeGrant(code);
|
||||
spotifyApi.setAccessToken(tokenResponse.body['access_token']);
|
||||
spotifyApi.setRefreshToken(tokenResponse.body['refresh_token']);
|
||||
await writeFile('spotifyCreds.json', JSON.stringify({
|
||||
await writeFile(workingCredentialsPath, JSON.stringify({
|
||||
token: tokenResponse.body['access_token'],
|
||||
refreshToken: tokenResponse.body['refresh_token']
|
||||
}));
|
||||
@@ -180,6 +193,8 @@ try {
|
||||
logger.info(`Server started at ${localUrl}`);
|
||||
}
|
||||
|
||||
app.set('views', './views');
|
||||
app.set('view engine', 'ejs');
|
||||
const server = await app.listen(port)
|
||||
}());
|
||||
} catch (e) {
|
||||
@@ -190,6 +205,7 @@ try {
|
||||
const pollSpotify = async function (spotifyApi, interval = 60, clients = []) {
|
||||
logger.info('Starting spotify polling', {label: 'Spotify'});
|
||||
try {
|
||||
let checkCount = 0;
|
||||
while (true) {
|
||||
let data = {};
|
||||
logger.debug('Refreshing recently played', {label: 'Spotify'})
|
||||
@@ -199,31 +215,45 @@ const pollSpotify = async function (spotifyApi, interval = 60, clients = []) {
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.statusCode === 401) {
|
||||
logger.info('Access token was not valid, attempting to refresh', {label: 'Spotify'});
|
||||
const tokenResponse = await spotifyApi.refreshAccessToken();
|
||||
const {body: {
|
||||
access_token,
|
||||
// spotify may return a new refresh token
|
||||
// if it doesn't then continue to use the last refresh token we received
|
||||
refresh_token = spotifyApi.getRefreshToken(),
|
||||
} = {}} = tokenResponse;
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
await writeFile('spotifyCreds.json', JSON.stringify({
|
||||
token: access_token,
|
||||
refreshToken: refresh_token,
|
||||
}));
|
||||
data = await spotifyApi.getMyRecentlyPlayedTracks({
|
||||
limit: 20
|
||||
});
|
||||
if (spotifyApi.getRefreshToken() === undefined) {
|
||||
throw new Error('Access token was not valid and no refresh token was present, bailing out of polling')
|
||||
}
|
||||
logger.debug('Access token was not valid, attempting to refresh', {label: 'Spotify'});
|
||||
try {
|
||||
const tokenResponse = await spotifyApi.refreshAccessToken();
|
||||
const {
|
||||
body: {
|
||||
access_token,
|
||||
// spotify may return a new refresh token
|
||||
// if it doesn't then continue to use the last refresh token we received
|
||||
refresh_token = spotifyApi.getRefreshToken(),
|
||||
} = {}
|
||||
} = tokenResponse;
|
||||
spotifyApi.setAccessToken(access_token);
|
||||
await writeFile(workingCredentialsPath, JSON.stringify({
|
||||
token: access_token,
|
||||
refreshToken: refresh_token,
|
||||
}));
|
||||
data = await spotifyApi.getMyRecentlyPlayedTracks({
|
||||
limit: 20
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Refreshing access token encountered an error', {label: 'Spotify'});
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
checkCount++;
|
||||
let newLastPLayedAt = undefined;
|
||||
const now = new Date();
|
||||
for (const playObj of data.body.items) {
|
||||
const {track: {name: trackName, duration_ms }, played_at} = playObj;
|
||||
const {track: {name: trackName, duration_ms}, played_at} = playObj;
|
||||
const playDate = new Date(played_at);
|
||||
if (lastTrackPlayedAt === undefined) {
|
||||
lastTrackPlayedAt = playDate;
|
||||
}
|
||||
// compare play time to most recent track played_at scrobble
|
||||
if (playDate.getTime() > lastTrackPlayedAt.getTime()) {
|
||||
logger.info(`New Track => ${buildTrackString(playObj)}`, {label: 'Spotify'});
|
||||
@@ -254,9 +284,22 @@ const pollSpotify = async function (spotifyApi, interval = 60, clients = []) {
|
||||
lastTrackPlayedAt = newLastPLayedAt;
|
||||
}
|
||||
}
|
||||
let sleepTime = interval;
|
||||
// don't need to do back off calc if interval is 10 minutes or greater since its already pretty light on API calls
|
||||
// and don't want to back off if we just started the app
|
||||
if (checkCount > 5 && sleepTime < 600) {
|
||||
const lastPlayToNowSecs = Math.abs(now.getTime() - lastTrackPlayedAt.getTime()) / 1000;
|
||||
// back off if last play was longer than 10 minutes ago
|
||||
const backoffThreshold = Math.min((interval * 10), 600);
|
||||
if (lastPlayToNowSecs >= backoffThreshold) {
|
||||
// back off to a maximum of 5 minutes
|
||||
sleepTime = Math.min(interval * 5, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// sleep for interval
|
||||
logger.debug(`Sleeping for interval (${interval}s)`, {label: 'Spotify'});
|
||||
await sleep(interval * 1000);
|
||||
logger.debug(`Sleeping for interval (${sleepTime}s)`, {label: 'Spotify'});
|
||||
await sleep(sleepTime * 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error occurred while in spotify polling loop', {label: 'Spotify'});
|
||||
@@ -267,37 +310,34 @@ const pollSpotify = async function (spotifyApi, interval = 60, clients = []) {
|
||||
|
||||
const createClients = async function (clientConfigs = [], configDir = '.') {
|
||||
const clients = [];
|
||||
for (const config of clientConfigs) {
|
||||
const dataType = typeof config;
|
||||
if (!['object', 'string'].includes(dataType)) {
|
||||
throw new Error('All client configs must be objects or strings');
|
||||
}
|
||||
const clientType = dataType === 'string' ? config : config.type;
|
||||
if (!clientConfigs.every(x => typeof x === 'object')) {
|
||||
throw new Error('All client from config json must be objects');
|
||||
}
|
||||
for (const clientType of ['maloja']) {
|
||||
|
||||
let clientConfig = {};
|
||||
|
||||
switch (clientType) {
|
||||
case 'maloja':
|
||||
let data = `${configDir}/maloja.json`;
|
||||
if (dataType === 'object') {
|
||||
const {data: dataProp = process.env.MALOJA_CONFIG_PATH || `${configDir}/maloja.json`} = config;
|
||||
data = dataProp;
|
||||
}
|
||||
let malojaConfig;
|
||||
if (typeof data === 'string') {
|
||||
clientConfig = clientConfigs.find(x => x.type === 'maloja') || {
|
||||
url: process.env.MALOJA_URL,
|
||||
apiKey: process.env.MALOJA_API_KEY
|
||||
};
|
||||
|
||||
if (Object.values(clientConfig).every(x => x === undefined)) {
|
||||
try {
|
||||
malojaConfig = await readJson(data);
|
||||
clientConfig = await readJson(`${configDir}/maloja.json`);
|
||||
} catch (e) {
|
||||
logger.warn('Maloja config was not parsable or file does not exist');
|
||||
// no config exists, skip this client
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
malojaConfig = data;
|
||||
}
|
||||
|
||||
const {
|
||||
url = process.env.MALOJA_URL,
|
||||
apiKey = process.env.MALOJA_API_KEY
|
||||
} = malojaConfig;
|
||||
if (url === undefined && apiKey === undefined) {
|
||||
// the user probably didn't set anything up for this client at all, don't log
|
||||
continue;
|
||||
}
|
||||
url,
|
||||
apiKey
|
||||
} = clientConfig;
|
||||
|
||||
if (url === undefined) {
|
||||
logger.warn('Maloja url not found in config');
|
||||
continue;
|
||||
@@ -306,7 +346,7 @@ const createClients = async function (clientConfigs = [], configDir = '.') {
|
||||
logger.warn('Maloja api key not found in config');
|
||||
continue;
|
||||
}
|
||||
clients.push(new MalojaScrobbler(logger, malojaConfig));
|
||||
clients.push(new MalojaScrobbler(logger, clientConfig));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
Generated
+101
-4
@@ -28,6 +28,14 @@
|
||||
"negotiator": "0.6.2"
|
||||
}
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"requires": {
|
||||
"color-convert": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -43,6 +51,11 @@
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
},
|
||||
"body-parser": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
|
||||
@@ -80,11 +93,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"bytes": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
|
||||
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
|
||||
},
|
||||
"chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"requires": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz",
|
||||
@@ -143,6 +175,11 @@
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
|
||||
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"content-disposition": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
|
||||
@@ -183,10 +220,10 @@
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz",
|
||||
"integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ=="
|
||||
"dayjs": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.9.6.tgz",
|
||||
"integrity": "sha512-HngNLtPEBWRo8EFVmHFmSXAjtCX8rGNqeXQI0Gh7wCTSqwaKgPIDqu9m07wABVopNwzvOeCb+2711vQhDlcIXw=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.2.0",
|
||||
@@ -216,6 +253,14 @@
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
|
||||
},
|
||||
"ejs": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.5.tgz",
|
||||
"integrity": "sha512-dldq3ZfFtgVTJMLjOe+/3sROTzALlL9E34V4/sDtUd/KlBSS0s6U1/+WPE1B4sj9CXHJpL1M6rhNJnc9Wbal9w==",
|
||||
"requires": {
|
||||
"jake": "^10.6.1"
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
|
||||
@@ -231,6 +276,11 @@
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
|
||||
},
|
||||
"escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
|
||||
},
|
||||
"etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -316,6 +366,14 @@
|
||||
"moment": "^2.11.2"
|
||||
}
|
||||
},
|
||||
"filelist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.1.tgz",
|
||||
"integrity": "sha512-8zSK6Nu0DQIC08mUC46sWGXi+q3GGpKydAG36k+JDba6VRpkevvOWUW5a/PhShij4+vHT9M+ghgG7eM+a9JDUQ==",
|
||||
"requires": {
|
||||
"minimatch": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"finalhandler": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
|
||||
@@ -375,6 +433,11 @@
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
|
||||
},
|
||||
"http-errors": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
|
||||
@@ -427,6 +490,24 @@
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
|
||||
},
|
||||
"jake": {
|
||||
"version": "10.8.2",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
|
||||
"integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
|
||||
"requires": {
|
||||
"async": "0.9.x",
|
||||
"chalk": "^2.4.2",
|
||||
"filelist": "^1.0.1",
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"async": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
|
||||
"integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0="
|
||||
}
|
||||
}
|
||||
},
|
||||
"kuler": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||
@@ -477,6 +558,14 @@
|
||||
"mime-db": "1.44.0"
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
||||
@@ -698,6 +787,14 @@
|
||||
"semver": "^7.3.2"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"requires": {
|
||||
"has-flag": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"text-hex": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
"homepage": "https://github.com/FoxxMD/maloja-spotify-scrobbler#readme",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.6.3",
|
||||
"date-fns": "^2.16.1",
|
||||
"dayjs": "^1.9.6",
|
||||
"ejs": "^3.1.5",
|
||||
"express": "^4.17.1",
|
||||
"spotify-web-api-node": "^5.0.0",
|
||||
"superagent": "^6.1.0",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import fs, {promises, constants} from "fs";
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export async function readJson(path) {
|
||||
await promises.access(path, constants.R_OK);
|
||||
@@ -62,5 +66,5 @@ export const buildTrackString = (obj) => {
|
||||
played_at
|
||||
} = obj;
|
||||
let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(' / ');
|
||||
return `${artistString} - ${name}, played at ${played_at}`
|
||||
return `${artistString} - ${name}, played at ${dayjs(played_at).local().format()}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<html>
|
||||
<body>
|
||||
<h2>Status: <%= status %><b></b></h2>
|
||||
<% if (authUrl) { %>
|
||||
Visit <a target="_blank" href="<%= authUrl %>">Redirect URL to start polling</a>
|
||||
<% } %>
|
||||
<h2>Log (Last 50, most recent first)</h2>
|
||||
<pre>
|
||||
<% logs.forEach(function (logEntry){ %>
|
||||
<%=logEntry%><% }) %>
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user