Compare commits

...
11 Commits
Author SHA1 Message Date
Matt Foxx 403af711eb Merge pull request #20 from christophernewton/master
Frontend update
2021-03-01 16:36:07 -05:00
Chris Newton 31cd72cd15 feat: fixed typo in debug output, added active state for links 2021-03-02 08:24:32 +11:00
Chris Newton 55afe876e0 feat: fixed typo in readme for docker 2021-02-27 14:31:41 +11:00
Chris Newton 49fadd8c9a feat: added styles, dark mode to frontend 2021-02-27 14:30:39 +11:00
Chris Newton 58157ddfee wip: updating styles for front end 2021-02-26 16:54:08 +11:00
FoxxMD 2a9cd87848 Update package properties 2021-02-25 09:20:37 -05:00
FoxxMD 7b31285f89 Fix missing throw error from polling try-catch
Without it the polling continues forever on loop! oops
2021-02-19 09:14:10 -05:00
FoxxMD 89db858289 Fix import error for Errors from spotify-web-api-node 2021-02-19 09:13:16 -05:00
FoxxMD 2fec6aff6e Implement request and polling retries
* Hierarchical retries and delay options for sources and clients (override general config => individual config)
* Logging for retry attempts
* Respect Retry-After header on responses if present
2021-02-18 15:01:21 -05:00
FoxxMD 9beadfaf0f Bump dependencies to fix spotify-web-api-node crash
spotify-web-api-node#340
2021-02-18 10:04:12 -05:00
FoxxMD dd7e971e71 Fix how scrobbled tracks are returned to source
Returns tracks array should only show that was a track was scrobbled or not (by existing in the array) -- and only be in the array once.
2021-01-12 09:47:47 -05:00
20 changed files with 1710 additions and 143 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ npm install
### [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
```
foxxmd/spotify-scrobbler:latest
foxxmd/multi-scrobbler:latest
```
## Setup
@@ -61,7 +61,7 @@ SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL=http://doma
#### Docker
```bash
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -v /path/on/host/config:/home/node/app/config foxxmd/spotify-scrobbler
docker run -e "SPOTIFY_CLIENT_ID=yourId" -e "SPOTIFY_CLIENT_SECRET=yourSecret" -e "MALOJA_URL=http://domain.tld" -e "MALOJA_API_KEY=1234" -v /path/on/host/config:/home/node/app/config foxxmd/multi-scrobbler
```
**But I want to use json for configuration?**
+10 -5
View File
@@ -2,7 +2,7 @@ import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import dayjs from 'dayjs';
import LastFm from 'lastfm-node-client';
import {
buildTrackString,
buildTrackString, parseRetryAfterSecsFromObj,
playObjDataMatch,
readJson,
setIntersection, sleep,
@@ -74,7 +74,12 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
formatPlayObj = obj => LastfmScrobbler.formatPlayObj(obj);
callApi = async (func, tries = 1) => {
callApi = async (func, retries = 0) => {
const {
maxRequestRetries = 2,
retryMultiplier = 1.5
} = this.config;
try {
return await func(this.client);
} catch (e) {
@@ -84,11 +89,11 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
// for now check for exceptional errors by matching error code text
const retryError = retryErrors.find(x => message.toLocaleLowerCase().includes(x));
if(undefined !== retryError) {
if(tries <= 2) {
const delay = tries * 3;
if(retries < maxRequestRetries) {
const delay = (retries + 1) * retryMultiplier;
this.logger.warn(`API call was not good but recoverable (${retryError}), retrying in ${delay} seconds...`);
await sleep(delay * 1000);
return this.callApi(func, tries + 1);
return this.callApi(func, retries + 1);
} else {
this.logger.warn('Could not recover!');
throw e;
+21 -2
View File
@@ -1,7 +1,15 @@
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import request from 'superagent';
import dayjs from 'dayjs';
import {buildTrackString, playObjDataMatch, setIntersection, sortByPlayDate, truncateStringToLength} from "../utils.js";
import {
buildTrackString,
playObjDataMatch,
setIntersection,
sleep,
sortByPlayDate,
truncateStringToLength,
parseRetryAfterSecsFromObj
} from "../utils.js";
const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"];
@@ -52,10 +60,21 @@ export default class MalojaScrobbler extends AbstractScrobbleClient {
formatPlayObj = obj => MalojaScrobbler.formatPlayObj(obj);
callApi = async (req) => {
callApi = async (req, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 1.5
} = this.config;
try {
return await req;
} catch (e) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
}
const {
message,
response: {
+23 -6
View File
@@ -1,5 +1,11 @@
import dayjs from "dayjs";
import {createLabelledLogger, isValidConfigStructure, readJson, returnDuplicateStrings} from "../utils.js";
import {
createLabelledLogger,
isValidConfigStructure,
playObjDataMatch,
readJson,
returnDuplicateStrings
} from "../utils.js";
import MalojaScrobbler from "./MalojaScrobbler.js";
import LastfmScrobbler from "./LastfmScrobbler.js";
@@ -29,8 +35,13 @@ export default class ScrobbleClients {
} catch (e) {
throw new Error('config.json could not be parsed');
}
let clientDefaults = {};
if (configFile !== undefined) {
const {clients: mainConfigClientConfigs = []} = configFile;
const {
clients: mainConfigClientConfigs = [],
clientDefaults: cd = {},
} = configFile;
clientDefaults = cd;
if (!mainConfigClientConfigs.every(x => x !== null && typeof x === 'object')) {
throw new Error('All clients from config.json must be objects');
}
@@ -156,16 +167,18 @@ ${sources.join('\n')}`);
name
}));
for (const c of finalConfigs) {
await this.addClient(c);
await this.addClient(c, clientDefaults);
}
}
addClient = async (clientConfig) => {
addClient = async (clientConfig, defaults = {}) => {
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
if (isValidConfig !== true) {
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
}
const {type, name, data = {}} = clientConfig;
const {type, name, data: d = {}} = clientConfig;
// add defaults
const data = {...defaults, ...d};
switch (type) {
case 'maloja':
this.logger.debug(`(${name}) Attempting Maloja initialization...`);
@@ -242,7 +255,11 @@ ${sources.join('\n')}`);
if (client.timeFrameIsValid(playObj, newFromSource) && !client.alreadyScrobbled(playObj, newFromSource)) {
await client.scrobble(playObj)
client.tracksScrobbled++;
tracksScrobbled.push(playObj);
// since this is what we return to the source only add to tracksScrobbled if not already in array
// (source should only know that a track was scrobbled (binary) -- doesn't care if it was scrobbled more than once
if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) {
tracksScrobbled.push(playObj);
}
}
} catch(e) {
this.logger.error(`Encountered error while in scrobble loop for ${client.name}`);
+9
View File
@@ -1,4 +1,13 @@
{
"sourceDefaults": {
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"clientDefaults": {
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"sources": [
{
"type": "spotify", // required, source type
+1
View File
@@ -8,6 +8,7 @@
// if not specified will be generated during authentication
"redirectUri": "http://localhost:9078/lastfm/callback" // optional, if not different than this default
// callback for auth. Must have "lastfm/callback" in the url somewhere
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
}
}
]
+1
View File
@@ -4,6 +4,7 @@
"data": {
"url": "https://domain.tld", // required, the base url of your maloja installation
"apiKey": "string" // required, your maloja api key
// ALSO see config.json.example for default properties that can be overridden here (in clientDefaults)
}
}
]
+2 -1
View File
@@ -8,7 +8,8 @@
"accessToken": "string", // spotify access token -- required if not providing client id/secret
"refreshToken": "string", // spotify refresh token -- recommended to provide if not providing client id/secret
"redirectUri": "http://localhost:9078/callback",// spotify redirect URI -- required only if not the default shown here. URI must end in "callback"
"interval": 60 // optional, how long to wait before calling spotify for new tracks
"interval": 60, // optional, how long to wait before calling spotify for new tracks
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
}
}
]
+2 -1
View File
@@ -4,7 +4,8 @@
"data": {
"url": "http://localhost:4040/airsonic",// required, the url you would visit to listen to music on the web
"user": "yourUser", // required, username to login with
"password": "yourPassword" // required, password to login with
"password": "yourPassword", // required, password to login with
// ALSO see config.json.example for default properties that can be overridden here (in sourceDefaults)
}
}
]
+10
View File
@@ -18,6 +18,15 @@ Using just one config file located at `CONFIG_DIR/config.json`:
```json5
{
"sourceDefaults": {
"maxPollRetries": 0, // optional, default # of automatic polling restarts on error. can be overridden by property in individual config
"maxRequestRetries": 1, // optional, default # of http request retries a source can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"clientDefaults": {
"maxRequestRetries": 1, // optional, default # of http request retries a client can make before error is thrown. can be overridden by property in individual config
"retryMultiplier": 1.5, // optional, default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying). can be overridden by property in individual config
},
"sources": [
{
"type": "spotify",
@@ -26,6 +35,7 @@ Using just one config file located at `CONFIG_DIR/config.json`:
"data": {
"clientId": "foxxSpotifyAppId",
"clientSecret": "foxxSpotifyAppSecret",
"maxRequestRetries": 2, // override default max retries because spotify can...spotty
}
},
{
+9 -4
View File
@@ -35,7 +35,12 @@ const {transports} = winston;
let output = []
const stream = new Writable()
stream._write = (chunk, encoding, next) => {
output.unshift(chunk.toString().replace('\n', ''));
let formatString = chunk.toString().replace('\n', '<br />')
.replace(/(debug)/gi, '<span class="debug text-pink-400">$1</span>')
.replace(/(warn)/gi, '<span class="warn text-blue-400">$1</span>')
.replace(/(info)/gi, '<span class="info text-yellow-500">$1</span>')
.replace(/(error)/gi, '<span class="error text-red-400">$1</span>')
output.unshift(formatString);
output = output.slice(0, 101);
next()
}
@@ -198,9 +203,9 @@ app.use(bodyParser.json());
clients: clientData,
logs: {
output: slicedLog,
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'bold' : ''}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'bold' : ''}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
level: availableLevels.map(x => `<a class="capitalize ${logConfig.level === x ? 'bold' : ''}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
limit: [10, 20, 50, 100].map(x => `<a class="capitalize ${logConfig.limit === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${logConfig.sort === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
level: availableLevels.map(x => `<a class="capitalize ${logConfig.level === x ? 'font-bold no-underline pointer-events-none' : ''}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | ')
}
});
})
+1317 -14
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1,7 +1,7 @@
{
"name": "maloja-spotify-scrobbler",
"name": "multi-scrobbler",
"version": "0.1.0",
"description": "",
"description": "scrobble plays from multiple sources to multiple clients",
"type": "module",
"main": "index.js",
"scripts": {
@@ -13,24 +13,24 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/FoxxMD/maloja-spotify-scrobbler.git"
"url": "git+https://github.com/FoxxMD/multi-scrobbler.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/FoxxMD/maloja-spotify-scrobbler/issues"
"url": "https://github.com/FoxxMD/multi-scrobbler/issues"
},
"homepage": "https://github.com/FoxxMD/maloja-spotify-scrobbler#readme",
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
"dependencies": {
"@awaitjs/express": "^0.6.3",
"body-parser": "^1.19.0",
"dayjs": "^1.9.6",
"ejs": "^3.1.5",
"dayjs": "^1.10.4",
"ejs": "^3.1.6",
"express": "^4.17.1",
"lastfm-node-client": "^2.2.0",
"multer": "^1.4.2",
"safe-stable-stringify": "^1.1.1",
"spotify-web-api-node": "^5.0.0",
"spotify-web-api-node": "^5.0.2",
"superagent": "^6.1.0",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0"
+31 -1
View File
@@ -13,6 +13,7 @@ export default class AbstractSource {
canPoll = false;
polling = false;
pollRetries = 0;
tracksDiscovered = 0;
constructor(type, name, config = {}, clients = []) {
@@ -39,10 +40,38 @@ export default class AbstractSource {
await this.startPolling(allClients);
}
startPolling = async (allClients) => {
// reset poll attempts if already previously run
this.pollRetries = 0;
const {
maxPollRetries = 0,
retryMultiplier = 1.5,
} = this.config;
// can't have negative retries!
const maxRetries = Math.max(0, maxPollRetries);
while (this.pollRetries <= maxRetries) {
try {
await this.doPolling(allClients);
} catch (e) {
if (this.pollRetries < maxRetries) {
const delayFor = (this.pollRetries + 1) * retryMultiplier;
this.logger.info(`Poll reties (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`);
await sleep((delayFor) * 1000);
} else {
this.logger.warn(`Poll retries (${this.pollRetries}) equal to max poll retries (${maxRetries}), stopping polling!`);
}
this.pollRetries++;
}
}
}
/**
* @param {ScrobbleClients} allClients
*/
startPolling = async (allClients) => {
doPolling = async (allClients) => {
if (this.polling === true) {
return;
}
@@ -143,6 +172,7 @@ export default class AbstractSource {
this.logger.error('Error occurred while polling');
this.logger.error(e);
this.polling = false;
throw e;
}
}
}
+11 -4
View File
@@ -40,8 +40,13 @@ export default class ScrobbleSources {
} catch (e) {
throw new Error('config.json could not be parsed');
}
let sourceDefaults = {};
if (configFile !== undefined) {
const {sources: mainConfigSourcesConfigs = []} = configFile;
const {
sources: mainConfigSourcesConfigs = [],
sourceDefaults: sd = {},
} = configFile;
sourceDefaults = sd;
if (!mainConfigSourcesConfigs.every(x => x !== null && typeof x === 'object')) {
throw new Error('All sources from config.json must be objects');
}
@@ -196,18 +201,20 @@ export default class ScrobbleSources {
name: hasDups ? `${name}${i + 1}` : name
}));
for (const c of tempNamedConfigs) {
await this.addSource(c);
await this.addSource(c, sourceDefaults);
}
}
}
}
addSource = async (clientConfig) => {
addSource = async (clientConfig, defaults = {}) => {
const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
if (isValidConfig !== true) {
throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
}
const {type, name, clients = [], data = {}} = clientConfig;
const {type, name, clients = [], data: d = {}} = clientConfig;
// add defaults
const data = {...defaults, ...d};
this.logger.debug(`(${name}) Initializing ${type} source`);
switch (type) {
case 'spotify':
+12 -3
View File
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import {
readJson,
writeFile,
sortByPlayDate,
sortByPlayDate, sleep, parseRetryAfterSecsFromObj,
} from "../utils.js";
import SpotifyWebApi from "spotify-web-api-node";
import AbstractSource from "./AbstractSource.js";
@@ -174,7 +174,11 @@ export default class SpotifySource extends AbstractSource {
return result;
}
callApi = async (func) => {
callApi = async (func, retries = 0) => {
const {
maxRequestRetries = 1,
retryMultiplier = 2,
} = this.config;
try {
return await func(this.spotifyApi);
} catch (e) {
@@ -205,8 +209,13 @@ export default class SpotifySource extends AbstractSource {
this.logger.error(ee, {label: 'Spotify'});
throw ee;
}
} else if(maxRequestRetries > retries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return this.callApi(func, retries + 1);
} else {
this.logger.error('Refreshing access token encountered an error');
this.logger.error(`Request failed on retry (${retries}) with no more retries permitted (max ${maxRequestRetries})`);
this.logger.error(e, {label: 'Spotify'});
throw e;
}
+15 -4
View File
@@ -3,7 +3,7 @@ import request from 'superagent';
import crypto from 'crypto';
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import {buildTrackString} from "../utils.js";
import {buildTrackString, parseRetryAfterSecsFromObj, sleep} from "../utils.js";
dayjs.extend(isSameOrAfter);
@@ -65,8 +65,13 @@ export class SubsonicSource extends AbstractSource {
return isValid;
}
callApi = async (req) => {
const {user, password} = this.config;
callApi = async (req, retries = 0) => {
const {
user,
password,
maxRequestRetries = 1,
retryMultiplier = 1.5
} = this.config;
const salt = await crypto.randomBytes(10).toString('hex');
const hash = crypto.createHash('md5').update(`${password}${salt}`).digest('hex')
@@ -77,7 +82,7 @@ export class SubsonicSource extends AbstractSource {
v: '1.15.0',
c: `multi-scrobbler - ${this.name}`,
f: 'json'
})
});
try {
const resp = await req;
const {
@@ -95,6 +100,12 @@ export class SubsonicSource extends AbstractSource {
}
return ssResp;
} catch (e) {
if(retries < maxRequestRetries) {
const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
await sleep(retryAfter * 1000);
return await this.callApi(req, retries + 1)
}
const {
message,
response: {
+67
View File
@@ -3,6 +3,8 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import winston from "winston";
import jsonStringify from 'safe-stable-stringify';
import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js";
import { Response } from 'superagent';
const {format} = winston;
const {combine, printf, timestamp, label, splat, errors} = format;
@@ -260,3 +262,68 @@ export const playObjDataMatch = (a, b) => {
return true;
}
export const parseRetryAfterSecsFromObj = (err) => {
let raVal;
if (err instanceof TimeoutError) {
return undefined;
}
if (err instanceof WebapiError || err instanceof Response) {
const {headers = {}} = err;
raVal = headers['retry-after']
}
// if (err instanceof Response) {
// const {headers = {}} = err;
// raVal = headers['retry-after']
// }
const {
response: {
headers, // returned in superagent error
} = {},
retryAfter: ra // possible custom property we have set
} = err;
if (ra !== undefined) {
raVal = ra;
} else if (headers !== null && typeof headers === 'object') {
raVal = headers['retry-after'];
}
if (raVal === undefined || raVal === null) {
return raVal;
}
// first try to parse as float
let retryAfter = Number.parseFloat(raVal);
if (!isNaN(retryAfter)) {
return retryAfter; // got a number!
}
// try to parse as date
retryAfter = dayjs(retryAfter);
if (!dayjs.isDayjs(retryAfter)) {
return undefined; // could not parse string if not in ISO 8601 format
}
// otherwise we got a date! now get the difference the specified retry-after date and now in seconds
const diff = retryAfter.diff(dayjs(), 'second');
if (diff <= 0) {
// if diff is in the past returned undefined as its irrelevant now
return undefined;
}
return diff;
}
export const spreadDelay = (retries, multiplier) => {
if(retries === 0) {
return [];
}
let r;
let s = [];
for(r = 0; r < retries; r++) {
s.push(((r+1) * multiplier) * 1000);
}
return s;
}
+59 -20
View File
@@ -1,26 +1,65 @@
<html>
<head>
<style>
small {
display: block;
}
.bold {
font-weight: bold;
}
.capitalize {
text-transform: capitalize;
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css" integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ==" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css" integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg==" crossorigin="anonymous" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@catalyst-elements/catalyst-toggle-switch@0.7.0/catalyst-toggle-switch.min.js"></script>
<style>
a {
text-decoration: underline;
}
</style>
<title>Multi Scrobbler - Status</title>
</head>
<body>
<h2>Recently Played (<%= name %>)</h2>
<% if (sourceType === 'spotify') { %>
This list was returned from the Spotify Api <b>now.</b> Sometimes Spotify "backlogs" user activity so your recent tracks may not show up for minutes (or hours). ¯\_(ツ)_/¯
<% } %>
<ul>
<% plays.forEach(function (play){ %>
<li><pre><%-play%></pre></li>
<% }) %>
</ul>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
<div class="container mx-auto">
<div class="flex items-center justify-between">
<a href="/" class="flex items-center flex-grow no-underline">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+" style="max-width:100px; max-height:30px;"/>
<span class="px-4 break-normal">
Multi Scrobbler
</span>
</a>
<div>
Dark mode <catalyst-toggle-switch id="toggle-switch"></catalyst-toggle-switch>
</div>
</div>
</div>
</div>
<div class="container mx-auto">
<div class="grid">
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h3>Recently Played (<%= name %>)</h3>
</div>
<div class="p-6 md:px-10 md:py-6">
<% if (sourceType === 'spotify') { %>
This list was returned from the Spotify Api <b>now.</b> Sometimes Spotify "backlogs" user activity so your recent tracks may not show up for minutes (or hours). ¯\_(ツ)_/¯
<% } %>
<ul>
<% plays.forEach(function (play){ %>
<li><%-play%></li>
<% }) %>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
let toggleSwitch = document.querySelector('#toggle-switch');
toggleSwitch.addEventListener('change', (event) => {
document.body.classList.toggle('dark')
toggleSwitch.checked ? localStorage.setItem('ms-dark', 'yes') : localStorage.setItem('ms-dark', 'no')
});
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
if (localStorage.getItem('ms-dark') === 'yes') {
document.body.classList.add('dark')
toggleSwitch.checked = true
localStorage.setItem('ms-dark', 'yes')
}
}
</script>
</body>
</html>
+100 -68
View File
@@ -1,75 +1,107 @@
<html>
<head>
<style>
small {
display: block;
}
.bold {
font-weight: bold;
}
.capitalize {
text-transform: capitalize;
}
.facetContainer {
flex-wrap: wrap;
display: flex;
}
.facetItem {
flex: 1 0 auto;
}
.disabled {
color: currentColor;
cursor: not-allowed;
opacity: 0.5;
text-decoration: none;
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css" integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ==" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css" integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg==" crossorigin="anonymous" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@catalyst-elements/catalyst-toggle-switch@0.7.0/catalyst-toggle-switch.min.js"></script>
<style>
a {
text-decoration: underline;
}
</style>
<title>Multi Scrobbler</title>
</head>
<body>
<h2>Sources</h2>
<div class="facetContainer">
<% sources.forEach(function (source){ %>
<div class="facetItem">
<h3><%= source.display %> - <%= source.name %></h3>
<ul>
<li><b>Status: <%= source.status %></b></li>
<li>Tracks Discovered (since app started): <%= source.tracksDiscovered %></li>
<% if (source.canPoll === true) { %>
<li>Click <a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="recent?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>to see recently played tracks returned by API</a></li>
<% if (source.hasAuth) { %>
<li>Click <a target="_blank" href="source/auth?name=<%= source.name %>&type=<%= source.type %>">to (re)authenticate and (re)start polling</a></li>
<% } %>
<li>Click <a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="poll?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>to restart polling</a></li>
<% } %>
</ul>
</div>
<% }) %>
</div>
<h2>Clients</h2>
<div class="facetContainer">
<% clients.forEach(function (client){ %>
<div class="facetItem">
<h3><%= client.display %> - <%= client.name %></h3>
<ul>
<li><b>Status: <%= client.status %></b></li>
<li>Tracks Scrobbled (since app started): <%= client.tracksDiscovered %></li>
<% if (client.hasAuth) { %>
<li>Click <a target="_blank" href="client/auth?name=<%= client.name %>&type=<%= client.type %>">to (re)authenticate or initialize</a></li>
<% } %>
</ul>
<body class="">
<script>localStorage.getItem('ms-dark') === 'yes' ? document.body.classList.add('dark') : document.body.classList.remove('dark')</script>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
<div class="container mx-auto">
<div class="flex items-center justify-between">
<a href="/" class="flex items-center flex-grow no-underline">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyODkuNTUgMTM1LjE3Ij48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6IzNkNTNhNDt9LmNscy0ye2ZpbGw6I2ZmZjt9PC9zdHlsZT48L2RlZnM+PGcgaWQ9IkxheWVyXzEiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTI5Ljg3LDE3My4zNCwzOS4zOCwyNjMuODNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xMTQsMTU3LjQzbC03OS4zOSw3OS40LTExLjEsMTEuMDljLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMGw3OS40LTc5LjM5LDExLjA5LTExLjFjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgycy0yMi45My04Ljg5LTMxLjgyLDBsLTc5LjM5LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMDktMTEuMWM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJTMTIyLjg1LDE0OC41NCwxMTQsMTU3LjQzWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE2Ljk5IC0xNTEpIi8+PC9nPjxnIGlkPSJMYXllcl8xX2NvcHkiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5Ij48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0xOTEuODgsMTU3LjQzbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MnMtMjIuOTMtOC44OS0zMS44MiwwbC03OS40LDc5LjQtMTEuMSwxMS4wOWMtOC4zMyw4LjM0LTguOTUsMjMuNiwwLDMxLjgyczIyLjkzLDguOSwzMS44MiwwbDc5LjQtNzkuMzksMTEuMS0xMS4xYzguMzMtOC4zMyw5LTIzLjYsMC0zMS44MlMyMDAuNzcsMTQ4LjU0LDE5MS44OCwxNTcuNDNaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PGcgaWQ9IkxheWVyXzFfY29weV8yIiBkYXRhLW5hbWU9IkxheWVyIDEgY29weSAyIj48cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0yNjguMjMsMTU3LjQzbC0yOC43NywyOC43OGMtOC4zNCw4LjMzLTksMjMuNiwwLDMxLjgyczIyLjkyLDguODksMzEuODIsMGwyOC43Ny0yOC43OGM4LjMzLTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkzLTguODktMzEuODIsMGwtMjguNzcsMjguNzhjLTguMzQsOC4zMy05LDIzLjYsMCwzMS44MnMyMi45Miw4Ljg5LDMxLjgyLDBsMjguNzctMjguNzhjOC4zMy04LjMzLDktMjMuNiwwLTMxLjgyUzI3Ny4xMiwxNDguNTQsMjY4LjIzLDE1Ny40M1oiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNi45OSAtMTUxKSIvPjwvZz48ZyBpZD0iTGF5ZXJfMV9jb3B5XzMiIGRhdGEtbmFtZT0iTGF5ZXIgMSBjb3B5IDMiPjxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTIwMy40OSwyMjIuMTcsMTc5LjEsMjQ2LjU2Yy04LjMzLDguMzQtOC45NSwyMy42LDAsMzEuODJzMjIuOTMsOC45LDMxLjgyLDBMMjM1LjMxLDI1NGM4LjM0LTguMzMsOS0yMy42LDAtMzEuODJzLTIyLjkyLTguODktMzEuODIsMEwxNzkuMSwyNDYuNTZjLTguMzMsOC4zNC04Ljk1LDIzLjYsMCwzMS44MnMyMi45Myw4LjksMzEuODIsMEwyMzUuMzEsMjU0YzguMzQtOC4zMyw5LTIzLjYsMC0zMS44MlMyMTIuMzksMjEzLjI4LDIwMy40OSwyMjIuMTdaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYuOTkgLTE1MSkiLz48L2c+PC9zdmc+" style="max-width:100px; max-height:30px;"/>
<span class="px-4 break-normal">
Multi Scrobbler
</span>
</a>
<div>
Dark mode <catalyst-toggle-switch id="toggle-switch"></catalyst-toggle-switch>
</div>
</div>
</div>
<% }) %>
</div>
<div class="container mx-auto">
<div class="grid md:grid-cols-3 gap-3 ">
<% sources.forEach(function (source){ %>
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h3><%= source.display %> - <%= source.name %></h3>
</div>
<div class="p-6 md:px-10 md:py-6">
<div><b>Status: <%= source.status %></b></div>
<div>Tracks Discovered (since app started): <%= source.tracksDiscovered %></div>
<% if (source.canPoll === true) { %>
<div><a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="recent?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>See recently played tracks returned by API</a></div>
<% if (source.hasAuth) { %>
<div><a target="_blank" href="source/auth?name=<%= source.name %>&type=<%= source.type %>">(Re)authenticate and (re)start polling</a></div>
<% } %>
<div><a target="_blank" <% if (!source.hasAuth || source.authed) { %> href="poll?name=<%= source.name %>&type=<%= source.type %>" <% } else { %>class="disabled"<% } %>>Restart polling</a></div>
<% } %>
</div>
</div>
<% }) %>
</div>
<div class="grid md:grid-cols-3 gap-3">
<% clients.forEach(function (client){ %>
<div class="bg-white shadow-md rounded my-6 px-4 py-4 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h3><%= client.display %> - <%= client.name %></h3>
</div>
<div class="p-6 md:px-10 md:py-6">
<ul>
<div><b>Status: <%= client.status %></b></div>
<div>Tracks Scrobbled (since app started): <%= client.tracksDiscovered %></div>
<% if (client.hasAuth) { %>
<div>Click <a target="_blank" href="client/auth?name=<%= client.name %>&type=<%= client.type %>">to (re)authenticate or initialize</a></div>
<% } %>
</ul>
</div>
</div>
<% }) %>
</div>
<div class="grid ">
<div class="bg-white shadow-md rounded my-6 dark:bg-gray-500 dark:text-white">
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
<h2>Log (Most Recent)</h2>
</div>
<div class="p-6 md:px-10 md:py-6">
<div>Level : <%- logs.level %> </div>
<div>Sort : <%- logs.sort %></div>
<div>Limit : <%- logs.limit %></div>
<br />
<% logs.output.forEach(function (logEntry){ %>
<%-logEntry%>
<% }) %>
</div>
<div class="w-full flex-auto flex min-h-0 overflow-auto">
<div class="w-full relative flex-auto">
</div>
</div>
</div>
</div>
</div>
</div>
<h2>Log (Most Recent)</h2>
<ul>
<li>Level : <%- logs.level %> </li>
<li>Sort : <%- logs.sort %></li>
<li>Limit : <%- logs.limit %></li>
</ul>
<pre>
<% logs.output.forEach(function (logEntry){ %>
<%=logEntry%><% }) %>
</pre>
<script>
let toggleSwitch = document.querySelector('#toggle-switch');
toggleSwitch.addEventListener('change', (event) => {
document.body.classList.toggle('dark')
toggleSwitch.checked ? localStorage.setItem('ms-dark', 'yes') : localStorage.setItem('ms-dark', 'no')
});
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
if (localStorage.getItem('ms-dark') === 'yes') {
document.body.classList.add('dark')
toggleSwitch.checked = true
localStorage.setItem('ms-dark', 'yes')
}
}
</script>
</body>
</html>