mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a560a49aac | ||
|
|
de763d68f3 | ||
|
|
f731c25332 | ||
|
|
f738eb92e8 | ||
|
|
b9f4f43d30 | ||
|
|
e42f00604e | ||
|
|
74eed98b9b | ||
|
|
cef6f5864a | ||
|
|
d5e816b1d3 | ||
|
|
dcc4201019 | ||
|
|
9c093a8455 | ||
|
|
9a56a3ee4d | ||
|
|
f2b0714dea | ||
|
|
0a1357acc7 | ||
|
|
403af711eb | ||
|
|
31cd72cd15 | ||
|
|
55afe876e0 | ||
|
|
49fadd8c9a | ||
|
|
58157ddfee | ||
|
|
2a9cd87848 | ||
|
|
855d3f6144 |
@@ -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?**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+1305
-2
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user