Compare commits

...
21 Commits
Author SHA1 Message Date
FoxxMD a560a49aac Fix typo 2021-03-16 15:44:38 -04:00
FoxxMD de763d68f3 Add Jellyfin source #15
* Implement Jellyfin source similar to Plex, but using MemorySource
* Add configuration docs and json example
2021-03-16 15:36:51 -04:00
FoxxMD f731c25332 Return new plays when memory source adds them to stateful
Will make it easier for non-polling sources to see what is new from state
2021-03-16 15:33:41 -04:00
FoxxMD f738eb92e8 Merge branch 'develop' into feat-jellyfin 2021-03-16 14:39:26 -04:00
FoxxMD b9f4f43d30 Refactor subsonic source to use MemorySource
* Should stabilize subsonic as a source and prevents duplicate scrobbles
* Reduce polling interval to 10 seconds for more accurate recently played data and restrict max backoff time to 30 seconds
2021-03-16 14:38:59 -04:00
FoxxMD e42f00604e Refactor polling source backoff approach
Use time-based approach rather than check count approach so active period is not sensitive to interval value
2021-03-16 14:37:15 -04:00
FoxxMD 74eed98b9b Fix filter method 2021-03-16 14:25:55 -04:00
FoxxMD cef6f5864a Implement "in memory" recently played tracking for sources that don't support a source of truth
If sources don't support a scrobble action or don't return a sane "recently played" data source we need to keep track of these plays ourselves. Do it in memory based on currently playing and return that as source "source of truth" for recently played.
2021-03-16 14:01:17 -04:00
FoxxMD d5e816b1d3 Use a persistent datetime based on source instantiation or last track play as client refresh check threshold
We don't need to refresh clients on every source poll, only when first creating a source or when a new track play is discovered.
2021-03-16 13:59:43 -04:00
FoxxMD dcc4201019 Improve parsing of last.fm track responses and handling recent tracks
* Include some metadata (mbid, url, nowplaying)
* Check for more artist properties since responses seem inconsistent now
* Check for timestamp to handle nowplaying use case
* Handle invalid scrobbles from last.fm on recents refresh WRT nowplaying and missing timestamps #22
2021-03-16 10:57:44 -04:00
FoxxMD 9c093a8455 POC jellyfin implementation #15
* Hopefully can get artist from webhook payload in the future so we can actually use this
* Add basic instructions for using Webhook plugin in configuration docs
2021-03-12 11:24:33 -05:00
FoxxMD 9a56a3ee4d Merge branch 'develop' into feat-jellyfin
# Conflicts:
#	index.js
2021-03-12 10:24:54 -05:00
Matt Foxx f2b0714dea Merge pull request #21 from christophernewton/master
Feature: Fix padding on client box
2021-03-02 09:19:19 -05:00
Chris Newton 0a1357acc7 feat: fix padding on client boxes 2021-03-02 08:54:47 +11:00
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 855d3f6144 Jellyfin initial code 2021-01-04 12:06:45 -05:00
14 changed files with 1862 additions and 132 deletions
+3 -2
View File
@@ -10,6 +10,7 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git
* [Spotify](/docs/configuration.md#spotify)
* [Plex](/docs/configuration.md#plex) or [Tautulli](/docs/configuration.md#tautulli)
* [Subsonic-compatible APIs](/docs/configuration.md#subsonic) (like [Airsonic](https://airsonic.github.io/))
* [Jellyfin](/docs/configuration.md#jellyfin)
* Supports scrobbling to many clients
* [Maloja](/docs/configuration.md#maloja)
* [Last.fm](/docs/configuration.md#lastfm)
@@ -42,7 +43,7 @@ npm install
### [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler)
```
foxxmd/spotify-scrobbler:latest
foxxmd/multi-scrobbler:latest
```
## Setup
@@ -61,7 +62,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?**
+49 -6
View File
@@ -46,7 +46,9 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
static formatPlayObj(obj) {
const {
artist: {
'#text': artists
// last.fm doesn't seem consistent with which of these properties it returns...
'#text': artists,
name: artistName,
},
name: title,
album: {
@@ -55,19 +57,30 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
duration,
date: {
uts: time,
},
} = {},
'@attr': {
nowplaying = 'false',
} = {},
url,
mbid,
} = obj;
let artistStrings = artists.split(',');
// arbitrary decision yikes
let artistStrings = artists !== undefined ? artists.split(',') : [artistName];
return {
data: {
artists: [...new Set(artistStrings)],
track: title,
album,
duration,
playDate: dayjs.unix(time),
playDate: time !== undefined ? dayjs.unix(time) : undefined,
},
meta: {
nowPlaying: nowplaying === 'true',
mbid,
source: 'Lastfm',
url: {
web: url,
}
}
}
}
@@ -160,13 +173,43 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
refreshScrobbles = async () => {
if (this.refreshEnabled) {
this.logger.debug('Refreshing recent scrobbles');
const resp = await this.callApi(client => client.userGetRecentTracks({user: this.user, limit: 20}));
const resp = await this.callApi(client => client.userGetRecentTracks({user: this.user, limit: 20, extended: true}));
const {
recenttracks: {
track: list = [],
}
} = resp;
this.recentScrobbles = list.map(x => LastfmScrobbler.formatPlayObj(x)).sort(sortByPlayDate);
this.recentScrobbles = list.reduce((acc, x) => {
try {
const formatted = LastfmScrobbler.formatPlayObj(x);
const {
data: {
track,
playDate,
},
meta: {
mbid,
nowPlaying,
}
} = formatted;
if(nowPlaying === true) {
// if the track is "now playing" it doesn't get a timestamp so we can't determine when it started playing
// and don't want to accidentally count the same track at different timestamps by artificially assigning it 'now' as a timestamp
// so we'll just ignore it in the context of recent tracks since really we only want "tracks that have already finished being played" anyway
this.logger.debug("Ignoring 'now playing' track returned from Last.fm client", {track, mbid});
return acc;
} else if(playDate === undefined) {
this.logger.warn(`Last.fm recently scrobbled track did not contain a timestamp, omitting from time frame check`, {track, mbid});
return acc;
}
return acc.concat(formatted);
} catch (e) {
this.logger.warn('Failed to format Last.fm recently scrobbled track, omitting from time frame check', {error: e.message});
this.logger.debug('Full api response object:');
this.logger.debug(x);
return acc;
}
}, []).sort(sortByPlayDate);
if (this.recentScrobbles.length > 0) {
const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1);
const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1);
+10
View File
@@ -0,0 +1,10 @@
[
{
"name": "default", // optional, friendly name for logs
"clients": [], // optional, list of scrobble clients (by config name) that this source should scrobble to. Using an empty list or not including this property will make this source scrobble to all clients.
"data": {
"users": ["FoxxMD"], // optional, list of users to scrobble tracks for
"servers": ["myServer","anotherServer"] // optional, list of servers to scrobble tracks from
}
}
]
+25
View File
@@ -143,6 +143,31 @@ Can use this source for any application that implements the [Subsonic API](http:
See [`subsonic.json.example`](../config/subsonic.json.example)
## [Jellyfin](https://jellyfin.org/)
Must be using Jellyfin 10.7 or greater
* Add the [Webhook Plugin](https://github.com/crobibero/jellyfin-plugin-webhook) repository to your plugins, then restart your server
* In the Webhook settings:
* `Add Generic Destination`
* In the new `Generic` dropdown:
* Webhook Url: `http://localhost:9078/jellyfin`
* Notification Type: `Playback Progress`
* Item Type: `Songs`
* Check `Send All Properties`
* Save
### ENV-Based
| Environmental Variable | Required? | Default | Description |
|------------------------|-----------|---------|-------------------------------------------------------------------|
| `JELLYFIN_USER` | | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
| `JELLYFIN_SERVER` | | | Comma-separated list of Jellyfin server names to scrobble from |
### JSON-Based
See [`jellyfin.json.example`](../config/jellyfin.json.example)
# Clients
## [Maloja](https://github.com/krateng/maloja)
+23 -4
View File
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import isBetween from 'dayjs/plugin/isBetween.js';
import relativeTime from 'dayjs/plugin/relativeTime.js';
import duration from 'dayjs/plugin/duration.js';
import {Writable} from 'stream';
import 'winston-daily-rotate-file';
import {
@@ -22,6 +23,7 @@ import ScrobbleSources from "./sources/ScrobbleSources.js";
import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./server/middleware.js";
import TautulliSource from "./sources/TautulliSource.js";
import PlexSource from "./sources/PlexSource.js";
import JellyfinSource from "./sources/JellyfinSource.js";
const storage = multer.memoryStorage()
const upload = multer({storage: storage})
@@ -29,13 +31,19 @@ const upload = multer({storage: storage})
dayjs.extend(utc)
dayjs.extend(isBetween);
dayjs.extend(relativeTime);
dayjs.extend(duration);
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 +206,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(' | ')
}
});
})
@@ -249,6 +257,17 @@ app.use(bodyParser.json());
res.send('OK');
});
// webhook plugin sends json with context type text/utf-8 so we need to parse it differently
const jellyfinJsonParser = bodyParser.json({type: 'text/*'});
app.postAsync('/jellyfin', jellyfinJsonParser, async function (req, res) {
const playObj = JellyfinSource.formatPlayObj(req.body, true);
const pSources = scrobbleSources.getByType('jellyfin');
for (const source of pSources) {
await source.handle(playObj, scrobbleClients);
}
res.send('OK');
});
app.use('/client/auth', clientCheckMiddle);
app.getAsync('/client/auth', async function (req, res) {
const {
+1305 -2
View File
File diff suppressed because it is too large Load Diff
+5 -5
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,14 +13,14 @@
},
"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",
+15 -11
View File
@@ -10,6 +10,7 @@ export default class AbstractSource {
config;
clients;
logger;
instantiatedAt;
canPoll = false;
polling = false;
@@ -23,6 +24,7 @@ export default class AbstractSource {
this.logger = createLabelledLogger(this.identifier, this.identifier);
this.config = config;
this.clients = clients;
this.instantiatedAt = dayjs();
}
getRecentlyPlayed = async (options = {}) => {
@@ -76,8 +78,9 @@ export default class AbstractSource {
return;
}
this.logger.info('Polling started');
let lastTrackPlayedAt = dayjs();
let lastTrackPlayedAt = this.instantiatedAt;
let checkCount = 0;
let checksOverThreshold = 0;
try {
this.polling = true;
while (true) {
@@ -135,9 +138,13 @@ export default class AbstractSource {
}
} else {
checkCount = 0;
checksOverThreshold = 0;
}
// use the source instantiation time or the last track play time to determine if we should refresh clients..
// we only need to refresh clients when the source has "newer" information otherwise we're just refreshing clients for no reason
const scrobbleResult = await allClients.scrobble(playObjs, {
checkTime: lastTrackPlayedAt.add(2, 's'),
forceRefresh: closeToInterval,
scrobbleFrom: this.identifier,
scrobbleTo: this.clients
@@ -148,19 +155,16 @@ export default class AbstractSource {
this.tracksDiscovered += scrobbleResult.length;
}
const {interval = 30} = this.config;
const {interval = 30, checkActiveFor = 300, maxSleep = 300} = this.config;
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
// don't need to do back off calc if interval is 5 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.unix() - lastTrackPlayedAt.unix());
// 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);
}
const activeThreshold = lastTrackPlayedAt.add(checkActiveFor, 's');
if (activeThreshold.isBefore(dayjs()) && sleepTime < 300) {
checksOverThreshold++;
const backoffMultiplier = Math.min(checksOverThreshold, 1000) * 1.5;
sleepTime = Math.min(interval * backoffMultiplier, maxSleep);
}
// sleep for interval
+164
View File
@@ -0,0 +1,164 @@
import MemorySource from "./MemorySource.js";
import dayjs from "dayjs";
import {buildTrackString} from "../utils.js";
export default class JellyfinSource extends MemorySource {
users;
servers;
constructor(name, config, clients, type = 'jellyfin') {
super(type, name, config, clients);
const {users, servers} = config
if (users === undefined || user === null) {
this.users = undefined;
} else {
if (!Array.isArray(users)) {
this.users = [users];
} else {
this.users = users;
}
this.users = this.users.map(x => x.toLocaleLowerCase())
}
if (servers === undefined || servers === null) {
this.servers = undefined;
} else {
if (!Array.isArray(servers)) {
this.servers = [servers];
} else {
this.servers = servers;
}
this.servers = this.servers.map(x => x.toLocaleLowerCase())
}
if (users === undefined && servers === undefined) {
this.logger.warn('Initializing, but with no filters! All tracks from all users on all servers will be scrobbled.');
} else {
this.logger.info(`Initializing with the following filters => Users: ${this.users === undefined ? 'N/A' : this.users.join(', ')} | Servers: ${this.servers === undefined ? 'N/A' : this.servers.join(', ')}`);
}
}
static formatPlayObj(obj, newFromSource = false) {
const {
ServerId,
ServerName,
Username,
UserId,
NotificationType,
UtcTimestamp,
Album,
Artist,
Name,
RunTime,
ItemId,
ItemType,
} = obj;
const parsedRuntime = RunTime.split(':');
const dur = dayjs.duration({
hours: Number.parseInt(parsedRuntime[0]),
minutes: Number.parseInt(parsedRuntime[1]),
seconds: Number.parseInt(parsedRuntime[2])
});
return {
data: {
artists: [Artist],
album: Album,
track: Name,
duration: dur.as('seconds'),
playDate: dayjs(),
},
meta: {
event: NotificationType,
mediaType: ItemType,
sourceId: ItemId,
user: Username,
server: ServerName,
source: 'Jellyfin',
newFromSource,
}
}
}
isValidEvent = (playObj) => {
const {
meta: {
mediaType, event, user, server
},
data: {
artists,
track,
} = {}
} = playObj;
if (this.users !== undefined) {
if (user === undefined) {
this.logger.warn(`Config defined users but payload contained no user info${hint}`);
} else if (!this.users.includes(user.toLocaleLowerCase())) {
this.logger.debug(`Will not scrobble event because author was not an allowed user: ${user}`, {
artists,
track
})
return false;
}
}
if (event !== undefined && !['PlaybackProgress','PlaybackStarted'].includes(event)) {
this.logger.debug(`Will not scrobble event because it is not media.scrobble (${event})`, {
artists,
track
})
return false;
}
if (mediaType !== 'Audio') {
this.logger.debug(`Will not scrobble event because media type was not 'Audio' (${mediaType})`, {
artists,
track
});
return false;
}
if (this.servers !== undefined && !this.servers.includes(server.toLocaleLowerCase())) {
this.logger.debug(`Will not scrobble event because server was not on allowed list: ${server}`, {
artists,
track
})
return false;
}
return true;
}
getRecentlyPlayed = async (options = {}) => {
return this.statefulRecentlyPlayed;
}
handle = async (playObj, allClients) => {
if (!this.isValidEvent(playObj)) {
return;
}
const newPlays = this.processRecentPlays([playObj]);
for(const p of newPlays) {
this.logger.info(`New Track => ${buildTrackString(p)}`);
}
if(newPlays.length > 0) {
const recent = await this.getRecentlyPlayed();
const newestPlay = recent[recent.length - 1];
try {
await allClients.scrobble(newPlays, {scrobbleTo: this.clients, scrobbleFrom: this.identifier, checkTime: newestPlay.data.playDate});
// only gets hit if we scrobbled ok
this.tracksDiscovered++;
} catch (e) {
this.logger.error('Encountered error while scrobbling')
this.logger.error(e)
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
import AbstractSource from "./AbstractSource.js";
import {playObjDataMatch, sortByPlayDate, buildTrackString} from "../utils.js";
import dayjs from "dayjs";
export default class MemorySource extends AbstractSource {
/*
* MemorySource uses its own state to maintain a list of recently played tracks and determine if a track if valid.
* This is necessary for any source that
* * doesn't have its own source of truth for "recently played" or
* * that does not return "started at" and "duration" timestamps for recent plays or
* * where these timestamps don't have enough granularity (IE second accuracy)
* such as subsonic and jellyfin */
statefulRecentlyPlayed = [];
candidateRecentlyPlayed = [];
processRecentPlays = (plays) => {
let newStatefulPlays = [];
// first format new plays with locked play date
const lockedPlays = plays.map((p) => {
const {data: {playDate, ...restData}, ...rest} = p;
return {data: {...restData, playDate: dayjs()}, ...rest};
})
// if no candidates exist new plays are new candidates
if(this.candidateRecentlyPlayed.length === 0) {
this.candidateRecentlyPlayed = lockedPlays;
} else {
// otherwise determine new tracks (not found in prior candidates)
const newTracks = lockedPlays.filter(x => this.candidateRecentlyPlayed.every(y => !playObjDataMatch(y, x)));
// filter prior candidates based on new recently played
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.filter(x => lockedPlays.some(y => playObjDataMatch(x, y)));
// and then combine still playing with new tracks
this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.concat(newTracks);
this.candidateRecentlyPlayed.sort(sortByPlayDate);
for(const candidate of this.candidateRecentlyPlayed) {
const {data: {playDate, track}} = candidate;
if(playDate.isBefore(dayjs().subtract(30, 's'))) {
// a prior candidate has been playing for more than 30 seconds, time to check statefuls
const matchingRecent = this.statefulRecentlyPlayed.find(x => playObjDataMatch(x, candidate));
let stPrefix = `(Stateful Play) ${buildTrackString(candidate, {include: ['artist', 'track']})}`;
if(matchingRecent === undefined) {
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and not matching any prior plays`);
newStatefulPlays.push(candidate);
this.statefulRecentlyPlayed.push(candidate);
} else {
const {data: { playDate, duration }} = candidate;
const {data: { playDate: rplayDate }} = matchingRecent;
if(!playDate.isSame(rplayDate)) {
if(duration !== undefined) {
if(playDate.isAfter(rplayDate.add(duration, 's'))) {
this.logger.debug(`${stPrefix} added after being seen for 30 seconds and having a different timestamp than a prior play`);
newStatefulPlays.push(candidate);
this.statefulRecentlyPlayed.push(candidate);
}
} else if(!playObjDataMatch(this.statefulRecentlyPlayed[0], candidate)) {
// if most recent stateful play is not this track we'll add it
this.logger.debug(`${stPrefix} added after being seen for 30 seconds. Matched other recent play but could not determine time frame due to missing duration. Allowed due to not being last played track.`);
newStatefulPlays.push(candidate);
this.statefulRecentlyPlayed.push(candidate);
}
}
}
}
}
this.statefulRecentlyPlayed.sort(sortByPlayDate);
}
return newStatefulPlays;
}
recentlyPlayedTrackIsValid = (playObj) => {
return playObj.data.playDate.isBefore(dayjs().subtract(30, 's'));
}
}
+21 -1
View File
@@ -3,6 +3,7 @@ import SpotifySource from "./SpotifySource.js";
import PlexSource from "./PlexSource.js";
import TautulliSource from "./TautulliSource.js";
import {SubsonicSource} from "./SubsonicSource.js";
import JellyfinSource from "./JellyfinSource.js";
export default class ScrobbleSources {
@@ -11,7 +12,7 @@ export default class ScrobbleSources {
configDir;
localUrl;
sourceTypes = ['spotify', 'plex', 'tautulli', 'subsonic'];
sourceTypes = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin'];
constructor(localUrl, configDir = process.cwd()) {
this.configDir = configDir;
@@ -122,6 +123,21 @@ export default class ScrobbleSources {
})
}
break;
case 'jellyfin':
const j = {
user: process.env.JELLYFIN_USER,
server: process.env.JELLYFIN_SERVER,
};
if (!Object.values(j).every(x => x === undefined)) {
configs.push({
type: 'jellyfin',
name: 'unnamed',
source: 'ENV',
mode: 'single',
data: j
})
}
break;
default:
break;
}
@@ -239,6 +255,10 @@ export default class ScrobbleSources {
await ssSource.testConnection();
this.sources.push(ssSource);
break;
case 'jellyfin':
const jellyfinSource = await new JellyfinSource(name, data, clients);
this.sources.push(jellyfinSource);
break;
default:
break;
}
+7 -13
View File
@@ -4,13 +4,16 @@ import crypto from 'crypto';
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
import {buildTrackString, parseRetryAfterSecsFromObj, sleep} from "../utils.js";
import MemorySource from "./MemorySource.js";
dayjs.extend(isSameOrAfter);
export class SubsonicSource extends AbstractSource {
export class SubsonicSource extends MemorySource {
constructor(name, config = {}, clients = []) {
super('subsonic', name, config, clients);
// default to quick interval so we can get a decently accurate nowPlaying
const subsonicConfig = {interval: 10, maxSleep: 30, ...config};
super('subsonic', name, subsonicConfig, clients);
const {user, password, url} = this.config;
@@ -55,16 +58,6 @@ export class SubsonicSource extends AbstractSource {
}
}
recentlyPlayedTrackIsValid = (playObj) => {
const {data: {playDate} = {}} = playObj;
// want to make sure that the track has been played for at least one minute (according to subsonic api)
const isValid = dayjs().startOf('minute').subtract(1, 'minute').isSameOrAfter(playDate);
if (!isValid) {
this.logger.debug(`${buildTrackString(playObj, {include: ['artist', 'track']})} recently played but not valid b/c it has not been playing for >= 1 minute`);
}
return isValid;
}
callApi = async (req, retries = 0) => {
const {
user,
@@ -150,6 +143,7 @@ export class SubsonicSource extends AbstractSource {
entry = []
} = {}
} = resp;
return entry.map(x => formatted ? SubsonicSource.formatPlayObj(x) : x)
this.processRecentPlays(entry.map(x => formatted ? SubsonicSource.formatPlayObj(x) : x));
return this.statefulRecentlyPlayed;
}
}
+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 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>