From bd188a8ca7f01b8f8f216dfb75ae0d9cbca906af Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Feb 2023 09:58:56 -0500 Subject: [PATCH 01/41] refactor: Remove unused trackLength property --- src/common/infrastructure/Atomic.ts | 1 - src/sources/DeezerSource.ts | 1 - src/sources/SpotifySource.ts | 1 - src/sources/SubsonicSource.ts | 1 - src/sources/TautulliSource.ts | 1 - src/sources/YTMusicSource.ts | 1 - 6 files changed, 6 deletions(-) diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index b50dc3d8..20b7b610 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -47,7 +47,6 @@ export interface PlayMeta { web: string [key: string]: string } - trackLength?: number user?: string mediaType?: string server?: string diff --git a/src/sources/DeezerSource.ts b/src/sources/DeezerSource.ts index c30dbf76..17c96b17 100644 --- a/src/sources/DeezerSource.ts +++ b/src/sources/DeezerSource.ts @@ -61,7 +61,6 @@ export default class DeezerSource extends AbstractSource { playDate: dayjs(timestamp * 1000), }, meta: { - trackLength: duration, source: 'Deezer', sourceId: id, newFromSource, diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index bb0cc503..9243bfab 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -68,7 +68,6 @@ export default class SpotifySource extends AbstractSource { playDate: dayjs(played_at), }, meta: { - trackLength: duration_ms / 1000, source: 'Spotify', sourceId: id, newFromSource, diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index 11d2b228..2beab255 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -66,7 +66,6 @@ export class SubsonicSource extends MemorySource { playDate: minutesAgo === 0 ? dayjs().startOf('minute') : dayjs().startOf('minute').subtract(minutesAgo, 'minute'), }, meta: { - trackLength: duration, source: 'Subsonic', sourceId: id, newFromSource, diff --git a/src/sources/TautulliSource.ts b/src/sources/TautulliSource.ts index d9f2d9a0..e7c33d67 100644 --- a/src/sources/TautulliSource.ts +++ b/src/sources/TautulliSource.ts @@ -45,7 +45,6 @@ export default class TautulliSource extends PlexSource { sourceVersion: version, mediaType: media_type, user: username, - trackLength: duration, source: 'Tautulli', newFromSource, } diff --git a/src/sources/YTMusicSource.ts b/src/sources/YTMusicSource.ts index 0aa99218..4ed9847f 100644 --- a/src/sources/YTMusicSource.ts +++ b/src/sources/YTMusicSource.ts @@ -55,7 +55,6 @@ export default class YTMusicSource extends AbstractSource { playDate: newFromSource ? dayjs().startOf('minute') : undefined, }, meta: { - trackLength: duration, source: 'YTMusic', sourceId: id, newFromSource, -- 2.51.2 From f7b03d9622dac93c227f67cfd12b1faee32e8ecd Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Feb 2023 10:14:06 -0500 Subject: [PATCH 02/41] refactor: Rename sourceId to trackId to be clearer in what it actually is --- src/common/infrastructure/Atomic.ts | 7 +++++-- src/sources/DeezerSource.ts | 2 +- src/sources/JellyfinSource.ts | 2 +- src/sources/MemorySource.ts | 8 ++++---- src/sources/SpotifySource.ts | 2 +- src/sources/SubsonicSource.ts | 2 +- src/sources/YTMusicSource.ts | 2 +- src/utils.ts | 14 +++++++------- 8 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 20b7b610..0f687ea2 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -41,7 +41,10 @@ export interface PlayData { export interface PlayMeta { source?: string - sourceId?: string + /** + * Unique ID for this track, given by the Source + * */ + trackId?: string newFromSource?: boolean url?: { web: string @@ -61,7 +64,7 @@ export interface PlayObject { } export interface TrackStringOptions { - include?: ('time' | 'artist' | 'track' | 'timeFromNow' | 'sourceId')[] + include?: ('time' | 'artist' | 'track' | 'timeFromNow' | 'trackId')[] transformers?: { artists?: (a: string[]) => string track?: (t: string) => string diff --git a/src/sources/DeezerSource.ts b/src/sources/DeezerSource.ts index 17c96b17..09c2ece0 100644 --- a/src/sources/DeezerSource.ts +++ b/src/sources/DeezerSource.ts @@ -62,7 +62,7 @@ export default class DeezerSource extends AbstractSource { }, meta: { source: 'Deezer', - sourceId: id, + trackId: id, newFromSource, url: { web: link diff --git a/src/sources/JellyfinSource.ts b/src/sources/JellyfinSource.ts index ec8950a1..e532a8ee 100644 --- a/src/sources/JellyfinSource.ts +++ b/src/sources/JellyfinSource.ts @@ -91,7 +91,7 @@ export default class JellyfinSource extends MemorySource { meta: { event: NotificationType, mediaType: ItemType, - sourceId: ItemId, + trackId: ItemId, user: NotificationUsername ?? UserId, server, source: 'Jellyfin', diff --git a/src/sources/MemorySource.ts b/src/sources/MemorySource.ts index f797be3e..b86d3794 100644 --- a/src/sources/MemorySource.ts +++ b/src/sources/MemorySource.ts @@ -25,7 +25,7 @@ export default class MemorySource extends AbstractSource { // if no candidates exist new plays are new candidates if(this.candidateRecentlyPlayed.length === 0) { for(const p of lockedPlays) { - this.logger.debug(`No prior candidate recent plays! Adding new locked plays: ${buildTrackString(p, {include: ['sourceId', 'artist', 'track']})}`); + this.logger.debug(`No prior candidate recent plays! Adding new locked plays: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); } this.candidateRecentlyPlayed = lockedPlays; } else { @@ -33,14 +33,14 @@ export default class MemorySource extends AbstractSource { const newTracks = lockedPlays.filter((x: any) => this.candidateRecentlyPlayed.every(y => !playObjDataMatch(y, x))); if(newTracks.length > 0) { for(const p of newTracks) { - this.logger.debug(`New play found that does not match existing candidates will be added: ${buildTrackString(p, {include: ['sourceId', 'artist', 'track']})}`); + this.logger.debug(`New play found that does not match existing candidates will be added: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); } } // filter prior candidates based on new recently played this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.filter(x => { const candidateMatchedLocked = lockedPlays.some((y: any) => playObjDataMatch(x, y)); if(!candidateMatchedLocked) { - this.logger.debug(`Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['sourceId', 'artist', 'track']})}`); + this.logger.debug(`Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['trackId', 'artist', 'track']})}`); } return candidateMatchedLocked; }); @@ -54,7 +54,7 @@ export default class MemorySource extends AbstractSource { // 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: ['sourceId', 'artist', 'track']})}`; + let stPrefix = `(Stateful Play) ${buildTrackString(candidate, {include: ['trackId', '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); diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index 9243bfab..baa115cf 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -69,7 +69,7 @@ export default class SpotifySource extends AbstractSource { }, meta: { source: 'Spotify', - sourceId: id, + trackId: id, newFromSource, url: { web: spotify diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index 2beab255..5b7d2945 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -67,7 +67,7 @@ export class SubsonicSource extends MemorySource { }, meta: { source: 'Subsonic', - sourceId: id, + trackId: id, newFromSource, } } diff --git a/src/sources/YTMusicSource.ts b/src/sources/YTMusicSource.ts index 4ed9847f..28bcf4d3 100644 --- a/src/sources/YTMusicSource.ts +++ b/src/sources/YTMusicSource.ts @@ -56,7 +56,7 @@ export default class YTMusicSource extends AbstractSource { }, meta: { source: 'YTMusic', - sourceId: id, + trackId: id, newFromSource, } } diff --git a/src/utils.ts b/src/utils.ts index 98c7b56b..4518fde1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -105,13 +105,13 @@ export const buildTrackString = (playObj: PlayObject, options: TrackStringOption playDate } = {}, meta: { - sourceId + trackId } = {}, } = playObj; const strParts = []; - if(include.includes('sourceId') && sourceId !== undefined) { - strParts.push(`(${sourceId})`); + if(include.includes('trackId') && trackId !== undefined) { + strParts.push(`(${trackId})`); } if(include.includes('artist')) { strParts.push(`${artistsFunc(artists)}`) @@ -229,7 +229,7 @@ export const playObjDataMatch = (a: PlayObject, b: PlayObject) => { } = {}, meta: { source: aSource, - sourceId: aSourceId, + trackId: atrackId, } = {}, } = a; @@ -241,13 +241,13 @@ export const playObjDataMatch = (a: PlayObject, b: PlayObject) => { } = {}, meta: { source: bSource, - sourceId: bSourceId, + trackId: btrackId, } = {}, } = b; // if sources are the same and both plays have source ids then we can just compare by id - if(aSource === bSource && aSourceId !== undefined && bSourceId !== undefined) { - if(aSourceId !== bSourceId) { + if(aSource === bSource && atrackId !== undefined && btrackId !== undefined) { + if(atrackId !== btrackId) { return false; } } -- 2.51.2 From 259d4dabc8cebb4c96aac23d759cf102d4a10733 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Feb 2023 11:12:15 -0500 Subject: [PATCH 03/41] feat: Parse device id metadata from sources to be used for multi-player awareness #67 --- docs/plex.md | 8 +++++- src/common/infrastructure/Atomic.ts | 19 +++++++++++++ src/sources/JellyfinSource.ts | 11 ++++++-- src/sources/MemorySource.ts | 3 +- src/sources/PlexSource.ts | 9 +++++- src/sources/SubsonicSource.ts | 10 +++++-- src/sources/TautulliSource.ts | 20 ++++++++++++-- src/utils.ts | 43 +++++++++++++++++++++++++++++ 8 files changed, 113 insertions(+), 10 deletions(-) diff --git a/docs/plex.md b/docs/plex.md index c619da6f..51973421 100644 --- a/docs/plex.md +++ b/docs/plex.md @@ -44,7 +44,13 @@ Expand the **Watched** dropdown and add the following code block to the **JSON D "username": "{username}", "server": "{server_name}", "version": "{server_version}", -"library": "{library_name}" +"library": "{library_name}", +"player": "{player}", +"device": "{device}", +"platform": "{platform}", +"action": "{action}", +"machine_id": "{machine_id}", +"session_key": "{session_key}" } ``` diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 0f687ea2..9b9d331e 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -35,7 +35,13 @@ export interface PlayData { artists?: string[] album?: string track?: string + /** + * The length of the track, in seconds + * */ duration?: number + /** + * The date the track was played at + * */ playDate?: Dayjs } @@ -45,6 +51,11 @@ export interface PlayMeta { * Unique ID for this track, given by the Source * */ trackId?: string + + /** + * Atomic ID for this instance of played tracked IE a unique ID for "this track played at this time" + * */ + playId?: string newFromSource?: boolean url?: { web: string @@ -54,6 +65,14 @@ export interface PlayMeta { mediaType?: string server?: string library?: string + /** + * The position the "player" is at in the track at the time the play was reported, in seconds + * */ + trackProgressPosition?: number + /** + * A unique identifier for the device playing this track + * */ + deviceId?: string [key: string]: any } diff --git a/src/sources/JellyfinSource.ts b/src/sources/JellyfinSource.ts index e532a8ee..0eef1973 100644 --- a/src/sources/JellyfinSource.ts +++ b/src/sources/JellyfinSource.ts @@ -1,10 +1,11 @@ import MemorySource from "./MemorySource.js"; import dayjs from "dayjs"; -import {buildTrackString, parseDurationFromTimestamp} from "../utils.js"; +import {buildTrackString, combinePartsToString, parseDurationFromTimestamp, truncateStringToLength} from "../utils.js"; import {JellySourceConfig} from "../common/infrastructure/config/source/jellyfin.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {Notifiers} from "../notifier/Notifiers.js"; +const shortDeviceId = truncateStringToLength(10, ''); export default class JellyfinSource extends MemorySource { users; @@ -63,6 +64,9 @@ export default class JellyfinSource extends MemorySource { ItemType, PlaybackPosition, connectionId, + DeviceId = '', + DeviceName, + ClientName, } = obj; const dur = parseDurationFromTimestamp(RunTime); @@ -96,8 +100,9 @@ export default class JellyfinSource extends MemorySource { server, source: 'Jellyfin', newFromSource, - playbackPosition: parseDurationFromTimestamp(PlaybackPosition), - sourceVersion: ServerVersion + trackProgressPosition: parseDurationFromTimestamp(PlaybackPosition).asSeconds(), + sourceVersion: ServerVersion, + deviceId: combinePartsToString([shortDeviceId(DeviceId), DeviceName]) } } } diff --git a/src/sources/MemorySource.ts b/src/sources/MemorySource.ts index b86d3794..d62bde9a 100644 --- a/src/sources/MemorySource.ts +++ b/src/sources/MemorySource.ts @@ -1,6 +1,7 @@ import AbstractSource from "./AbstractSource.js"; import {playObjDataMatch, sortByPlayDate, buildTrackString} from "../utils.js"; import dayjs from "dayjs"; +import {PlayObject} from "../common/infrastructure/Atomic.js"; export default class MemorySource extends AbstractSource { /* @@ -14,7 +15,7 @@ export default class MemorySource extends AbstractSource { statefulRecentlyPlayed = []; candidateRecentlyPlayed = []; - processRecentPlays = (plays: any) => { + processRecentPlays = (plays: PlayObject[]) => { let newStatefulPlays: any = []; // first format new plays with locked play date diff --git a/src/sources/PlexSource.ts b/src/sources/PlexSource.ts index fdb8e61f..36c2f3f0 100644 --- a/src/sources/PlexSource.ts +++ b/src/sources/PlexSource.ts @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import {buildTrackString, createLabelledLogger} from "../utils.js"; +import {buildTrackString, combinePartsToString, createLabelledLogger, truncateStringToLength} from "../utils.js"; import AbstractSource from "./AbstractSource.js"; import formidable from 'formidable'; import concatStream from 'concat-stream'; @@ -7,6 +7,8 @@ import {PlexSourceConfig} from "../common/infrastructure/config/source/plex.js"; import {InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js"; import {Notifiers} from "../notifier/Notifiers.js"; +const shortDeviceId = truncateStringToLength(10, ''); + export default class PlexSource extends AbstractSource { users: string[]; libraries: string[]; @@ -70,6 +72,10 @@ export default class PlexSource extends AbstractSource { // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message title: server } = {}, + Player: { + title, + uuid, + } } = obj; return { data: { @@ -86,6 +92,7 @@ export default class PlexSource extends AbstractSource { server, source: 'Plex', newFromSource, + deviceId: combinePartsToString([shortDeviceId(uuid), title]) } } } diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index 5b7d2945..27c7a33b 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -2,7 +2,7 @@ import request from 'superagent'; import * as crypto from 'crypto'; import dayjs from "dayjs"; import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; -import {buildTrackString, parseRetryAfterSecsFromObj, sleep} from "../utils.js"; +import {buildTrackString, parseRetryAfterSecsFromObj, removeDuplicates, sleep} from "../utils.js"; import MemorySource from "./MemorySource.js"; import {SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; @@ -54,6 +54,8 @@ export class SubsonicSource extends MemorySource { artist, duration, // seconds minutesAgo, + playerId, + username, } = obj; return { data: { @@ -69,6 +71,8 @@ export class SubsonicSource extends MemorySource { source: 'Subsonic', trackId: id, newFromSource, + user: username, + deviceId: playerId } } } @@ -186,7 +190,9 @@ export class SubsonicSource extends MemorySource { entry = [] } = {} } = resp; - this.processRecentPlays(entry.map((x: any) => formatted ? SubsonicSource.formatPlayObj(x) : x)); + // sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays + const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj)); + this.processRecentPlays(deduped); return this.statefulRecentlyPlayed; } } diff --git a/src/sources/TautulliSource.ts b/src/sources/TautulliSource.ts index e7c33d67..634fd2b8 100644 --- a/src/sources/TautulliSource.ts +++ b/src/sources/TautulliSource.ts @@ -3,6 +3,9 @@ import PlexSource from "./PlexSource.js"; import {TautulliSourceConfig} from "../common/infrastructure/config/source/tautulli.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {Notifiers} from "../notifier/Notifiers.js"; +import {combinePartsToString, truncateStringToLength} from "../utils.js"; + +const shortDeviceId = truncateStringToLength(10, ''); export default class TautulliSource extends PlexSource { @@ -24,12 +27,23 @@ export default class TautulliSource extends PlexSource { version, duration, username, - library - } = obj; + library, + machine_id = '', + session_key, + action, + platform, + device, + player, + } = obj.body; let artists = [artist_name]; if (track_artist !== undefined && track_artist !== artist_name) { artists.push(track_artist); } + if(action === undefined) { + //TODO why does TS think logger doesn't exist? + // @ts-ignore + this.logger.warn(`Payload did contain property 'action', assuming it should be 'watched'`); + } return { data: { artists, @@ -40,6 +54,7 @@ export default class TautulliSource extends PlexSource { }, meta: { title, + event: action === 'watched' ? 'media.scrobble' : action, library: library_name ?? library, server, sourceVersion: version, @@ -47,6 +62,7 @@ export default class TautulliSource extends PlexSource { user: username, source: 'Tautulli', newFromSource, + deviceId: combinePartsToString([shortDeviceId(machine_id), session_key, player]) } } } diff --git a/src/utils.ts b/src/utils.ts index 4518fde1..737248f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -519,3 +519,46 @@ export const closePlayDate = (existingPlay: PlayObject, newPlay: PlayObject, dif return closeTime; } + +export const combinePartsToString = (parts: any[], glue: string = '-'): string | undefined => { + const cleanParts: string[] = []; + for (const part of parts) { + if (part === null || part === undefined) { + continue; + } + if (Array.isArray(part)) { + const nestedParts = combinePartsToString(part, glue); + if (nestedParts !== undefined) { + cleanParts.push(nestedParts); + } + } else if (typeof part === 'object') { + // hope this works + cleanParts.push(JSON.stringify(part)); + } else if(typeof part === 'string') { + if(part.trim() !== '') { + cleanParts.push(part); + } + } else { + cleanParts.push(part.toString()); + } + } + if (cleanParts.length > 0) { + return cleanParts.join(glue); + } + return undefined; +} + +/** + * Remove duplicates based on trackId, deviceId, and play date + * */ +export const removeDuplicates = (plays: PlayObject[]): PlayObject[] => { + return plays.reduce((acc: PlayObject[], currPlay: PlayObject) => { + if(currPlay.meta.trackId !== undefined && currPlay.meta.deviceId !== undefined && currPlay.data.playDate !== undefined) { + if(acc.some((x: PlayObject) => x.meta.trackId === currPlay.meta.trackId && x.meta.deviceId === currPlay.meta.deviceId && x.data.playDate.isSame(currPlay.data.playDate, 'minute'))) { + // don't add current play to list if we find an existing that matches track, device, and play date + return acc; + } + } + return acc.concat(currPlay); + }, []); +} -- 2.51.2 From e0ae621b6282405d29274e9e21800aee4a02a0c4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Feb 2023 12:34:09 -0500 Subject: [PATCH 04/41] feat: Implement more context awareness for memory sources to handle multi-platform/device plays * Track candidate plays separately based on device/platform/user metadata * Perform additional check for playback progress if metadata exists Fixes #67 --- src/common/infrastructure/Atomic.ts | 6 + src/sources/JellyfinSource.ts | 4 +- src/sources/MemorySource.ts | 172 +++++++++++++++++++--------- src/sources/SubsonicSource.ts | 3 +- src/utils.ts | 18 ++- 5 files changed, 140 insertions(+), 63 deletions(-) diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 9b9d331e..887468ac 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -82,6 +82,12 @@ export interface PlayObject { meta: PlayMeta } +export interface ProgressAwarePlayObject extends PlayObject { + meta: PlayMeta & { + initialTrackProgressPosition?: number + } +} + export interface TrackStringOptions { include?: ('time' | 'artist' | 'track' | 'timeFromNow' | 'trackId')[] transformers?: { diff --git a/src/sources/JellyfinSource.ts b/src/sources/JellyfinSource.ts index 0eef1973..ef368230 100644 --- a/src/sources/JellyfinSource.ts +++ b/src/sources/JellyfinSource.ts @@ -100,7 +100,7 @@ export default class JellyfinSource extends MemorySource { server, source: 'Jellyfin', newFromSource, - trackProgressPosition: parseDurationFromTimestamp(PlaybackPosition).asSeconds(), + trackProgressPosition: PlaybackPosition !== undefined ? parseDurationFromTimestamp(PlaybackPosition).asSeconds() : undefined, sourceVersion: ServerVersion, deviceId: combinePartsToString([shortDeviceId(DeviceId), DeviceName]) } @@ -154,7 +154,7 @@ export default class JellyfinSource extends MemorySource { } getRecentlyPlayed = async (options = {}) => { - return this.statefulRecentlyPlayed; + return this.getFlatStatefulRecentlyPlayed(); } handle = async (playObj: any, allClients: any) => { diff --git a/src/sources/MemorySource.ts b/src/sources/MemorySource.ts index d62bde9a..7a1cd9cf 100644 --- a/src/sources/MemorySource.ts +++ b/src/sources/MemorySource.ts @@ -1,7 +1,11 @@ import AbstractSource from "./AbstractSource.js"; -import {playObjDataMatch, sortByPlayDate, buildTrackString} from "../utils.js"; +import {playObjDataMatch, sortByPlayDate, buildTrackString, toProgressAwarePlayObject, getProgress} from "../utils.js"; import dayjs from "dayjs"; -import {PlayObject} from "../common/infrastructure/Atomic.js"; +import {PlayObject, ProgressAwarePlayObject} from "../common/infrastructure/Atomic.js"; + +export type GroupedPlays = Map; + +const genGroupId = (play: PlayObject) => `${play.meta.deviceId ?? 'NoDevice'}-${play.meta.user ?? 'SingleUser'}`; export default class MemorySource extends AbstractSource { /* @@ -12,75 +16,133 @@ export default class MemorySource extends AbstractSource { * * where these timestamps don't have enough granularity (IE second accuracy) * such as subsonic and jellyfin */ - statefulRecentlyPlayed = []; - candidateRecentlyPlayed = []; + /** + * Tracks we are tracked that we are confident qualified as being played based on: + * + * - MS saw the track for the first time while it was running/polling + * - Continued to see this same track through consecutive polling/ingress events for AT LEAST 30 seconds + * - If the play info contains playback position data we also check if that has progressed at least 30 seconds + * + * These are tracks that are actually used by the source to scrobble to clients + * */ + statefulRecentlyPlayed: GroupedPlays = new Map(); + /** + * Tracks that are actively being tracked (discovered from source recently) to see if they will "qualify" as being played. + * + * Once a track qualifies it is added to statefuls. + * */ + candidateRecentlyPlayed: GroupedPlays = new Map(); + + getFlatStatefulRecentlyPlayed = (): PlayObject[] => { + return Array.from(this.statefulRecentlyPlayed.values()).flat(); + } processRecentPlays = (plays: PlayObject[]) => { - let newStatefulPlays: any = []; - // first format new plays with locked play date - const lockedPlays = plays.map((p: any) => { + let newStatefulPlays: PlayObject[] = []; + // first format new plays with locked play date IE the first time we have "seen" this track + const flatLockedPlays = plays.map((p: any) => { 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) { - for(const p of lockedPlays) { - this.logger.debug(`No prior candidate recent plays! Adding new locked plays: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); - } - this.candidateRecentlyPlayed = lockedPlays; - } else { - // otherwise determine new tracks (not found in prior candidates) - const newTracks = lockedPlays.filter((x: any) => this.candidateRecentlyPlayed.every(y => !playObjDataMatch(y, x))); - if(newTracks.length > 0) { - for(const p of newTracks) { - this.logger.debug(`New play found that does not match existing candidates will be added: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); + }); + // group by device-user + const groupedLockedPlays = flatLockedPlays.reduce((acc: GroupedPlays, curr: ProgressAwarePlayObject) => { + const id = genGroupId(curr); + acc.set(id, (acc.get(id) ?? []).concat(curr)); + return acc; + }, new Map()); + + for(const [groupId, lockedPlays] of groupedLockedPlays.entries()) { + let cRecentlyPlayed = this.candidateRecentlyPlayed.get(groupId) ?? []; + // if no candidates exist new plays are new candidates + if(cRecentlyPlayed.length === 0) { + this.logger.debug(`[Platform ${groupId}] No prior candidate recent plays!`) + const progressAware: ProgressAwarePlayObject[] = []; + for(const p of lockedPlays) { + progressAware.push(toProgressAwarePlayObject(p)); + this.logger.debug(`[Platform ${groupId}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); } - } - // filter prior candidates based on new recently played - this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.filter(x => { - const candidateMatchedLocked = lockedPlays.some((y: any) => playObjDataMatch(x, y)); - if(!candidateMatchedLocked) { - this.logger.debug(`Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['trackId', 'artist', 'track']})}`); + this.candidateRecentlyPlayed.set(groupId, progressAware) + } else { + // otherwise determine new tracks (not found in prior candidates) + const newTracks = lockedPlays.filter((x: any) => cRecentlyPlayed.every(y => !playObjDataMatch(y, x))); + const newProgressAwareTracks: ProgressAwarePlayObject[] = []; + if(newTracks.length > 0) { + this.logger.debug(`[Platform ${groupId}] New plays found that do not match existing candidates.`) + for(const p of newTracks) { + this.logger.debug(`[Platform ${groupId}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); + newProgressAwareTracks.push(toProgressAwarePlayObject(p)); + } } - return candidateMatchedLocked; - }); - // and then combine still playing with new tracks - this.candidateRecentlyPlayed = this.candidateRecentlyPlayed.concat(newTracks); - this.candidateRecentlyPlayed.sort(sortByPlayDate); + // filter prior candidates based on new recently played + cRecentlyPlayed = cRecentlyPlayed.filter(x => { + const candidateMatchedLocked = lockedPlays.some((y: any) => playObjDataMatch(x, y)); + if(!candidateMatchedLocked) { + this.logger.debug(`[Platform ${groupId}] Existing candidate not found in locked plays will be removed: ${buildTrackString(x, {include: ['trackId', 'artist', 'track']})}`); + } + return candidateMatchedLocked; + }); + // and then combine still playing with new tracks + cRecentlyPlayed = cRecentlyPlayed.concat(newProgressAwareTracks); + cRecentlyPlayed.sort(sortByPlayDate); + + this.candidateRecentlyPlayed.set(groupId, cRecentlyPlayed); + + const sRecentlyPlayed = this.statefulRecentlyPlayed.get(groupId) ?? []; + + // now we check if all candidates pass tests for having been tracked long enough: + // * Has been tracked for at least 30 seconds + // * If it has playback position data then it must also have progressed at least 30 seconds since our initial tracking data + for(const candidate of cRecentlyPlayed) { + const {data: {playDate, track}} = candidate; + const firstSeenValid = playDate.isBefore(dayjs().subtract(30, 's')); + let progressValid = firstSeenValid; + if (firstSeenValid) { + // check if we can get progress as well + const matchingLockedPlay = lockedPlays.find(x => playObjDataMatch(x, candidate)); + // this should always be found but checking just in case + if (matchingLockedPlay !== undefined) { + const progress = getProgress(candidate, matchingLockedPlay); + if (progress !== undefined) { + if (progress < 30) { + progressValid = false; + } + } + } + } - 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 + if(firstSeenValid && progressValid) { + // a prior candidate has been playing for more than 30 seconds and passed progress test, time to check statefuls - const matchingRecent = this.statefulRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); - let stPrefix = `(Stateful Play) ${buildTrackString(candidate, {include: ['trackId', '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`); + const matchingRecent = sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); + let stPrefix = `[Platform ${groupId}] (Stateful Play) ${buildTrackString(candidate, {include: ['trackId', '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); + sRecentlyPlayed.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); + sRecentlyPlayed.push(candidate); + } + } else if(!playObjDataMatch(sRecentlyPlayed[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); + sRecentlyPlayed.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); } } } } + sRecentlyPlayed.sort(sortByPlayDate); + this.statefulRecentlyPlayed.set(groupId, sRecentlyPlayed); } - this.statefulRecentlyPlayed.sort(sortByPlayDate); } return newStatefulPlays; } diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index 27c7a33b..4fb897fc 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -192,7 +192,6 @@ export class SubsonicSource extends MemorySource { } = resp; // sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays const deduped = removeDuplicates(entry.map(SubsonicSource.formatPlayObj)); - this.processRecentPlays(deduped); - return this.statefulRecentlyPlayed; + return this.processRecentPlays(deduped); } } diff --git a/src/utils.ts b/src/utils.ts index 737248f9..fc646e4e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,11 +5,10 @@ import winston, {Logger} from "winston"; import stringify from 'safe-stable-stringify'; import JSON5 from 'json5'; import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js"; -import { Response } from 'superagent'; import Ajv, {Schema} from 'ajv'; import { lowGranularitySources, - PlayObject, + PlayObject, ProgressAwarePlayObject, RemoteIdentityParts, TrackStringOptions } from "./common/infrastructure/Atomic.js"; @@ -276,8 +275,8 @@ export const parseRetryAfterSecsFromObj = (err: any) => { if (err instanceof TimeoutError) { return undefined; } - // @ts-ignore - if (err instanceof WebapiError || err instanceof Response) { + + if (err instanceof WebapiError || 'headers' in err) { const {headers = {}} = err; raVal = headers['retry-after'] } @@ -562,3 +561,14 @@ export const removeDuplicates = (plays: PlayObject[]): PlayObject[] => { return acc.concat(currPlay); }, []); } + +export const toProgressAwarePlayObject = (play: PlayObject): ProgressAwarePlayObject => { + return {...play, meta: {...play.meta, initialTrackProgressPosition: play.meta.trackProgressPosition}}; +} + +export const getProgress = (initial: ProgressAwarePlayObject, curr: PlayObject): number | undefined => { + if(initial.meta.initialTrackProgressPosition !== undefined && curr.meta.trackProgressPosition !== undefined) { + return Math.abs(curr.meta.trackProgressPosition - initial.meta.initialTrackProgressPosition); + } + return undefined; +} -- 2.51.2 From 285f593a0f20257b69a7b169a44b684e09700a91 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 2 Mar 2023 10:29:46 -0500 Subject: [PATCH 05/41] Add dbus-next dependency --- package-lock.json | 939 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 2 files changed, 931 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index f78ab31f..bda8f521 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "compare-versions": "^4.1.2", "concat-stream": "^2.0.0", "dayjs": "^1.10.4", + "dbus-next": "^0.10.2", "ejs": "^3.1.6", "es6-error": "^4.1.1", "express": "^4.17.1", @@ -127,6 +128,14 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@nornagon/put": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@nornagon/put/-/put-0.0.8.tgz", + "integrity": "sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==", + "engines": { + "node": ">=0.3.0" + } + }, "node_modules/@sindresorhus/is": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz", @@ -388,6 +397,12 @@ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.2.tgz", "integrity": "sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==" }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "optional": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -440,7 +455,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -459,6 +474,52 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "optional": true + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "optional": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -475,6 +536,24 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/async": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", @@ -485,6 +564,21 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "optional": true + }, "node_modules/axios": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.1.tgz", @@ -524,6 +618,24 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -610,6 +722,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "optional": true + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -633,6 +751,15 @@ "node": "*" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -666,6 +793,15 @@ "node": ">=4" } }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", @@ -770,6 +906,12 @@ "typedarray": "^0.0.6" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "optional": true + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -807,6 +949,12 @@ "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "optional": true + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -841,11 +989,40 @@ "node": ">=0.4.0" } }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dayjs": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==" }, + "node_modules/dbus-next": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.10.2.tgz", + "integrity": "sha512-kLNQoadPstLgKKGIXKrnRsMgtAK/o+ix3ZmcfTfvBHzghiO9yHXpoKImGnB50EXwnfSFaSAullW/7UrSkAISSQ==", + "dependencies": { + "@nornagon/put": "0.0.8", + "event-stream": "3.3.4", + "hexy": "^0.2.10", + "jsbi": "^2.0.5", + "long": "^4.0.0", + "safe-buffer": "^5.1.1", + "xml2js": "^0.4.17" + }, + "optionalDependencies": { + "usocket": "^0.3.0" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -881,6 +1058,12 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -916,11 +1099,26 @@ "node": ">=0.3.1" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, "node_modules/duplexer3": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -944,7 +1142,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "devOptional": true }, "node_modules/enabled": { "version": "2.0.0", @@ -1024,6 +1222,15 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -1051,6 +1258,20 @@ "node": ">= 0.6" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -1155,11 +1376,32 @@ "node": ">= 0.8" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "optional": true + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "optional": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "optional": true + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1178,6 +1420,12 @@ "moment": "^2.29.1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "optional": true + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -1246,6 +1494,15 @@ } } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -1289,17 +1546,109 @@ "node": ">= 0.6" } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "devOptional": true }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, + "node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "optional": true, + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "optional": true, + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "optional": true, + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "optional": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1336,11 +1685,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, + "devOptional": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -1392,6 +1750,57 @@ "got": "10.7.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "optional": true + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "optional": true + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -1422,6 +1831,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "optional": true + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", @@ -1430,6 +1845,14 @@ "node": ">=8" } }, + "node_modules/hexy": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", + "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", + "bin": { + "hexy": "bin/hexy_cmd.js" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -1450,6 +1873,21 @@ "node": ">= 0.8" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1465,7 +1903,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, + "devOptional": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -1493,7 +1931,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -1509,6 +1947,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "optional": true + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "optional": true + }, "node_modules/jake": { "version": "10.8.5", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", @@ -1526,16 +1988,39 @@ "node": ">=10" } }, + "node_modules/jsbi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-2.0.5.tgz", + "integrity": "sha512-TzO/62Hxeb26QMb4IGlI/5X+QLr9Uqp1FPkwp2+KOICW+Q+vSuFj61c8pkT6wAns4WcK56X7CmSHhJeDGWOqxQ==" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "optional": true + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "optional": true + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1547,6 +2032,21 @@ "node": ">=6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/keyv": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz", @@ -1594,6 +2094,11 @@ "node": ">=10" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -1619,6 +2124,11 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1701,6 +2211,52 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.4.tgz", + "integrity": "sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ==", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -1714,6 +2270,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/nan": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", + "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", + "optional": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1722,6 +2284,45 @@ "node": ">= 0.6" } }, + "node_modules/node-gyp": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-7.1.2.tgz", + "integrity": "sha512-CbpcIo7C3eMu3dL1c3d0xw449fHIGALIJsRP4DDPHpyiW8vcriNY7ubh9TE4zEKfSxscY7PjeFnshE7h75ynjQ==", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.3", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "request": "^2.88.2", + "rimraf": "^3.0.2", + "semver": "^7.3.2", + "tar": "^6.0.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -1733,6 +2334,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "optional": true, + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, "node_modules/ntfy": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/ntfy/-/ntfy-1.0.5.tgz", @@ -1744,11 +2357,29 @@ "node": ">= 10.9" } }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/oauth": { "version": "0.9.15", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1919,7 +2550,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -1934,6 +2565,20 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, "node_modules/pony-cause": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", @@ -1942,6 +2587,12 @@ "node": ">=12.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "optional": true + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1959,6 +2610,12 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "optional": true + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -2033,6 +2690,61 @@ "node": ">= 6" } }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2061,6 +2773,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2090,6 +2817,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, "node_modules/semver": { "version": "7.3.8", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", @@ -2146,6 +2878,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "optional": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2176,6 +2914,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "optional": true + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -2263,6 +3007,17 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, "node_modules/spotify-web-api-node": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/spotify-web-api-node/-/spotify-web-api-node-5.0.2.tgz", @@ -2271,6 +3026,31 @@ "superagent": "^6.1.0" } }, + "node_modules/sshpk": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", + "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -2287,6 +3067,14 @@ "node": ">= 0.8" } }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dependencies": { + "duplexer": "~0.1.1" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2299,7 +3087,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2313,7 +3101,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2393,11 +3181,33 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^4.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, "node_modules/to-readable-stream": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz", @@ -2414,6 +3224,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/triple-beam": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", @@ -2495,6 +3318,24 @@ "json5": "lib/cli.js" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "optional": true + }, "node_modules/type-fest": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.10.0.tgz", @@ -2615,6 +3456,18 @@ "punycode": "^2.1.0" } }, + "node_modules/usocket": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/usocket/-/usocket-0.3.0.tgz", + "integrity": "sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "nan": "^2.14.2", + "node-gyp": "^7.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2628,6 +3481,16 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -2642,6 +3505,44 @@ "node": ">= 0.8" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/winston": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/winston/-/winston-3.8.2.tgz", @@ -2815,6 +3716,26 @@ } } }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 909f809a..4831eebc 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "compare-versions": "^4.1.2", "concat-stream": "^2.0.0", "dayjs": "^1.10.4", + "dbus-next": "^0.10.2", "ejs": "^3.1.6", "es6-error": "^4.1.1", "express": "^4.17.1", -- 2.51.2 From 5ae79deca4fac78270798cb3d34ed2c8dcca8790 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 2 Mar 2023 12:57:29 -0500 Subject: [PATCH 06/41] feat: Implement MPRIS Source --- README.md | 1 + assets/mpris.jpg | Bin 0 -> 36520 bytes config/mpris.json.example | 9 + docs/configuration.md | 25 +++ src/common/index.ts | 2 +- src/common/infrastructure/Atomic.ts | 4 +- .../infrastructure/config/source/mpris.ts | 55 +++++ .../infrastructure/config/source/sources.ts | 5 +- src/common/schema/aio-source.json | 110 ++++++++++ src/common/schema/aio.json | 110 ++++++++++ src/common/schema/source.json | 103 +++++++++ src/sources/MPRISSource.ts | 195 ++++++++++++++++++ src/sources/ScrobbleSources.ts | 25 ++- src/utils.ts | 16 ++ 14 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 assets/mpris.jpg create mode 100644 config/mpris.json.example create mode 100644 src/common/infrastructure/config/source/mpris.ts create mode 100644 src/sources/MPRISSource.ts diff --git a/README.md b/README.md index bfd83327..a8b238b1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git * [Youtube Music](/docs/configuration.md#youtube-music) * [Last.fm](/docs/configuration.md#lastfm-source) * [Deezer](/docs/configuration.md#deezer) + * [MPRIS (Linux Desktop)](/docs/configuration.md#mpris) * Supports scrobbling to many clients * [Maloja](/docs/configuration.md#maloja) * [Last.fm](/docs/configuration.md#lastfm) diff --git a/assets/mpris.jpg b/assets/mpris.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e4ebb006d0c4733c937e48ac805d7a3abbd64f89 GIT binary patch literal 36520 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 literal 0 HcmV?d00001 diff --git a/config/mpris.json.example b/config/mpris.json.example new file mode 100644 index 00000000..3026f10c --- /dev/null +++ b/config/mpris.json.example @@ -0,0 +1,9 @@ +[ + { + "name": "ubuntu", + "data": { + "whitelist": ["vlc", "mpd"], + "blacklist": ["spotify"] + } + } +] diff --git a/docs/configuration.md b/docs/configuration.md index 90e5d089..816401bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,6 +12,7 @@ * [Last.fm (Source)](#lastfm--source-) * [Deezer](#deezer) * [Youtube Music](#youtube-music) + * [MPRIS (Linux Desktop)](#mpris) * [Client Configurations](#client-configurations) * [Maloja](#maloja) * [Last.fm](#lastfm) @@ -322,6 +323,30 @@ NOTES: See [`ytmusic.json.example`](/config/ytmusic.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FYTMusicSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json) +## [MPRIS](https://specifications.freedesktop.org/mpris-spec/latest/) + +MPRIS is a standard interface for communicating with Music Players on **linux operating systems.** + +If you run Linux and have a notification tray that shows what media you are listening to, you likely have access to MPRIS. + +![Notification Tray](/assets/mpris.jpg) + +multi-scrobbler can listen to this interface and scrobble tracks played by **any media player** that communicates to the operating system with MPRIS. + +**NOTE:** multi-scrobbler needs to be running as a [**Local Installation**](/docs/installation.md#local) in order to use MPRIS. This cannot be used from docker. + +### ENV-Based + +| Environmental Variable | Required? | Default | Description | +|------------------------|-----------|---------|----------------------------------------------------------------------------------| +| MPRIS_ENABLE | No | | Use MPRIS as a Source (useful when you don't need any other options) | +| MPRIS_BLACKLIST | No | | Comma-delimited list of player names not to scrobble from | +| MPRIS_WHITELIST | No | | Comma-delimited list of players names to ONLY scrobble from. Overrides blacklist | + +### File-Based + +See [`mpris.json.example`](/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) + # Client Configurations diff --git a/src/common/index.ts b/src/common/index.ts index a0025a74..334684a9 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import {fileURLToPath} from "url"; +//import {fileURLToPath} from "url"; //const __filename = fileURLToPath(import.meta.url); //const __dirname = path.dirname(__filename); diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 887468ac..c31b267b 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -1,7 +1,7 @@ import {Dayjs} from "dayjs"; -export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic'; -export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic']; +export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris'; +export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris']; export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic']; diff --git a/src/common/infrastructure/config/source/mpris.ts b/src/common/infrastructure/config/source/mpris.ts new file mode 100644 index 00000000..4ff190ec --- /dev/null +++ b/src/common/infrastructure/config/source/mpris.ts @@ -0,0 +1,55 @@ +import {CommonSourceConfig, CommonSourceData} from "./index.js"; + +export const PLAYBACK_STATUS_PLAYING = 'Playing'; +export const PLAYBACK_STATUS_PAUSED = 'Paused'; +export const PLAYBACK_STATUS_STOPPED = 'Stopped'; + +export type PlaybackStatus = 'Playing' | 'Paused' | 'Stopped'; + +export const MPRIS_IFACE = 'org.mpris.MediaPlayer2.Player'; +export const MPRIS_PATH = '/org/mpris/MediaPlayer2'; +export const PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'; + +export interface MPRISMetadata { + trackid?: string + length?: number + artUrl?: string + album?: string + albumArtist?: string[] + artist?: string[] + title?: string + url?: string +} + +export interface PlayerInfo { + name: string + status: PlaybackStatus + position?: number + metadata: MPRISMetadata +} + +export interface MPRISData extends CommonSourceData { + /** + * DO NOT scrobble from any players that START WITH these values, case-insensitive + * + * @examples [["spotify","vlc"]] + * */ + blacklist?: string | string[] + + /** + * ONLY from any players that START WITH these values, case-insensitive + * + * If whitelist is present then blacklist is ignored + * + * @examples [["spotify","vlc"]] + * */ + whitelist?: string | string[] +} + +export interface MPRISSourceConfig extends CommonSourceConfig { + data: MPRISData +} + +export interface MPRISSourceAIOConfig extends MPRISSourceConfig { + type: 'mpris' +} diff --git a/src/common/infrastructure/config/source/sources.ts b/src/common/infrastructure/config/source/sources.ts index b797124b..5a4d12dc 100644 --- a/src/common/infrastructure/config/source/sources.ts +++ b/src/common/infrastructure/config/source/sources.ts @@ -6,7 +6,8 @@ import {SubsonicSourceAIOConfig, SubSonicSourceConfig} from "./subsonic.js"; import {JellySourceAIOConfig, JellySourceConfig} from "./jellyfin.js"; import {LastFmSouceAIOConfig, LastfmSourceConfig} from "./lastfm.js"; import {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.js"; +import {MPRISSourceAIOConfig, MPRISSourceConfig} from "./mpris.js"; -export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig; +export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig; -export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig; +export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig; diff --git a/src/common/schema/aio-source.json b/src/common/schema/aio-source.json index 4bf740df..05529a9c 100644 --- a/src/common/schema/aio-source.json +++ b/src/common/schema/aio-source.json @@ -328,6 +328,113 @@ ], "type": "object" }, + "MPRISData": { + "properties": { + "blacklist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "DO NOT scrobble from any players that START WITH these values, case-insensitive", + "examples": [ + [ + "spotify", + "vlc" + ] + ] + }, + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "whitelist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "ONLY from any players that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored", + "examples": [ + [ + "spotify", + "vlc" + ] + ] + } + }, + "type": "object" + }, + "MPRISSourceAIOConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "data": { + "$ref": "#/definitions/MPRISData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + }, + "type": { + "enum": [ + "mpris" + ], + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "type": "object" + }, "PlexSourceAIOConfig": { "properties": { "clients": { @@ -482,6 +589,9 @@ }, { "$ref": "#/definitions/YTMusicSourceAIOConfig" + }, + { + "$ref": "#/definitions/MPRISSourceAIOConfig" } ] }, diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index 8e0963ab..d61a9045 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -529,6 +529,113 @@ ], "type": "object" }, + "MPRISData": { + "properties": { + "blacklist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "DO NOT scrobble from any players that START WITH these values, case-insensitive", + "examples": [ + [ + "spotify", + "vlc" + ] + ] + }, + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "whitelist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "ONLY from any players that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored", + "examples": [ + [ + "spotify", + "vlc" + ] + ] + } + }, + "type": "object" + }, + "MPRISSourceAIOConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "data": { + "$ref": "#/definitions/MPRISData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + }, + "type": { + "enum": [ + "mpris" + ], + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "type": "object" + }, "MalojaClientAIOConfig": { "properties": { "data": { @@ -906,6 +1013,9 @@ }, { "$ref": "#/definitions/YTMusicSourceAIOConfig" + }, + { + "$ref": "#/definitions/MPRISSourceAIOConfig" } ] }, diff --git a/src/common/schema/source.json b/src/common/schema/source.json index 9aec2a5d..59de4fb2 100644 --- a/src/common/schema/source.json +++ b/src/common/schema/source.json @@ -24,6 +24,9 @@ }, { "$ref": "#/definitions/YTMusicSourceConfig" + }, + { + "$ref": "#/definitions/MPRISSourceConfig" } ], "definitions": { @@ -333,6 +336,106 @@ ], "type": "object" }, + "MPRISData": { + "properties": { + "blacklist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "DO NOT scrobble from any players that START WITH these values, case-insensitive", + "examples": [ + [ + "spotify", + "vlc" + ] + ] + }, + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "whitelist": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "ONLY from any players that START WITH these values, case-insensitive\n\nIf whitelist is present then blacklist is ignored", + "examples": [ + [ + "spotify", + "vlc" + ] + ] + } + }, + "type": "object" + }, + "MPRISSourceConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "data": { + "$ref": "#/definitions/MPRISData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "PlexSourceConfig": { "properties": { "clients": { diff --git a/src/sources/MPRISSource.ts b/src/sources/MPRISSource.ts new file mode 100644 index 00000000..bb35a18a --- /dev/null +++ b/src/sources/MPRISSource.ts @@ -0,0 +1,195 @@ +import dbus, {ClientInterface, Variant} from 'dbus-next'; +import dayjs from "dayjs"; +import { + MPRIS_IFACE, + MPRIS_PATH, + MPRISMetadata, MPRISSourceConfig, PLAYBACK_STATUS_STOPPED, + PlaybackStatus, PlayerInfo, + PROPERTIES_IFACE +} from "../common/infrastructure/config/source/mpris.js"; +import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import MemorySource from "./MemorySource.js"; +import {Notifiers} from "../notifier/Notifiers.js"; +import {RecentlyPlayedOptions} from "./AbstractSource.js"; +import {removeDuplicates} from "../utils.js"; + + +export class MPRISSource extends MemorySource { + + declare config: MPRISSourceConfig; + + whitelist: string[] = []; + blacklist: string[] = []; + + constructor(name: any, config: MPRISSourceConfig, internal: InternalConfig, notifier: Notifiers) { + super('mpris', name, config, internal, notifier); + this.canPoll = true; + + const {data: {whitelist = [], blacklist = []} = {}} = config; + if(!Array.isArray(whitelist)) { + this.whitelist = whitelist.split(',') + } else { + this.whitelist = whitelist; + } + if(!Array.isArray(blacklist)) { + this.blacklist = blacklist.split(','); + } else { + this.blacklist = blacklist; + } + } + + static formatPlayObj(obj: PlayerInfo, newFromSource = false): PlayObject { + const { + name, + position, + metadata: { + length, + album, + artist = [], + albumArtist = [], + title, + trackid, + url, + } = {} + } = obj; + + return { + data: { + track: title, + album, + artists: Array.from(new Set(artist.concat(albumArtist))), + duration: length, + playDate: dayjs() + }, + meta: { + source: 'dbus', + trackId: trackid, + newFromSource, + url: { + web: url + }, + trackProgressPosition: position, + deviceId: name, + } + } + } + + initialize = async () => { + // test if we can get DBus + try { + await this.getDBus(); + return true; + } catch (e) { + this.logger.error('Could not get DBus interface from operating system'); + this.logger.error(e); + return false; + } + } + + protected getDBus = async () => { + const bus = dbus.sessionBus(); + const obj = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); + return obj.getInterface('org.freedesktop.DBus'); + } + + protected listAll = async () => { + let iface = await this.getDBus(); + let names = await iface.ListNames(); + return names.filter((n) => n.startsWith('org.mpris.MediaPlayer2')) + } + + getPlayersInfo = async (activeOnly = true): Promise => { + const list = await this.listAll(); + + let bus = dbus.sessionBus(); + + const playerInfos: PlayerInfo[] = []; + + for (const playerName of list) { + let obj = await bus.getProxyObject(playerName, MPRIS_PATH); + + const plainPlayerName = playerName.replace('org.mpris.MediaPlayer2.', ''); + //let player = obj.getInterface(MPRIS_IFACE); + let props = obj.getInterface(PROPERTIES_IFACE); + + const pos = await this.getPlayerPosition(props); + const status = await this.getPlayerStatus(props); + if (status === PLAYBACK_STATUS_STOPPED && activeOnly) { + continue; + } + const metadata = await this.getPlayerMetadata(props); + playerInfos.push({ + name: plainPlayerName, + status, + position: pos, + metadata + }); + } + return playerInfos; + } + + protected getPlayerPosition = async (props: ClientInterface): Promise => { + const pos = await props.Get(MPRIS_IFACE, 'Position'); + return dayjs.duration({milliseconds: Number(pos.value / 1000n)}).asSeconds(); + } + + protected getPlayerStatus = async (props: ClientInterface): Promise => { + const status = await props.Get(MPRIS_IFACE, 'PlaybackStatus'); + return status.value as PlaybackStatus; + } + + protected getPlayerMetadata = async (props: ClientInterface): Promise => { + const metadata = await props.Get(MPRIS_IFACE, 'Metadata'); + return this.metadataToPlain(metadata.value); + } + + metadataToPlain = (metadataVariant): MPRISMetadata => { + let metadataPlain = {}; + for (let k of Object.keys(metadataVariant)) { + let value = metadataVariant[k]; + if (value === undefined || value === null) { + //logging.warn(`ignoring a null metadata value for key ${k}`); + continue; + } + const plainKey = k.replace(/mpris:|xesam:/, ''); + if (value instanceof Variant) { + if (typeof value.value === 'bigint') { + // in this context we're using it as a duration (track length or playback position) + metadataPlain[plainKey] = dayjs.duration({milliseconds: Number(value.value / 1000n)}).asSeconds(); + } else { + metadataPlain[plainKey] = value.value; + } + } else { + metadataPlain[plainKey] = value; + } + } + return metadataPlain; + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + const infos = await this.getPlayersInfo(); + let plays: PlayObject[] = []; + for(const info of infos) { + const lowerName = info.name.toLocaleLowerCase(); + if(this.whitelist.length > 0) { + if(!this.whitelist.some(x => lowerName.includes(x.toLocaleLowerCase()))) { + this.logger.debug(`No name in whitelist was found in Player Name '${info.name}', skipping player`); + continue; + } + } else if(this.blacklist.length > 0) { + if(this.whitelist.some(x => lowerName.includes(x.toLocaleLowerCase()))) { + this.logger.debug(`A name in blacklist was found in Player Name '${info.name}', skipping player`); + continue; + } + } + plays.push(MPRISSource.formatPlayObj(info)); + } + const deduped = removeDuplicates(plays); + if(options.display === true) { + return deduped; + } + return this.processRecentPlays(deduped); + } +} + + diff --git a/src/sources/ScrobbleSources.ts b/src/sources/ScrobbleSources.ts index f3cac21d..9cbea0fc 100644 --- a/src/sources/ScrobbleSources.ts +++ b/src/sources/ScrobbleSources.ts @@ -1,4 +1,4 @@ -import {createLabelledLogger, readJson, validateJson} from "../utils.js"; +import {createLabelledLogger, parseBool, readJson, validateJson} from "../utils.js"; import SpotifySource from "./SpotifySource.js"; import PlexSource from "./PlexSource.js"; import TautulliSource from "./TautulliSource.js"; @@ -25,6 +25,8 @@ import {LastfmSourceConfig} from "../common/infrastructure/config/source/lastfm. import YTMusicSource from "./YTMusicSource.js"; import {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.js"; import {Notifiers} from "../notifier/Notifiers.js"; +import {MPRISData, MPRISSourceConfig} from "../common/infrastructure/config/source/mpris.js"; +import {MPRISSource} from "./MPRISSource.js"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -229,6 +231,23 @@ export default class ScrobbleSources { }); } break; + case 'mpris': + const shouldUse = parseBool(process.env.MPRIS_ENABLE); + const mp = { + blacklist: process.env.MPRIS_BLACKLIST, + whitelist: process.env.MPRIS_WHITELIST + } + if (!Object.values(mp).every(x => x === undefined) || shouldUse) { + configs.push({ + type: 'mpris', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: mp as MPRISData + }); + } + break; default: break; } @@ -370,6 +389,10 @@ export default class ScrobbleSources { break; case 'ytmusic': newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, internal, notifier); + break; + case 'mpris': + newSource = await new MPRISSource(name, compositeConfig as MPRISSourceConfig, internal, notifier); + break; default: break; } diff --git a/src/utils.ts b/src/utils.ts index fc646e4e..ee05bc11 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -572,3 +572,19 @@ export const getProgress = (initial: ProgressAwarePlayObject, curr: PlayObject): } return undefined; } + +export function parseBool(value: any, prev: any = false): boolean { + let usedVal = value; + if (value === undefined || value === '') { + usedVal = prev; + } + if(usedVal === undefined || usedVal === '') { + return false; + } + if (typeof usedVal === 'string') { + return usedVal === 'true'; + } else if (typeof usedVal === 'boolean') { + return usedVal; + } + throw new Error('Not a boolean value.'); +} -- 2.51.2 From 7a8138773c9f7c2b2cf58f386cbc35092197ed93 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 2 Mar 2023 13:38:56 -0500 Subject: [PATCH 07/41] feat(spotify): Implement currently playing retrieval --- src/sources/SpotifySource.ts | 119 ++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 16 deletions(-) diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index baa115cf..d366d310 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -10,8 +10,12 @@ import {SpotifySourceConfig} from "../common/infrastructure/config/source/spotif import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import PlayHistoryObject = SpotifyApi.PlayHistoryObject; import {Notifiers} from "../notifier/Notifiers.js"; +import CurrentlyPlayingObject = SpotifyApi.CurrentlyPlayingObject; +import TrackObjectFull = SpotifyApi.TrackObjectFull; +import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified; +import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified; -const scopes = ['user-read-recently-played', 'user-read-currently-playing']; +const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position']; const state = 'random'; export default class SpotifySource extends AbstractSource { @@ -42,23 +46,74 @@ export default class SpotifySource extends AbstractSource { this.canPoll = true; } - static formatPlayObj(obj: PlayHistoryObject, newFromSource = false): PlayObject { - const { - track: { - artists = [], - name, - id, - duration_ms, - album: { - name: albumName, - } = {}, + static formatPlayObj(obj: object, newFromSource = false): PlayObject { + let artists: ArtistObjectSimplified[]; + let album: AlbumObjectSimplified; + let name: string; + let duration_ms: number; + let played_at: string; + let id: string; + let url: string; + let playbackPosition: number | undefined; + + + if (asPlayHistoryObject(obj)) { + const { + track, + played_at: pa + } = obj; + const { + artists: art = [], + name: n, + id: i, + duration_ms: dm, + album: a, external_urls: { spotify, } = {} - } = {}, - played_at - } = obj; - //let artistString = artists.reduce((acc, curr) => acc.concat(curr.name), []).join(','); + } = track; + + played_at = pa; + artists = art; + name = n; + id = i; + duration_ms = dm; + album = a; + url = spotify; + + } else if (asCurrentlyPlayingObject(obj)) { + const { + is_playing, + progress_ms, + timestamp, + item, + } = obj; + const { + artists: art, + name: n, + id: i, + duration_ms: dm, + album: a, + external_urls: { + spotify, + } = {} + } = item as TrackObjectFull; + + played_at = dayjs(timestamp).toISOString(); + artists = art; + name = n; + id = i; + duration_ms = dm; + album = a; + url = spotify; + playbackPosition = progress_ms / 1000; + + } else { + throw new Error('Could not determine format of spotify response data'); + } + + const {name: albumName} = album || {}; + return { data: { artists: artists.map((x: any) => x.name), @@ -70,9 +125,10 @@ export default class SpotifySource extends AbstractSource { meta: { source: 'Spotify', trackId: id, + trackProgressPosition: playbackPosition, newFromSource, url: { - web: spotify + web: url } } }; @@ -184,6 +240,28 @@ export default class SpotifySource extends AbstractSource { return result.body.items.map((x: any) => SpotifySource.formatPlayObj(x)).sort(sortByPlayDate); } + getNowPlaying = async () => { + const func = (api: SpotifyWebApi) => api.getMyCurrentPlayingTrack(); + const playingRes = await this.callApi>(func); + + const {body: {item}} = playingRes; + if(item !== undefined) { + const play = SpotifySource.formatPlayObj(playingRes.body); + return play; + } + return undefined; + } + + getCurrentPlaybackState = async () => { + const funcState = (api: SpotifyWebApi) => api.getMyCurrentPlaybackState(); + return await this.callApi>(funcState); + } + + getDevices = async () => { + const funcDevice = (api: SpotifyWebApi) => api.getMyDevices(); + return await this.callApi>(funcDevice); + } + callApi = async (func: (api: SpotifyWebApi) => Promise, retries = 0): Promise => { const { maxRequestRetries = 1, @@ -237,6 +315,15 @@ export default class SpotifySource extends AbstractSource { this.logger.warn('Cannot poll spotify without valid credentials configuration') return; } + await this.getNowPlaying(); await this.startPolling(allClients); } } + +const asPlayHistoryObject = (obj: object): obj is PlayHistoryObject => { + return 'played_at' in obj; +} + +const asCurrentlyPlayingObject = (obj: object): obj is CurrentlyPlayingObject => { + return 'is_playing' in obj; +} -- 2.51.2 From 6d3cbf32df11cce7cc7b8318b055e576bdf71a45 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 2 Mar 2023 15:29:31 -0500 Subject: [PATCH 08/41] feat(spotify): Refactor spotify Source as Memory Source #51 * Use recently-played to scrobble backlogged tracks * Use currently-playing or current-state to implement spotify as a memory source in order to track plays made from spotify connect devices --- src/sources/AbstractSource.ts | 142 +++++++++++++++++++--------------- src/sources/MemorySource.ts | 14 ++-- src/sources/SpotifySource.ts | 90 +++++++++++++++++---- 3 files changed, 166 insertions(+), 80 deletions(-) diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index 927551a9..1c66d92f 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -140,74 +140,22 @@ export default abstract class AbstractSource { break; } let playObjs: PlayObject[] = []; - this.logger.debug('Refreshing recently played') + this.logger.debug('Refreshing recently played'); playObjs = await this.getRecentlyPlayed({formatted: true}); - checkCount++; - let newTracksFound = false; - let closeToInterval = false; - const now = dayjs(); - - const playInfo = playObjs.reduce((acc, playObj) => { - if(this.recentlyPlayedTrackIsValid(playObj)) { - const {data: { - playDate - } = {} - } = playObj; - if (playDate.unix() > lastTrackPlayedAt.unix()) { - newTracksFound = true; - this.logger.info(`New Track => ${buildTrackString(playObj)}`); - - if (closeToInterval === false) { - closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5; - } - - return { - plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}], - lastTrackPlayedAt: playDate - } - } - return { - ...acc, - plays: [...acc.plays, playObj] - } - } - return acc; - }, {plays: [], lastTrackPlayedAt}); - playObjs = playInfo.plays; - lastTrackPlayedAt = playInfo.lastTrackPlayedAt; - - if (closeToInterval) { - // because the interval check was so close to the play date we are going to delay client calls for a few secs - // this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups) - // additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully - this.logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first'); - await sleep(10 * 1000); - } - if (newTracksFound === false) { - if (playObjs.length === 0) { - this.logger.debug(`No new tracks found and no tracks returned from API`); - } else { - this.logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`); - } - } else { + const { + lastTrackPlayedAt: lastPlayed, + scrobbleResult, + newTracksFound: newTracks + } = await this.ingestPlays(playObjs, allClients, lastTrackPlayedAt); + + lastTrackPlayedAt = lastPlayed; + + if(newTracks) { checkCount = 0; checksOverThreshold = 0; } - let scrobbleResult = []; - - if(playObjs.length > 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 - scrobbleResult = await allClients.scrobble(playObjs, { - checkTime: lastTrackPlayedAt.add(2, 's'), - forceRefresh: closeToInterval, - scrobbleFrom: this.identifier, - scrobbleTo: this.clients - }); - } - if (scrobbleResult.length > 0) { checkCount = 0; this.tracksDiscovered += scrobbleResult.length; @@ -237,4 +185,74 @@ export default abstract class AbstractSource { throw e; } } + + protected ingestPlays = async (playObjs: PlayObject[], allClients: any, lastTrackPlayedAt: Dayjs) => { + let newTracksFound = false; + let closeToInterval = false; + const now = dayjs(); + + const playInfo = playObjs.reduce((acc, playObj) => { + if(this.recentlyPlayedTrackIsValid(playObj)) { + const {data: { + playDate + } = {} + } = playObj; + if (playDate.unix() > lastTrackPlayedAt.unix()) { + newTracksFound = true; + this.logger.info(`New Track => ${buildTrackString(playObj)}`); + + if (closeToInterval === false) { + closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5; + } + + return { + plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}], + lastTrackPlayedAt: playDate + } + } + return { + ...acc, + plays: [...acc.plays, playObj] + } + } + return acc; + }, {plays: [], lastTrackPlayedAt}); + playObjs = playInfo.plays; + lastTrackPlayedAt = playInfo.lastTrackPlayedAt; + + if (playObjs.length > 0 && closeToInterval) { + // because the interval check was so close to the play date we are going to delay client calls for a few secs + // this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups) + // additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully + this.logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first'); + await sleep(10 * 1000); + } + + if (newTracksFound === false) { + if (playObjs.length === 0) { + this.logger.debug(`No new tracks found`); + } else { + this.logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`); + } + } + + let scrobbleResult = []; + + if(playObjs.length > 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 + scrobbleResult = await allClients.scrobble(playObjs, { + checkTime: lastTrackPlayedAt.add(2, 's'), + forceRefresh: closeToInterval, + scrobbleFrom: this.identifier, + scrobbleTo: this.clients + }); + } + + return { + newTracksFound, + scrobbleResult, + lastTrackPlayedAt + } + } } diff --git a/src/sources/MemorySource.ts b/src/sources/MemorySource.ts index 7a1cd9cf..40705b3a 100644 --- a/src/sources/MemorySource.ts +++ b/src/sources/MemorySource.ts @@ -34,14 +34,17 @@ export default class MemorySource extends AbstractSource { candidateRecentlyPlayed: GroupedPlays = new Map(); getFlatStatefulRecentlyPlayed = (): PlayObject[] => { - return Array.from(this.statefulRecentlyPlayed.values()).flat(); + return Array.from(this.statefulRecentlyPlayed.values()).flat().sort(sortByPlayDate); } - processRecentPlays = (plays: PlayObject[]) => { + processRecentPlays = (plays: PlayObject[], useExistingPlayDate = false) => { let newStatefulPlays: PlayObject[] = []; - // first format new plays with locked play date IE the first time we have "seen" this track - const flatLockedPlays = plays.map((p: any) => { + // if we can't trust existing play dates (like for subsonic where there is no timestamp) then + // we format new plays with locked play date IE the first time we have "seen" this track + + // -- otherwise, for sources like Spotify that accurately report when track started to play, we can use existing dates + const flatLockedPlays = useExistingPlayDate ? plays : plays.map((p: any) => { const {data: {playDate, ...restData}, ...rest} = p; return {data: {...restData, playDate: dayjs()}, ...rest}; }); @@ -144,7 +147,8 @@ export default class MemorySource extends AbstractSource { this.statefulRecentlyPlayed.set(groupId, sRecentlyPlayed); } } - return newStatefulPlays; + return this.getFlatStatefulRecentlyPlayed(); + //return newStatefulPlays; } recentlyPlayedTrackIsValid = (playObj: any) => { diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index d366d310..00b7704e 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import { readJson, writeFile, - sortByPlayDate, sleep, parseRetryAfterSecsFromObj, + sortByPlayDate, sleep, parseRetryAfterSecsFromObj, buildTrackString, truncateStringToLength, combinePartsToString, } from "../utils.js"; import SpotifyWebApi from "spotify-web-api-node"; import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; @@ -14,11 +14,15 @@ import CurrentlyPlayingObject = SpotifyApi.CurrentlyPlayingObject; import TrackObjectFull = SpotifyApi.TrackObjectFull; import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified; import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified; +import UserDevice = SpotifyApi.UserDevice; +import MemorySource from "./MemorySource.js"; const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position']; const state = 'random'; -export default class SpotifySource extends AbstractSource { +const shortDeviceId = truncateStringToLength(10, ''); + +export default class SpotifySource extends MemorySource { spotifyApi: SpotifyWebApi; workingCredsPath: string; @@ -26,18 +30,20 @@ export default class SpotifySource extends AbstractSource { requiresAuth = true; requiresAuthInteraction = true; + canGetState = false; + declare config: SpotifySourceConfig; constructor(name: any, config: SpotifySourceConfig, internal: InternalConfig, notifier: Notifiers) { super('spotify', name, config, internal, notifier); const { data: { - interval = 60, + interval = 30, } = {} } = config; if (interval < 15) { - this.logger.warn('Interval should be above 30 seconds...😬'); + this.logger.warn('Interval should be 15 seconds or above...😬'); } this.config.data.interval = interval; @@ -55,6 +61,7 @@ export default class SpotifySource extends AbstractSource { let id: string; let url: string; let playbackPosition: number | undefined; + let deviceId: string | undefined; if (asPlayHistoryObject(obj)) { @@ -86,6 +93,10 @@ export default class SpotifySource extends AbstractSource { is_playing, progress_ms, timestamp, + device: { + id: deviceIdentifier, + name: deviceName + }, item, } = obj; const { @@ -107,6 +118,7 @@ export default class SpotifySource extends AbstractSource { album = a; url = spotify; playbackPosition = progress_ms / 1000; + deviceId = combinePartsToString([shortDeviceId(deviceIdentifier), deviceName]); } else { throw new Error('Could not determine format of spotify response data'); @@ -123,6 +135,7 @@ export default class SpotifySource extends AbstractSource { playDate: dayjs(played_at), }, meta: { + deviceId, source: 'Spotify', trackId: id, trackProgressPosition: playbackPosition, @@ -232,6 +245,26 @@ export default class SpotifySource extends AbstractSource { } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + const plays: PlayObject[] = []; + if(this.canGetState) { + const state = await this.getCurrentPlaybackState(); + if(state.play !== undefined) { + if(state.device.is_private_session) { + this.logger.debug(`Will not track play on Device ${state.device.name} because it is a private session: ${buildTrackString(state.play)}`); + } else { + plays.push(state.play); + } + } + } else { + const currPlay = await this.getNowPlaying(); + if(currPlay !== undefined) { + plays.push(currPlay); + } + } + return this.processRecentPlays(plays, true); + } + + getPlayHistory = async (options: RecentlyPlayedOptions = {}) => { const {limit = 20} = options; const func = (api: SpotifyWebApi) => api.getMyRecentlyPlayedTracks({ limit @@ -245,22 +278,36 @@ export default class SpotifySource extends AbstractSource { const playingRes = await this.callApi>(func); const {body: {item}} = playingRes; - if(item !== undefined) { - const play = SpotifySource.formatPlayObj(playingRes.body); - return play; + if(item !== undefined && item !== null) { + return SpotifySource.formatPlayObj(playingRes.body, true); } return undefined; } - getCurrentPlaybackState = async () => { - const funcState = (api: SpotifyWebApi) => api.getMyCurrentPlaybackState(); - return await this.callApi>(funcState); + getCurrentPlaybackState = async (logError = true): Promise<{device?: UserDevice, play?: PlayObject}> => { + try { + const funcState = (api: SpotifyWebApi) => api.getMyCurrentPlaybackState(); + const res = await this.callApi>(funcState); + const {body: { + device, + item + } = {}} = res; + return { + device: device === null ? undefined : device, + play: item !== null && item !== undefined ? SpotifySource.formatPlayObj(res.body, true) : undefined + } + } catch (e) { + if(logError) { + this.logger.error(`Error occurred while trying to retrieve current playback state: ${e.message}`); + } + throw e; + } } - getDevices = async () => { +/* getDevices = async () => { const funcDevice = (api: SpotifyWebApi) => api.getMyDevices(); return await this.callApi>(funcDevice); - } + }*/ callApi = async (func: (api: SpotifyWebApi) => Promise, retries = 0): Promise => { const { @@ -315,7 +362,24 @@ export default class SpotifySource extends AbstractSource { this.logger.warn('Cannot poll spotify without valid credentials configuration') return; } - await this.getNowPlaying(); + + // test capabilities + try { + await this.getCurrentPlaybackState(false); + this.canGetState = true; + } catch (e) { + this.logger.warn('multi-scrobbler does not have sufficient permissions to access Spotify API "Get Playback State". MS will continue to work but accuracy for determining if/when a track played from a Spotify Connect device (smart device controlled through Spotify app) may be degraded. To fix this re-authenticate MS with Spotify and restart polling.'); + } + + this.logger.info('Checking recently played API for tracks to backlog...'); + const backlogPlays = await this.getPlayHistory({formatted: true}); + const { + lastTrackPlayedAt: lastPlayed, + scrobbleResult, + newTracksFound: newTracks + } = await this.ingestPlays(backlogPlays, allClients, this.instantiatedAt); + this.logger.info('Backlog complete.'); + await this.startPolling(allClients); } } -- 2.51.2 From a2ab1aa49e9d435a57463d892b57acb6ff5bf3eb Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 3 Mar 2023 12:32:16 -0500 Subject: [PATCH 09/41] feat: Add polling options to config schema and make polling more aggressive Since spotify now uses currently playing with memory we need to poll more aggressively to make sure we catch tracks as they are played. Additionally need to decrease max polling interval so as not to miss activity. --- src/common/infrastructure/config/common.ts | 19 +++++++++ .../infrastructure/config/source/spotify.ts | 16 +++++-- .../infrastructure/config/source/subsonic.ts | 19 ++++++++- .../infrastructure/config/source/ytmusic.ts | 3 +- src/common/schema/aio-source.json | 42 ++++++++++++++++++- src/common/schema/aio.json | 42 ++++++++++++++++++- src/common/schema/source.json | 42 ++++++++++++++++++- src/sources/AbstractSource.ts | 29 ++++++++----- src/sources/SubsonicSource.ts | 6 +-- 9 files changed, 195 insertions(+), 23 deletions(-) diff --git a/src/common/infrastructure/config/common.ts b/src/common/infrastructure/config/common.ts index 835f7e48..33ffa481 100644 --- a/src/common/infrastructure/config/common.ts +++ b/src/common/infrastructure/config/common.ts @@ -24,3 +24,22 @@ export interface RequestRetryOptions { * */ retryMultiplier?: number } + +export interface PollingOptions { + + /** + * How long to wait before polling the source API for new tracks (in seconds) + * + * @default 30 + * @examples [30] + * */ + interval?: number + + /** + * When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds) + * + * @default 60 + * @examples [60] + * */ + maxInterval?: number +} diff --git a/src/common/infrastructure/config/source/spotify.ts b/src/common/infrastructure/config/source/spotify.ts index aa48b157..7342ecdf 100644 --- a/src/common/infrastructure/config/source/spotify.ts +++ b/src/common/infrastructure/config/source/spotify.ts @@ -1,6 +1,7 @@ import {CommonSourceConfig, CommonSourceData} from "./index.js"; +import {PollingOptions} from "../common.js"; -export interface SpotifySourceData extends CommonSourceData { +export interface SpotifySourceData extends CommonSourceData, PollingOptions { /** * spotify client id * @@ -21,10 +22,17 @@ export interface SpotifySourceData extends CommonSourceData { * */ redirectUri: string /** - * optional, how long to wait before calling spotify for new tracks (in seconds) + * How long to wait before polling the source API for new tracks (in seconds) * - * @default 60 - * @examples [60] + * It is unlikely you should need to change this unless you scrobble many very short tracks often + * + * Reading: + * * https://developer.spotify.com/documentation/web-api/guides/rate-limits/ + * * https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e + * * The rate limit is ~180 req/min + * + * @default 30 + * @examples [30] * */ interval?: number } diff --git a/src/common/infrastructure/config/source/subsonic.ts b/src/common/infrastructure/config/source/subsonic.ts index f6a8ed63..df1d02fc 100644 --- a/src/common/infrastructure/config/source/subsonic.ts +++ b/src/common/infrastructure/config/source/subsonic.ts @@ -1,6 +1,7 @@ import {CommonSourceConfig, CommonSourceData} from "./index.js"; +import {PollingOptions} from "../common.js"; -export interface SubsonicData extends CommonSourceData { +export interface SubsonicData extends CommonSourceData, PollingOptions { /** * URL of the subsonic media server to query * @@ -20,6 +21,22 @@ export interface SubsonicData extends CommonSourceData { * @examples ["MyPassword"] * */ password: string + + /** + * How long to wait before polling the source API for new tracks (in seconds) + * + * @default 10 + * @examples [10] + * */ + interval?: number + + /** + * When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds) + * + * @default 30 + * @examples [30] + * */ + maxInterval?: number } export interface SubSonicSourceConfig extends CommonSourceConfig { data: SubsonicData diff --git a/src/common/infrastructure/config/source/ytmusic.ts b/src/common/infrastructure/config/source/ytmusic.ts index fdd1c83c..e50d810e 100644 --- a/src/common/infrastructure/config/source/ytmusic.ts +++ b/src/common/infrastructure/config/source/ytmusic.ts @@ -1,6 +1,7 @@ import {CommonSourceConfig, CommonSourceData} from "./index.js"; +import {PollingOptions} from "../common.js"; -export interface YTMusicData extends CommonSourceData { +export interface YTMusicData extends CommonSourceData, PollingOptions { /** * The cookie retrieved from the Request Headers of music.youtube.com after logging in. * diff --git a/src/common/schema/aio-source.json b/src/common/schema/aio-source.json index 05529a9c..c9d6eb1d 100644 --- a/src/common/schema/aio-source.json +++ b/src/common/schema/aio-source.json @@ -676,8 +676,16 @@ "type": "string" }, "interval": { + "default": 30, + "description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min", + "examples": [ + 30 + ], + "type": "number" + }, + "maxInterval": { "default": 60, - "description": "optional, how long to wait before calling spotify for new tracks (in seconds)", + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", "examples": [ 60 ], @@ -728,6 +736,22 @@ }, "SubsonicData": { "properties": { + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "type": "number" + }, + "maxInterval": { + "default": 30, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, "maxPollRetries": { "default": 0, "description": "default # of automatic polling restarts on error", @@ -864,6 +888,22 @@ ], "type": "string" }, + "interval": { + "default": 30, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, + "maxInterval": { + "default": 60, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 60 + ], + "type": "number" + }, "maxPollRetries": { "default": 0, "description": "default # of automatic polling restarts on error", diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index d61a9045..9f7faaf3 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -1100,8 +1100,16 @@ "type": "string" }, "interval": { + "default": 30, + "description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min", + "examples": [ + 30 + ], + "type": "number" + }, + "maxInterval": { "default": 60, - "description": "optional, how long to wait before calling spotify for new tracks (in seconds)", + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", "examples": [ 60 ], @@ -1152,6 +1160,22 @@ }, "SubsonicData": { "properties": { + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "type": "number" + }, + "maxInterval": { + "default": 30, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, "maxPollRetries": { "default": 0, "description": "default # of automatic polling restarts on error", @@ -1298,6 +1322,22 @@ ], "type": "string" }, + "interval": { + "default": 30, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, + "maxInterval": { + "default": 60, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 60 + ], + "type": "number" + }, "maxPollRetries": { "default": 0, "description": "default # of automatic polling restarts on error", diff --git a/src/common/schema/source.json b/src/common/schema/source.json index 59de4fb2..8407d6fe 100644 --- a/src/common/schema/source.json +++ b/src/common/schema/source.json @@ -603,8 +603,16 @@ "type": "string" }, "interval": { + "default": 30, + "description": "How long to wait before polling the source API for new tracks (in seconds)\n\nIt is unlikely you should need to change this unless you scrobble many very short tracks often\n\nReading:\n* https://developer.spotify.com/documentation/web-api/guides/rate-limits/\n* https://medium.com/mendix/limiting-your-amount-of-calls-in-mendix-most-of-the-time-rest-835dde55b10e\n * The rate limit is ~180 req/min", + "examples": [ + 30 + ], + "type": "number" + }, + "maxInterval": { "default": 60, - "description": "optional, how long to wait before calling spotify for new tracks (in seconds)", + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", "examples": [ 60 ], @@ -683,6 +691,22 @@ }, "SubsonicData": { "properties": { + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "type": "number" + }, + "maxInterval": { + "default": 30, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, "maxPollRetries": { "default": 0, "description": "default # of automatic polling restarts on error", @@ -777,6 +801,22 @@ ], "type": "string" }, + "interval": { + "default": 30, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, + "maxInterval": { + "default": 60, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 60 + ], + "type": "number" + }, "maxPollRetries": { "default": 0, "description": "default # of automatic polling restarts on error", diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index 1c66d92f..46bc30ec 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -131,6 +131,11 @@ export default abstract class AbstractSource { let lastTrackPlayedAt = this.instantiatedAt; let checkCount = 0; let checksOverThreshold = 0; + + const {interval = 30, checkActiveFor = 300, maxInterval = 60} = this.config.data; + const maxBackoff = maxInterval - interval; + let sleepTime = interval; + try { this.polling = true; while (true) { @@ -161,20 +166,24 @@ export default abstract class AbstractSource { this.tracksDiscovered += scrobbleResult.length; } - const {interval = 30, checkActiveFor = 300, maxSleep = 300} = this.config.data; - - let sleepTime = interval; - // 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 const activeThreshold = lastTrackPlayedAt.add(checkActiveFor, 's'); - if (activeThreshold.isBefore(dayjs()) && sleepTime < 300) { + const inactiveFor = dayjs.duration(Math.abs(activeThreshold.diff(dayjs(), 'millisecond'))).humanize(false); + if (activeThreshold.isBefore(dayjs())) { checksOverThreshold++; - const backoffMultiplier = Math.min(checksOverThreshold, 1000) * 1.5; - sleepTime = Math.min(interval * backoffMultiplier, maxSleep); + if(sleepTime < maxInterval) { + const checkVal = Math.min(checksOverThreshold, 1000); + const backoff = Math.round(Math.max(Math.min(Math.min(checkVal, 1000) * 2 * (1.1 * checkVal), maxBackoff), 5)); + sleepTime = interval + backoff; + this.logger.debug(`Last activity was at ${lastTrackPlayedAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for interval ${interval} + ${backoff} seconds.`); + } else { + this.logger.debug(`Last activity was at ${lastTrackPlayedAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for max interval ${maxInterval} seconds.`); + } + } else { + sleepTime = interval; + this.logger.debug(`Last activity was at ${lastTrackPlayedAt.format()}. Will sleep for interval ${sleepTime} seconds.`); } - // sleep for interval - this.logger.debug(`Sleeping for ${sleepTime}s`); + this.logger.verbose(`Sleeping for ${sleepTime}s`); await sleep(sleepTime * 1000); } diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index 4fb897fc..b931a7a6 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -21,14 +21,12 @@ export class SubsonicSource extends MemorySource { // default to quick interval so we can get a decently accurate nowPlaying const { data: { - // @ts-ignore interval = 10, - // @ts-ignore - maxSleep = 30, + maxInterval = 30, ...restData } = {} } = config; - const subsonicConfig = {...config, data: {...restData, internal, maxSleep}}; + const subsonicConfig = {...config, data: {...restData, internal, maxInterval}}; super('subsonic', name, subsonicConfig, internal, notifier); const {data: {user, password, url} = {}} = this.config; -- 2.51.2 From 40ae842f82ac07b1848899098826322304f6bf90 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 6 Mar 2023 11:12:11 -0500 Subject: [PATCH 10/41] Add fixed-size-list To be used for storing a fixed length array of scrobbled plays for clients and a discovered tracks for sources --- package-lock.json | 14 ++++++++++++++ package.json | 1 + 2 files changed, 15 insertions(+) diff --git a/package-lock.json b/package-lock.json index bda8f521..cb92bbab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "es6-error": "^4.1.1", "express": "^4.17.1", "express-session": "^1.17.2", + "fixed-size-list": "^0.3.0", "formidable": "^2.1", "gotify": "^1.1.0", "json5": "^2.2.3", @@ -1470,6 +1471,14 @@ "node": ">= 0.8" } }, + "node_modules/fixed-size-list": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/fixed-size-list/-/fixed-size-list-0.3.0.tgz", + "integrity": "sha512-c6I8wEE4ZtjKz35BaodH7yWuWmcaUVQwgBeNcI3LxJu79YH+ezHvf1oS9VkgJmyVy5eQ8Wh6jNVcj2rB4rgVgA==", + "dependencies": { + "mitt": "^1.2.0" + } + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -2245,6 +2254,11 @@ "node": ">=8" } }, + "node_modules/mitt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-1.2.0.tgz", + "integrity": "sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", diff --git a/package.json b/package.json index 4831eebc..8a0ffdb2 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "es6-error": "^4.1.1", "express": "^4.17.1", "express-session": "^1.17.2", + "fixed-size-list": "^0.3.0", "formidable": "^2.1", "gotify": "^1.1.0", "json5": "^2.2.3", -- 2.51.2 From 6bd8642c91fd34147d192b431a53c246a5d5d8c8 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 6 Mar 2023 13:13:25 -0500 Subject: [PATCH 11/41] Initial pass at IOC --- package-lock.json | 20 +++++++++++++ package.json | 1 + src/clients/ScrobbleClients.ts | 13 +++++++- src/index.ts | 35 +++++++++++----------- src/ioc.ts | 31 +++++++++++++++++++ src/notifier/Notifiers.ts | 18 +++++++++-- src/sources/AbstractSource.ts | 55 ++++++++++++++++++++-------------- src/sources/DeezerSource.ts | 6 ++-- src/sources/JellyfinSource.ts | 11 +++---- src/sources/LastfmSource.ts | 6 ++-- src/sources/MPRISSource.ts | 6 ++-- src/sources/PlexSource.ts | 11 +++---- src/sources/ScrobbleSources.ts | 31 ++++++++++--------- src/sources/SpotifySource.ts | 10 +++---- src/sources/SubsonicSource.ts | 6 ++-- src/sources/TautulliSource.ts | 6 ++-- src/sources/YTMusicSource.ts | 10 +++---- 17 files changed, 183 insertions(+), 93 deletions(-) create mode 100644 src/ioc.ts diff --git a/package-lock.json b/package-lock.json index bda8f521..7aaf7e1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "express-session": "^1.17.2", "formidable": "^2.1", "gotify": "^1.1.0", + "iti": "^0.6.0", "json5": "^2.2.3", "lastfm-node-client": "^2.2.0", "ntfy": "^1.0.5", @@ -1971,6 +1972,17 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "optional": true }, + "node_modules/iti": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/iti/-/iti-0.6.0.tgz", + "integrity": "sha512-JqujcnAIF3pmzitjbT3acc0LkordU6oHBDvWeT6a25wvEVBddFX3DFx/p6YBwGX1TTFsyLgVZtwhGOknthC96A==", + "dependencies": { + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/jake": { "version": "10.8.5", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", @@ -3473,6 +3485,14 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/utility-types": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz", + "integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 4831eebc..c062ff34 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "express-session": "^1.17.2", "formidable": "^2.1", "gotify": "^1.1.0", + "iti": "^0.6.0", "json5": "^2.2.3", "lastfm-node-client": "^2.2.0", "ntfy": "^1.0.5", diff --git a/src/clients/ScrobbleClients.ts b/src/clients/ScrobbleClients.ts index 92227e07..0978c045 100644 --- a/src/clients/ScrobbleClients.ts +++ b/src/clients/ScrobbleClients.ts @@ -17,6 +17,7 @@ import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja. import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; import {Notifiers} from "../notifier/Notifiers.js"; import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; +import {EventEmitter} from "events"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -29,9 +30,19 @@ export default class ScrobbleClients { logger; configDir; - constructor(configDir: any) { + emitter: EventEmitter; + + sourceEmitter: EventEmitter; + + constructor(emitter: EventEmitter, sourceEmitter: EventEmitter, configDir: any) { + this.emitter = emitter; + this.sourceEmitter = sourceEmitter; this.configDir = configDir; this.logger = createLabelledLogger('scrobblers', 'Scrobblers'); + + this.sourceEmitter.on('scrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => { + await this.scrobble(payload.data, payload.options); + }); } getByName = (name: any) => { diff --git a/src/index.ts b/src/index.ts index 5c071167..9200b505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,10 +28,8 @@ import JellyfinSource from "./sources/JellyfinSource.js"; import { Server } from "socket.io"; import * as path from "path"; import {projectDir} from "./common/index.js"; -import LastfmApiClient from "./apis/LastfmApiClient.js"; import LastfmSource from "./sources/LastfmSource.js"; import LastfmScrobbler from "./clients/LastfmScrobbler.js"; -import ScrobbleClients from "./clients/ScrobbleClients.js"; import DeezerSource from "./sources/DeezerSource.js"; import AbstractSource from "./sources/AbstractSource.js"; import {PlayObject, TrackStringOptions} from "./common/infrastructure/Atomic.js"; @@ -39,8 +37,8 @@ import SpotifySource from "./sources/SpotifySource.js"; import {JellyfinNotifier} from "./sources/ingressNotifiers/JellyfinNotifier.js"; import {PlexNotifier} from "./sources/ingressNotifiers/PlexNotifier.js"; import {TautulliNotifier} from "./sources/ingressNotifiers/TautulliNotifier.js"; -import {Notifiers} from "./notifier/Notifiers.js"; import {AIOConfig} from "./common/infrastructure/config/aioConfig.js"; +import root from "./ioc.js"; dayjs.extend(utc) @@ -140,24 +138,25 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) logger.warn('App config file exists but could not be parsed!'); } - const notifiers = new Notifiers(); + const notifiers = root.get('notifiers'); const { webhooks = [] } = (config || {}) as AIOConfig; - await notifiers.buildWebhooks(webhooks) + await notifiers.buildWebhooks(webhooks); + /* * setup clients * */ - const scrobbleClients = new ScrobbleClients(configDir); + const scrobbleClients = root.get('clients'); await scrobbleClients.buildClientsFromConfig(notifiers); if (scrobbleClients.clients.length === 0) { logger.warn('No scrobble clients were configured!') } - const scrobbleSources = new ScrobbleSources(localUrl, configDir); - await scrobbleSources.buildSourcesFromConfig([], notifiers); + const scrobbleSources = root.get('sources');//new ScrobbleSources(localUrl, configDir); + await scrobbleSources.buildSourcesFromConfig([]); const clientCheckMiddle = makeClientCheckMiddle(scrobbleClients); const sourceCheckMiddle = makeSourceCheckMiddle(scrobbleSources); @@ -271,7 +270,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) return res.send('OK'); } else { // @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'. - await source.handle(payload, scrobbleClients); + await source.handle(payload); return res.send('OK'); } } else { @@ -283,7 +282,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const tSources = scrobbleSources.getByType('tautulli'); for (const source of tSources) { // @ts-expect-error TS(2339): Property 'handle' does not exist on type 'never'. - await source.handle(payload, scrobbleClients); + await source.handle(payload); } res.send('OK'); @@ -312,7 +311,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) } for (const source of pSources) { - await source.handle(playObj, scrobbleClients); + await source.handle(playObj); } } @@ -354,7 +353,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) logger.debug(`[Jellyfin] Logging payload due to at least one Jellyfin source having 'logPayload: true`, req.body); } for (const source of pSources) { - await source.handle(playObj, scrobbleClients); + await source.handle(playObj); } res.send('OK'); }); @@ -415,7 +414,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) return res.status(400).send(`Specified source cannot poll (${source.type})`); } - source.poll(scrobbleClients); + source.poll(); res.send('OK'); }); @@ -496,7 +495,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) return res.send('Error with deezer credentials storage'); } else if(entity.config.data.accessToken !== undefined) { // start polling - entity.poll(entity.clients) + entity.poll() return res.redirect('/'); } else { await sleep(1500); @@ -537,7 +536,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const tokenResult = await source.handleAuthCodeCallback(req.query); let responseContent = 'OK'; if (tokenResult === true) { - source.poll(scrobbleClients); + source.poll(); } else { responseContent = tokenResult; } @@ -575,18 +574,18 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) if ((source as SpotifySource).spotifyApi.getAccessToken() === undefined) { anyNotReady = true; } else { - (source as SpotifySource).poll(scrobbleClients); + (source as SpotifySource).poll(); } } break; case 'lastfm': if(source.initialized === true) { - source.poll(scrobbleClients); + source.poll(); } break; default: if (source.poll !== undefined) { - source.poll(scrobbleClients); + source.poll(); } } } diff --git a/src/ioc.ts b/src/ioc.ts new file mode 100644 index 00000000..7eeabc7c --- /dev/null +++ b/src/ioc.ts @@ -0,0 +1,31 @@ +import {createContainer} from "iti"; +import path from "path"; +import {projectDir} from "./common/index.js"; +import ScrobbleClients from "./clients/ScrobbleClients.js"; +import ScrobbleSources from "./sources/ScrobbleSources.js"; +import {Notifiers} from "./notifier/Notifiers.js"; +import {EventEmitter} from "events"; +//import ScrobbleClients from "./clients/ScrobbleClients.js"; + +const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); +const port = process.env.PORT ?? 9078; + +let logPath = path.resolve(projectDir, `./logs`); +if(typeof process.env.CONFIG_DIR === 'string') { + logPath = path.resolve(process.env.CONFIG_DIR, './logs'); +} + +const root = createContainer().add({ + configDir: configDir, + logDir: logPath, + localUrl: `http://localhost:${port}`, + clientEmitter: () => new EventEmitter(), + sourceEmitter: () => new EventEmitter(), + notifierEmitter: () => new EventEmitter(), +}).add((items) => ({ + clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir), + sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir), + notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter), +})); + +export default root; diff --git a/src/notifier/Notifiers.ts b/src/notifier/Notifiers.ts index 67d6c001..287725a6 100644 --- a/src/notifier/Notifiers.ts +++ b/src/notifier/Notifiers.ts @@ -1,4 +1,4 @@ -import {config, Logger} from "winston"; +import {config, format, Logger} from "winston"; import {createLabelledLogger} from "../utils.js"; import { GotifyConfig, @@ -9,6 +9,7 @@ import { import {AbstractWebhookNotifier} from "./AbstractWebhookNotifier.js"; import {GotifyWebhookNotifier} from "./GotifyWebhookNotifier.js"; import {NtfyWebhookNotifier} from "./NtfyWebhookNotifier.js"; +import {EventEmitter} from "events"; export class Notifiers { @@ -16,8 +17,21 @@ export class Notifiers { webhooks: AbstractWebhookNotifier[] = []; - constructor() { + emitter: EventEmitter; + + clientEmitter: EventEmitter; + sourceEmitter: EventEmitter; + + constructor(emitter: EventEmitter, clientEmitter: EventEmitter, sourceEmitter: EventEmitter) { + this.emitter = emitter; + this.clientEmitter = clientEmitter; + this.sourceEmitter = sourceEmitter; + this.logger = createLabelledLogger('Notifiers', 'Notifiers'); + + this.sourceEmitter.on('notify', async (payload: WebhookPayload) => { + await this.notify(payload); + }) } buildWebhooks = async (webhookConfigs: WebhookConfig[]) => { diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index 927551a9..98d3a57c 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -3,7 +3,7 @@ import {buildTrackString, capitalize, createLabelledLogger, sleep} from "../util import {InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js"; import {Logger} from "winston"; import {SourceConfig} from "../common/infrastructure/config/source/sources.js"; -import {Notifiers} from "../notifier/Notifiers.js"; +import {EventEmitter} from "events"; export interface RecentlyPlayedOptions { limit?: number @@ -21,7 +21,6 @@ export default abstract class AbstractSource { config: SourceConfig; clients: string[]; logger: Logger; - notifier: Notifiers; instantiatedAt: Dayjs; initialized: boolean = false; requiresAuth: boolean = false; @@ -37,18 +36,20 @@ export default abstract class AbstractSource { pollRetries: number = 0; tracksDiscovered: number = 0; - constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, notifiers: Notifiers) { + emitter: EventEmitter; + + constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) { const {clients = [] } = config; this.type = type; this.name = name; this.identifier = `Source - ${capitalize(this.type)} - ${name}`; this.logger = createLabelledLogger(this.identifier, this.identifier); - this.notifier = notifiers; this.config = config; this.clients = clients; this.instantiatedAt = dayjs(); this.localUrl = internal.localUrl; this.configDir = internal.configDir; + this.emitter = emitter; } // default init function, should be overridden if init stage is required @@ -73,17 +74,31 @@ export default abstract class AbstractSource { return true; } - poll = async (allClients: any) => { - await this.startPolling(allClients); + protected scrobble = (plays: PlayObject[], options: { checkTime?: Dayjs, forceRefresh?: boolean } = {}) => { + this.emitter.emit('scrobble', { + data: plays, options: { + ...options, + scrobbleFrom: this.identifier, + scrobbleTo: this.clients + } + }); + } + + protected notify = (payload) => { + this.emitter.emit('notify', payload); + } + + poll = async () => { + await this.startPolling(); } - startPolling = async (allClients: any) => { + startPolling = async () => { if(this.requiresAuth && !this.authed) { if(this.requiresAuthInteraction) { - await this.notifier.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because user interaction is required for authentication', priority: 'error'}); + this.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because user interaction is required for authentication', priority: 'error'}); this.logger.error('Cannot start polling because user interaction is required for authentication'); } else { - await this.notifier.notify({title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because source does not have authentication.', priority: 'error'}); + this.notify( {title: `${this.identifier} - Polling Error`, message: 'Cannot start polling because source does not have authentication.', priority: 'error'}); this.logger.error('Cannot start polling because source is not authenticated correctly.'); } return; @@ -103,31 +118,28 @@ export default abstract class AbstractSource { while (this.pollRetries <= maxRetries) { try { - await this.doPolling(allClients); + await this.doPolling(); } catch (e) { if (this.pollRetries < maxRetries) { const delayFor = (this.pollRetries + 1) * retryMultiplier; this.logger.info(`Poll retries (${this.pollRetries}) less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay...`); - await this.notifier.notify({title: `${this.identifier} - Polling Retry`, message: `Encountered error while polling but retries (${this.pollRetries}) are less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay. | Error: ${e.message}`, priority: 'warn'}); + this.notify({title: `${this.identifier} - Polling Retry`, message: `Encountered error while polling but retries (${this.pollRetries}) are less than max poll retries (${maxRetries}), restarting polling after ${delayFor} second delay. | Error: ${e.message}`, priority: 'warn'}); await sleep((delayFor) * 1000); } else { this.logger.warn(`Poll retries (${this.pollRetries}) equal to max poll retries (${maxRetries}), stopping polling!`); - await this.notifier.notify({title: `${this.identifier} - Polling Error`, message: `Encountered error while polling and retries (${this.pollRetries}) are equal to max poll retries (${maxRetries}), stopping polling!. | Error: ${e.message}`, priority: 'error'}); + this.notify({title: `${this.identifier} - Polling Error`, message: `Encountered error while polling and retries (${this.pollRetries}) are equal to max poll retries (${maxRetries}), stopping polling!. | Error: ${e.message}`, priority: 'error'}); } this.pollRetries++; } } } - /** - * @param {ScrobbleClients} allClients - */ - doPolling = async (allClients: any) => { + doPolling = async () => { if (this.polling === true) { return; } this.logger.info('Polling started'); - this.notifier.notify({title: `${this.identifier} - Polling Started`, message: 'Polling Started', priority: 'info'}); + this.notify({title: `${this.identifier} - Polling Started`, message: 'Polling Started', priority: 'info'}); let lastTrackPlayedAt = this.instantiatedAt; let checkCount = 0; let checksOverThreshold = 0; @@ -200,12 +212,8 @@ export default abstract class AbstractSource { if(playObjs.length > 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 - scrobbleResult = await allClients.scrobble(playObjs, { - checkTime: lastTrackPlayedAt.add(2, 's'), - forceRefresh: closeToInterval, - scrobbleFrom: this.identifier, - scrobbleTo: this.clients - }); + this.scrobble(playObjs, {checkTime: lastTrackPlayedAt.add(2, 's'), + forceRefresh: closeToInterval}); } if (scrobbleResult.length > 0) { @@ -213,6 +221,7 @@ export default abstract class AbstractSource { this.tracksDiscovered += scrobbleResult.length; } + const {interval = 30, checkActiveFor = 300, maxSleep = 300} = this.config.data; let sleepTime = interval; diff --git a/src/sources/DeezerSource.ts b/src/sources/DeezerSource.ts index 09c2ece0..d569530b 100644 --- a/src/sources/DeezerSource.ts +++ b/src/sources/DeezerSource.ts @@ -5,7 +5,7 @@ import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import dayjs from "dayjs"; import {DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; -import {Notifiers} from "../notifier/Notifiers.js"; +import EventEmitter from "events"; export default class DeezerSource extends AbstractSource { workingCredsPath; @@ -18,8 +18,8 @@ export default class DeezerSource extends AbstractSource { declare config: DeezerSourceConfig; - constructor(name: any, config: DeezerSourceConfig, internal: InternalConfig, notifier: Notifiers) { - super('deezer', name, config, internal, notifier); + constructor(name: any, config: DeezerSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('deezer', name, config, internal, emitter); const { data: { interval = 60, diff --git a/src/sources/JellyfinSource.ts b/src/sources/JellyfinSource.ts index ef368230..d26ec18a 100644 --- a/src/sources/JellyfinSource.ts +++ b/src/sources/JellyfinSource.ts @@ -3,7 +3,7 @@ import dayjs from "dayjs"; import {buildTrackString, combinePartsToString, parseDurationFromTimestamp, truncateStringToLength} from "../utils.js"; import {JellySourceConfig} from "../common/infrastructure/config/source/jellyfin.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; -import {Notifiers} from "../notifier/Notifiers.js"; +import EventEmitter from "events"; const shortDeviceId = truncateStringToLength(10, ''); @@ -13,8 +13,8 @@ export default class JellyfinSource extends MemorySource { declare config: JellySourceConfig; - constructor(name: any, config: JellySourceConfig, internal: InternalConfig, notifier: Notifiers) { - super('jellyfin', name, config, internal, notifier); + constructor(name: any, config: JellySourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('jellyfin', name, config, internal, emitter); const {data: {users, servers} = {}} = config; if (users === undefined || users === null) { @@ -157,7 +157,7 @@ export default class JellyfinSource extends MemorySource { return this.getFlatStatefulRecentlyPlayed(); } - handle = async (playObj: any, allClients: any) => { + handle = async (playObj: any) => { if (!this.isValidEvent(playObj)) { return; } @@ -172,7 +172,8 @@ export default class JellyfinSource extends MemorySource { 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}); + this.scrobble(newPlays, {checkTime: newestPlay.data.playDate}); + //await root.get('clients').scrobble(newPlays, {scrobbleTo: this.clients, scrobbleFrom: this.identifier, checkTime: newestPlay.data.playDate}); // only gets hit if we scrobbled ok this.tracksDiscovered++; } catch (e) { diff --git a/src/sources/LastfmSource.ts b/src/sources/LastfmSource.ts index 1fe5c5de..1ba58d78 100644 --- a/src/sources/LastfmSource.ts +++ b/src/sources/LastfmSource.ts @@ -4,7 +4,7 @@ import {sortByPlayDate} from "../utils.js"; import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {UserGetRecentTracksResponse} from "lastfm-node-client"; -import {Notifiers} from "../notifier/Notifiers.js"; +import EventEmitter from "events"; export default class LastfmSource extends AbstractSource { @@ -14,8 +14,8 @@ export default class LastfmSource extends AbstractSource { declare config: LastfmClientConfig; - constructor(name: any, config: LastfmClientConfig, internal: InternalConfig, notifier: Notifiers) { - super('lastfm', name, config, internal, notifier); + constructor(name: any, config: LastfmClientConfig, internal: InternalConfig, emitter: EventEmitter) { + super('lastfm', name, config, internal, emitter); this.canPoll = true; this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir}); } diff --git a/src/sources/MPRISSource.ts b/src/sources/MPRISSource.ts index bb35a18a..de3eb715 100644 --- a/src/sources/MPRISSource.ts +++ b/src/sources/MPRISSource.ts @@ -9,9 +9,9 @@ import { } from "../common/infrastructure/config/source/mpris.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import MemorySource from "./MemorySource.js"; -import {Notifiers} from "../notifier/Notifiers.js"; import {RecentlyPlayedOptions} from "./AbstractSource.js"; import {removeDuplicates} from "../utils.js"; +import EventEmitter from "events"; export class MPRISSource extends MemorySource { @@ -21,8 +21,8 @@ export class MPRISSource extends MemorySource { whitelist: string[] = []; blacklist: string[] = []; - constructor(name: any, config: MPRISSourceConfig, internal: InternalConfig, notifier: Notifiers) { - super('mpris', name, config, internal, notifier); + constructor(name: any, config: MPRISSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('mpris', name, config, internal, emitter); this.canPoll = true; const {data: {whitelist = [], blacklist = []} = {}} = config; diff --git a/src/sources/PlexSource.ts b/src/sources/PlexSource.ts index 36c2f3f0..dd3b2efb 100644 --- a/src/sources/PlexSource.ts +++ b/src/sources/PlexSource.ts @@ -5,7 +5,7 @@ import formidable from 'formidable'; import concatStream from 'concat-stream'; import {PlexSourceConfig} from "../common/infrastructure/config/source/plex.js"; import {InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js"; -import {Notifiers} from "../notifier/Notifiers.js"; +import EventEmitter from "events"; const shortDeviceId = truncateStringToLength(10, ''); @@ -16,8 +16,8 @@ export default class PlexSource extends AbstractSource { declare config: PlexSourceConfig; - constructor(name: any, config: PlexSourceConfig, internal: InternalConfig, type: SourceType = 'plex', notifier: Notifiers) { - super(type, name, config, internal, notifier); + constructor(name: any, config: PlexSourceConfig, internal: InternalConfig, type: SourceType = 'plex',emitter: EventEmitter) { + super(type, name, config, internal, emitter); const {data: {user = [], libraries = [], servers = []} = {}} = config if (!Array.isArray(user)) { @@ -165,14 +165,15 @@ export default class PlexSource extends AbstractSource { return true; } - handle = async (playObj: any, allClients: any) => { + handle = async (playObj: any) => { if (!this.isValidEvent(playObj)) { return; } this.logger.info(`New Track => ${buildTrackString(playObj)}`); try { - await allClients.scrobble(playObj, {scrobbleTo: this.clients, scrobbleFrom: this.identifier}); + this.scrobble([playObj]); + //await root.get('clients').scrobble(playObj, {scrobbleTo: this.clients, scrobbleFrom: this.identifier}); // only gets hit if we scrobbled ok this.tracksDiscovered++; } catch (e) { diff --git a/src/sources/ScrobbleSources.ts b/src/sources/ScrobbleSources.ts index 9cbea0fc..f1904587 100644 --- a/src/sources/ScrobbleSources.ts +++ b/src/sources/ScrobbleSources.ts @@ -24,9 +24,9 @@ import * as sourceSchema from "../common/schema/source.json"; import {LastfmSourceConfig} from "../common/infrastructure/config/source/lastfm.js"; import YTMusicSource from "./YTMusicSource.js"; import {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.js"; -import {Notifiers} from "../notifier/Notifiers.js"; import {MPRISData, MPRISSourceConfig} from "../common/infrastructure/config/source/mpris.js"; import {MPRISSource} from "./MPRISSource.js"; +import EventEmitter from "events"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -39,7 +39,10 @@ export default class ScrobbleSources { configDir: string; localUrl: string; - constructor(localUrl: string, configDir: string = defaultConfigDir) { + emitter: EventEmitter; + + constructor(emitter: EventEmitter, localUrl: string, configDir: string = defaultConfigDir) { + this.emitter = emitter; this.configDir = configDir; this.localUrl = localUrl; this.logger = createLabelledLogger('sources', 'Sources'); @@ -84,7 +87,7 @@ export default class ScrobbleSources { return [sourcesReady, messages]; } - buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = [], notifier: Notifiers) => { + buildSourcesFromConfig = async (additionalConfigs: ParsedConfig[] = []) => { let configs: ParsedConfig[] = additionalConfigs; let configFile; @@ -336,7 +339,7 @@ export default class ScrobbleSources { // @ts-expect-error TS(2571): Object is of type 'unknown'. for (const c of tempNamedConfigs) { try { - await this.addSource(c, sourceDefaults, notifier); + await this.addSource(c, sourceDefaults); } catch(e) { this.logger.error(`Source ${c.name} of type ${c.type} was not added because of unrecoverable errors`); this.logger.error(e); @@ -346,7 +349,7 @@ export default class ScrobbleSources { } } - addSource = async (clientConfig: any, defaults = {}, notifier: Notifiers) => { + addSource = async (clientConfig: any, 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(' | ')}`) @@ -367,31 +370,31 @@ export default class ScrobbleSources { let newSource; switch (type) { case 'spotify': - newSource = new SpotifySource(name, compositeConfig as SpotifySourceConfig, internal, notifier); + newSource = new SpotifySource(name, compositeConfig as SpotifySourceConfig, internal, this.emitter); break; case 'plex': - newSource = await new PlexSource(name, compositeConfig as PlexSourceConfig, internal, 'plex', notifier); + newSource = await new PlexSource(name, compositeConfig as PlexSourceConfig, internal, 'plex', this.emitter); break; case 'tautulli': - newSource = await new TautulliSource(name, compositeConfig as TautulliSourceConfig, internal, notifier); + newSource = await new TautulliSource(name, compositeConfig as TautulliSourceConfig, internal, this.emitter); break; case 'subsonic': - newSource = new SubsonicSource(name, compositeConfig as SubSonicSourceConfig, internal, notifier); + newSource = new SubsonicSource(name, compositeConfig as SubSonicSourceConfig, internal, this.emitter); break; case 'jellyfin': - newSource = await new JellyfinSource(name, compositeConfig as JellySourceConfig, internal, notifier); + newSource = await new JellyfinSource(name, compositeConfig as JellySourceConfig, internal, this.emitter); break; case 'lastfm': - newSource = await new LastfmSource(name, compositeConfig as LastfmClientConfig, internal, notifier); + newSource = await new LastfmSource(name, compositeConfig as LastfmClientConfig, internal, this.emitter); break; case 'deezer': - newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, internal, notifier); + newSource = await new DeezerSource(name, compositeConfig as DeezerSourceConfig, internal, this.emitter); break; case 'ytmusic': - newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, internal, notifier); + newSource = await new YTMusicSource(name, compositeConfig as YTMusicSourceConfig, internal, this.emitter); break; case 'mpris': - newSource = await new MPRISSource(name, compositeConfig as MPRISSourceConfig, internal, notifier); + newSource = await new MPRISSource(name, compositeConfig as MPRISSourceConfig, internal, this.emitter); break; default: break; diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index baa115cf..fb6379ed 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -9,7 +9,7 @@ import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import {SpotifySourceConfig} from "../common/infrastructure/config/source/spotify.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import PlayHistoryObject = SpotifyApi.PlayHistoryObject; -import {Notifiers} from "../notifier/Notifiers.js"; +import EventEmitter from "events"; const scopes = ['user-read-recently-played', 'user-read-currently-playing']; const state = 'random'; @@ -24,8 +24,8 @@ export default class SpotifySource extends AbstractSource { declare config: SpotifySourceConfig; - constructor(name: any, config: SpotifySourceConfig, internal: InternalConfig, notifier: Notifiers) { - super('spotify', name, config, internal, notifier); + constructor(name: any, config: SpotifySourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('spotify', name, config, internal, emitter); const { data: { interval = 60, @@ -232,11 +232,11 @@ export default class SpotifySource extends AbstractSource { } } - poll = async (allClients: any) => { + poll = async () => { if (this.spotifyApi === undefined) { this.logger.warn('Cannot poll spotify without valid credentials configuration') return; } - await this.startPolling(allClients); + await this.startPolling(); } } diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index 4fb897fc..06b41a97 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -7,7 +7,7 @@ import MemorySource from "./MemorySource.js"; import {SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {RecentlyPlayedOptions} from "./AbstractSource.js"; -import {Notifiers} from "../notifier/Notifiers.js"; +import EventEmitter from "events"; dayjs.extend(isSameOrAfter); @@ -17,7 +17,7 @@ export class SubsonicSource extends MemorySource { declare config: SubSonicSourceConfig; - constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, notifier: Notifiers) { + constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { // default to quick interval so we can get a decently accurate nowPlaying const { data: { @@ -29,7 +29,7 @@ export class SubsonicSource extends MemorySource { } = {} } = config; const subsonicConfig = {...config, data: {...restData, internal, maxSleep}}; - super('subsonic', name, subsonicConfig, internal, notifier); + super('subsonic', name, subsonicConfig, internal,emitter); const {data: {user, password, url} = {}} = this.config; diff --git a/src/sources/TautulliSource.ts b/src/sources/TautulliSource.ts index 634fd2b8..a4498e22 100644 --- a/src/sources/TautulliSource.ts +++ b/src/sources/TautulliSource.ts @@ -2,16 +2,16 @@ import dayjs from "dayjs"; import PlexSource from "./PlexSource.js"; import {TautulliSourceConfig} from "../common/infrastructure/config/source/tautulli.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; -import {Notifiers} from "../notifier/Notifiers.js"; import {combinePartsToString, truncateStringToLength} from "../utils.js"; +import EventEmitter from "events"; const shortDeviceId = truncateStringToLength(10, ''); export default class TautulliSource extends PlexSource { declare config: TautulliSourceConfig; - constructor(name: any, config: TautulliSourceConfig, internal: InternalConfig, notifier: Notifiers) { - super(name, config, internal, 'tautulli', notifier); + constructor(name: any, config: TautulliSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super(name, config, internal, 'tautulli', emitter); } static formatPlayObj(obj: any, newFromSource = false): PlayObject { diff --git a/src/sources/YTMusicSource.ts b/src/sources/YTMusicSource.ts index 28bcf4d3..fc0caa77 100644 --- a/src/sources/YTMusicSource.ts +++ b/src/sources/YTMusicSource.ts @@ -7,7 +7,7 @@ import dayjs from "dayjs"; import {parseDurationFromTimestamp, playObjDataMatch} from "../utils.js"; import {IPlaylistDetail, ITrackDetail} from "youtube-music-ts-api/interfaces-supplementary"; import {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusic.js"; -import {Notifiers} from "../notifier/Notifiers.js"; +import EventEmitter from "events"; export default class YTMusicSource extends AbstractSource { apiInstance?: IYouTubeMusicAuthenticated @@ -18,8 +18,8 @@ export default class YTMusicSource extends AbstractSource { recentlyPlayed: PlayObject[] = []; - constructor(name: string, config: YTMusicSourceConfig, internal: InternalConfig, notifier: Notifiers) { - super('ytmusic', name, config, internal, notifier); + constructor(name: string, config: YTMusicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('ytmusic', name, config, internal, emitter); this.canPoll = true; } @@ -181,11 +181,11 @@ export default class YTMusicSource extends AbstractSource { return this.authed; } - poll = async (allClients: any) => { + poll = async () => { if(this.authed) { this.logger.verbose('Hydrating initial recently played tracks for reference.'); await this.getRecentlyPlayed(); } - await this.startPolling(allClients); + await this.startPolling(); } } -- 2.51.2 From 3d2e3534ffca7893072d7b7f6dde1f9aa10a2f1b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 10:34:20 -0500 Subject: [PATCH 12/41] Refactor: Move play date comparison into util --- src/utils.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index ee05bc11..e8549fe7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -136,7 +136,9 @@ export const buildTrackString = (playObj: PlayObject, options: TrackStringOption } // sorts playObj formatted objects by playDate in ascending (oldest first) order -export const sortByPlayDate = (a: any, b: any) => a.data.playDate.isAfter(b.data.playDate) ? 1 : -1; +export const sortByOldestPlayDate = (a: any, b: any) => a.data.playDate.isAfter(b.data.playDate) ? 1 : -1; + +export const sortByNewestPlayDate = (a: any, b: any) => a.data.playDate.isAfter(b.data.playDate) ? 1 : -1; const s = splat(); const SPLAT = Symbol.for('splat') @@ -473,7 +475,7 @@ export const remoteHostStr = (req: Request): string => { return `${host}${proxy !== undefined ? ` (${proxy})` : ''}${agent !== undefined ? ` (UA: ${agent})` : ''}`; } -export const closePlayDate = (existingPlay: PlayObject, newPlay: PlayObject, diffThreshold?: number): boolean => { +export const closePlayDate = (existingPlay: PlayObject, newPlay: PlayObject, options: { diffThreshold?: number, fuzzyDuration?: boolean} = {}): boolean => { const { meta:{ @@ -492,26 +494,30 @@ export const closePlayDate = (existingPlay: PlayObject, newPlay: PlayObject, dif } } = newPlay; + const { + diffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10, + fuzzyDuration = false, + } = options; + // cant compare! if(existingPlayDate === undefined || newPlayDate === undefined) { return false; } + const referenceDuration = newDuration ?? existingDuration; + let playDiffThreshold = diffThreshold; - if(playDiffThreshold === undefined) { - playDiffThreshold = lowGranularitySources.some(x => x.toLocaleLowerCase() === source) ? 60 : 10; - } + let closeTime = false; - // check if existing play time is same as new play date (when the track finished playing AKA entered recent tracks) + // check if existing play time is same as new play date let scrobblePlayDiff = Math.abs(existingPlayDate.unix() - newPlayDate.unix()); - let scrobblePlayStartDiff; if (scrobblePlayDiff <= playDiffThreshold) { closeTime = true; } - // also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations - if (closeTime === false && newDuration !== undefined) { - scrobblePlayStartDiff = Math.abs(existingPlayDate.unix() - (newPlayDate.unix() - newDuration)); - if (scrobblePlayStartDiff <= playDiffThreshold) { + // if the source has a duration its possible one play was scrobbled at the beginning of the track and the other at the end + // so check if the duration matches the diff between the two play dates + if (closeTime === false && referenceDuration !== undefined && fuzzyDuration) { + if(Math.abs(scrobblePlayDiff - newDuration) < 10) { // use finer comparison for this closeTime = true; } } @@ -588,3 +594,5 @@ export function parseBool(value: any, prev: any = false): boolean { } throw new Error('Not a boolean value.'); } + +export const genGroupId = (play: PlayObject) => `${play.meta.deviceId ?? 'NoDevice'}-${play.meta.user ?? 'SingleUser'}`; -- 2.51.2 From 605aad1df339f7551357005e037e8973e75aef46 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 10:36:05 -0500 Subject: [PATCH 13/41] Refactor: Consolidate scrobble client existing play check into abstract client * Move main logic into class methods that can be overridden * Move title, artist, and date checks into class methods that can be overridden --- src/clients/AbstractScrobbleClient.ts | 168 ++++++++++++++++++++++++-- src/clients/LastfmScrobbler.ts | 142 +--------------------- src/clients/MalojaScrobbler.ts | 146 +--------------------- src/clients/ScrobbleClients.ts | 11 +- 4 files changed, 180 insertions(+), 287 deletions(-) diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index da73c1d3..b127ec2d 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -1,12 +1,18 @@ import dayjs, {Dayjs} from "dayjs"; -import {buildTrackString, capitalize, createLabelledLogger, playObjDataMatch} from "../utils.js"; +import { + buildTrackString, + capitalize, closePlayDate, + createLabelledLogger, + playObjDataMatch, setIntersection, + truncateStringToLength +} from "../utils.js"; import { ClientType, INITIALIZED, INITIALIZING, InitState, NOT_INITIALIZED, - PlayObject, ScrobbledPlayObject + PlayObject, ScrobbledPlayObject, TrackStringOptions } from "../common/infrastructure/Atomic.js"; import {Logger} from "winston"; import {CommonClientConfig} from "../common/infrastructure/config/client/index.js"; @@ -30,7 +36,7 @@ export default abstract class AbstractScrobbleClient { oldestScrobbleTime: Dayjs = dayjs(); tracksScrobbled: number = 0; - lastScrobbleCheck: Dayjs = dayjs(); + lastScrobbleCheck: Dayjs = dayjs(0) refreshEnabled: boolean; checkExistingScrobbles: boolean; verboseOptions; @@ -136,7 +142,7 @@ export default abstract class AbstractScrobbleClient { // time frame is valid as long as the play date for the source track is newer than the oldest play time from the scrobble client // ...this is assuming the scrobble client is returning "most recent" scrobbles - timeFrameIsValid = (playObj: any, log = false) => { + timeFrameIsValid = (playObj: any) => { const { data: { // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message @@ -144,10 +150,12 @@ export default abstract class AbstractScrobbleClient { } = {}, } = playObj; const validTime = playDate.isAfter(this.oldestScrobbleTime); - if (log && !validTime) { - this.logger.debug(`${buildTrackString(playObj)} was in an invalid time frame (played before the oldest scrobble found)`); + let log = ''; + if (!validTime) { + const dur = dayjs.duration(Math.abs(playDate.diff(this.oldestScrobbleTime))).humanize(false); + log = `occurred ${dur} before the oldest scrobble returned by this client (${this.oldestScrobbleTime.format()})`; } - return validTime; + return [validTime, log] } addScrobbledTrack = (playObj: any, scrobbleResp: any) => { @@ -161,7 +169,7 @@ export default abstract class AbstractScrobbleClient { } = {}, } = playObj; - return track; + return track.toLocaleLowerCase().trim(); }; findExistingSubmittedPlayObj = (playObj: PlayObject): ([undefined, undefined] | [ScrobbledPlayObject, ScrobbledPlayObject[]]) => { @@ -200,4 +208,148 @@ export default abstract class AbstractScrobbleClient { return [matchPlayDate, dtInvariantMatches]; } + + protected compareExistingScrobbleTime = (existing: PlayObject, candidate: PlayObject): [boolean, boolean?] => { + let closeTime = closePlayDate(existing, candidate); + let fuzzyTime = false; + if(!closeTime) { + fuzzyTime = closePlayDate(existing, candidate, {fuzzyDuration: true}); + } + return [closeTime, fuzzyTime]; + } + protected compareExistingScrobbleTitle = (existing: PlayObject, candidate: PlayObject): number => { + + const { + data: { + track: scrobbleTitle, + } = {}, + } = existing; + + let cleanSourceTitle = this.cleanSourceSearchTitle(candidate); + + let titleMatch; + const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim(); + // because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces + // and compare against length of scrobble title + const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== '')); + const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms); + + titleMatch = commonTerms.size / sourceTitleTerms.size; + return titleMatch; + } + + protected compareExistingScrobbleArtist = (existing: PlayObject, candidate: PlayObject): number => { + const { + data: { + artists: sourceArtists = [], + } = {}, + } = candidate; + const { + data: { + artists = [], + } = {}, + } = candidate; + let artistMatch; + const lowerSourceArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase()); + const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase()); + artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length; + + return artistMatch; + } + + existingScrobble = async (playObj: PlayObject) => { + const tr = truncateStringToLength(27); + const scoreTrackOpts: TrackStringOptions = {include: ['track', 'time'], transformers: {track: (t: any) => tr(t).padEnd(30)}}; + + // return early if we don't care about checking existing + if (false === this.checkExistingScrobbles) { + if (this.verboseOptions.match.onNoMatch) { + this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`); + } + return undefined; + } + + let existingScrobble; + let closestMatch: {score: number, breakdowns: string[], scrobble?: PlayObject} = {score: 0, breakdowns: ['None']}; + + // then check if we have already recorded this + const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj); + + // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble + if (existingExactSubmitted !== undefined) { + existingScrobble = existingExactSubmitted.scrobble; + + closestMatch = { + score: 1, + breakdowns: ['Exact Match found in previously successfully scrobbled'] + } + } + // if not though then we need to check recent scrobbles from scrobble api. + // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start + + // if no recent scrobbles found then assume we haven't submitted it + // (either user doesnt want to check history or there is no history to check!) + if (this.recentScrobbles.length === 0) { + if (this.verboseOptions.match.onNoMatch) { + this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`); + } + return undefined; + } + + if (existingScrobble === undefined) { + + // we have have found an existing submission but without an exact date + // in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison + const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined; + + // clean source title so it matches title from the scrobble api response as closely as we can get it + let cleanSourceTitle = this.cleanSourceSearchTitle(playObj); + + existingScrobble = this.recentScrobbles.find((x) => { + + const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse); + + + const [closeTime, fuzzyTime = false] = this.compareExistingScrobbleTime(x, playObj); + + const titleMatch = this.compareExistingScrobbleTitle(x, playObj); + + const artistMatch = this.compareExistingScrobbleArtist(x, playObj); + + const artistScore = .2 * artistMatch; + const titleScore = .3 * titleMatch; + const timeScore = .5 * (closeTime ? 1 : (fuzzyTime ? 0.5 : 0)); + const referenceScore = .5 * (referenceMatch ? 1 : 0); + const score = artistScore + titleScore + timeScore; + + let scoreBreakdowns = [ + `Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`, + `Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`, + `Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`, + `Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`, + `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}` + ]; + + const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}` + + const scoreInfo = { + score, + scrobble: x, + breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence] + } + + if (closestMatch.score <= score && score > 0) { + closestMatch = scoreInfo + } + + return score >= .7; + }); + } + + if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) { + const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`; + this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`); + } + return existingScrobble; + } } diff --git a/src/clients/LastfmScrobbler.ts b/src/clients/LastfmScrobbler.ts index 742d67e6..f66c04c3 100644 --- a/src/clients/LastfmScrobbler.ts +++ b/src/clients/LastfmScrobbler.ts @@ -5,7 +5,7 @@ import { buildTrackString, capitalize, playObjDataMatch, removeUndefinedKeys, setIntersection, sleep, - sortByPlayDate, + sortByOldestPlayDate, truncateStringToLength, } from "../utils.js"; import LastfmApiClient from "../apis/LastfmApiClient.js"; @@ -87,14 +87,14 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { this.logger.debug(x); return acc; } - }, []).sort(sortByPlayDate); + }, []).sort(sortByOldestPlayDate); if (this.recentScrobbles.length > 0) { const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1); const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1); this.newestScrobbleTime = newestScrobbleTime; this.oldestScrobbleTime = oldestScrobbleTime; - this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)); + this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)[0]); } } this.lastScrobbleCheck = dayjs(); @@ -110,141 +110,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { } alreadyScrobbled = async (playObj: PlayObject, log = false) => { - return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined; - } - - existingScrobble = (playObj: PlayObject, logMatch = false) => { - const tr = truncateStringToLength(27); - const scoreTrackOpts: TrackStringOptions = {include: ['track', 'time'], transformers: {track: (t: any) => tr(t).padEnd(30)}}; - - // return early if we don't care about checking existing - if (false === this.checkExistingScrobbles) { - if (this.verboseOptions.match.onNoMatch) { - this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`); - } - return undefined; - } - - let existingScrobble; - let closestMatch: {score: number, breakdowns: string[], scrobble?: PlayObject} = {score: 0, breakdowns: ['None']}; - - // then check if we have already recorded this - const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj); - - // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble - if (existingExactSubmitted !== undefined) { - existingScrobble = existingExactSubmitted.scrobble; - - closestMatch = { - score: 1, - breakdowns: ['Exact Match found in previously successfully scrobbled'] - } - } - // if not though then we need to check recent scrobbles from scrobble api. - // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start - - // if no recent scrobbles found then assume we haven't submitted it - // (either user doesnt want to check history or there is no history to check!) - if (this.recentScrobbles.length === 0) { - if (this.verboseOptions.match.onNoMatch) { - this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`); - } - return undefined; - } - - if (existingScrobble === undefined) { - - // we have have found an existing submission but without an exact date - // in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison - const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined; - - const { - data: { - artists: sourceArtists = [], - playDate - } = {}, - meta: { - trackLength, - source, - } = {}, - } = playObj; - - // clean source title so it matches title from the scrobble api response as closely as we can get it - let cleanSourceTitle = this.cleanSourceSearchTitle(playObj); - - existingScrobble = this.recentScrobbles.find((x) => { - - const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse); - - const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x; - - const playDiffThreshold = source === 'Subsonic' ? 60 : 10; - let closeTime = false; - // check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks) - let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix()); - let scrobblePlayStartDiff; - if (scrobblePlayDiff <= playDiffThreshold) { - //this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`); - closeTime = true; - } - // also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations - if (closeTime === false && trackLength !== undefined) { - scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength)); - if (scrobblePlayStartDiff <= playDiffThreshold) { - //this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`); - closeTime = true; - } - } - - let titleMatch; - const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim(); - // because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces - // and compare against length of scrobble title - const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== '')); - const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms); - - titleMatch = commonTerms.size / sourceTitleTerms.size; - - let artistMatch; - const lowerSourceArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase()); - const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase()); - artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length; - - const artistScore = .2 * artistMatch; - const titleScore = .3 * titleMatch; - const timeScore = .5 * (closeTime ? 1 : 0); - const referenceScore = .5 * (referenceMatch ? 1 : 0); - const score = artistScore + titleScore + timeScore; - - let scoreBreakdowns = [ - `Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`, - `Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`, - `Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`, - `Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`, - `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}` - ]; - - const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}` - - const scoreInfo = { - score, - scrobble: x, - breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence] - } - - if (closestMatch.score <= score && score > 0) { - closestMatch = scoreInfo - } - - return score >= .7; - }); - } - - if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) { - const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`; - this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`); - } - return existingScrobble; + return await this.existingScrobble(playObj) !== undefined; } scrobble = async (playObj: PlayObject) => { diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index 8e4bf5fa..7c98e442 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -7,9 +7,9 @@ import { playObjDataMatch, setIntersection, sleep, - sortByPlayDate, + sortByOldestPlayDate, truncateStringToLength, - parseRetryAfterSecsFromObj, capitalize + parseRetryAfterSecsFromObj, capitalize, closePlayDate } from "../utils.js"; import { INITIALIZING, @@ -74,7 +74,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { artists = mArtists; time = mTime; title = mTitle; - duration = mDuration; + duration = mLength; album = mAlbum; } else { // scrobble data structure for v2 and below @@ -311,14 +311,14 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { list = [], } = {}, } = resp; - this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByPlayDate); + this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByOldestPlayDate); if (this.recentScrobbles.length > 0) { const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1); const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1); this.newestScrobbleTime = newestScrobbleTime; this.oldestScrobbleTime = oldestScrobbleTime; - this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)); + this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)[0]); } } this.lastScrobbleCheck = dayjs(); @@ -348,141 +348,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { } alreadyScrobbled = async (playObj: any, log = false) => { - return this.existingScrobble(playObj, (log || this.verboseOptions.match.onMatch)) !== undefined; - } - - existingScrobble = (playObj: PlayObject, logMatch = false) => { - const tr = truncateStringToLength(27); - const scoreTrackOpts: TrackStringOptions = {include: ['track', 'time'], transformers: {track: (t: any) => tr(t).padEnd(30)}}; - - // return early if we don't care about checking existing - if (false === this.checkExistingScrobbles) { - if (this.verboseOptions.match.onNoMatch) { - this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`); - } - return undefined; - } - - let existingScrobble; - let closestMatch: {score: number, breakdowns: string[], scrobble?: PlayObject} = {score: 0, breakdowns: ['None']}; - - // then check if we have already recorded this - const [existingExactSubmitted, existingDataSubmitted = []] = this.findExistingSubmittedPlayObj(playObj); - - // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble - if (existingExactSubmitted !== undefined) { - existingScrobble = existingExactSubmitted.scrobble; - - closestMatch = { - score: 1, - breakdowns: ['Exact Match found in previously successfully scrobbled'] - } - } - // if not though then we need to check recent scrobbles from scrobble api. - // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start - - // if no recent scrobbles found then assume we haven't submitted it - // (either user doesnt want to check history or there is no history to check!) - if (this.recentScrobbles.length === 0) { - if (this.verboseOptions.match.onNoMatch) { - this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`); - } - return undefined; - } - - if (existingScrobble === undefined) { - - // we have have found an existing submission but without an exact date - // in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison - const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined; - - const { - data: { - artists: sourceArtists = [], - playDate - } = {}, - meta: { - trackLength, - source, - } = {}, - } = playObj; - - // clean source title so it matches title from the scrobble api response as closely as we can get it - let cleanSourceTitle = this.cleanSourceSearchTitle(playObj); - - existingScrobble = this.recentScrobbles.find((x) => { - - const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse); - - const {data: {playDate: scrobbleTime, track: scrobbleTitle, artists = []} = {}} = x; - - const playDiffThreshold = source === 'Subsonic' ? 60 : 10; - let closeTime = false; - // check if scrobble time is same as play date (when the track finished playing AKA entered recent tracks) - let scrobblePlayDiff = Math.abs(playDate.unix() - scrobbleTime.unix()); - let scrobblePlayStartDiff; - if (scrobblePlayDiff <= playDiffThreshold) { - //this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (finish time) vs. scrobble time diff was smaller than 10 seconds`); - closeTime = true; - } - // also need to check that scrobble time isn't the BEGINNING of the track -- if the source supports durations - if (closeTime === false && trackLength !== undefined) { - scrobblePlayStartDiff = Math.abs(playDate.unix() - (scrobbleTime.unix() - trackLength)); - if (scrobblePlayStartDiff <= playDiffThreshold) { - //this.logger.debug(`Scrobble with same name (${scrobbleTitle}) found and the play (start time) vs. scrobble time diff was smaller than 10 seconds`); - closeTime = true; - } - } - - let titleMatch; - const lowerScrobbleTitle = scrobbleTitle.toLocaleLowerCase().trim(); - // because of all this replacing we need a more position-agnostic way of comparing titles so use intersection on title split by spaces - // and compare against length of scrobble title - const sourceTitleTerms = new Set(cleanSourceTitle.split(' ').filter((x: any) => x !== '')); - const commonTerms = setIntersection(new Set(lowerScrobbleTitle.split(' ')), sourceTitleTerms); - - titleMatch = commonTerms.size / sourceTitleTerms.size; - - let artistMatch; - const lowerSourceArtists = sourceArtists.map((x: any) => x.toLocaleLowerCase()); - const lowerScrobbleArtists = artists.map(x => x.toLocaleLowerCase()); - artistMatch = setIntersection(new Set(lowerScrobbleArtists), new Set(lowerSourceArtists)).size / artists.length; - - const artistScore = .2 * artistMatch; - const titleScore = .3 * titleMatch; - const timeScore = .5 * (closeTime ? 1 : 0); - const referenceScore = .5 * (referenceMatch ? 1 : 0); - const score = artistScore + titleScore + timeScore; - - let scoreBreakdowns = [ - `Reference: ${(referenceMatch ? 1 : 0)} * .5 = ${referenceScore.toFixed(2)}`, - `Artist ${artistMatch.toFixed(2)} * .2 = ${artistScore.toFixed(2)}`, - `Title: ${titleMatch.toFixed(2)} * .3 = ${titleScore.toFixed(2)}`, - `Time: ${closeTime ? 1 : 0} * .5 = ${timeScore.toFixed(2)}`, - `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}` - ]; - - const confidence = `Score ${score.toFixed(2)} => ${score >= .7 ? 'Matched!' : 'No Match'}` - - const scoreInfo = { - score, - scrobble: x, - breakdowns: this.verboseOptions.match.confidenceBreakdown ? scoreBreakdowns : [confidence] - } - - if (closestMatch.score <= score && score > 0) { - closestMatch = scoreInfo - } - - return score >= .7; - }); - } - - if ((existingScrobble !== undefined && this.verboseOptions.match.onMatch) || (existingScrobble === undefined && this.verboseOptions.match.onNoMatch)) { - const closestScrobble = closestMatch.scrobble === undefined ? closestMatch.breakdowns.join(' | ') : `Closest Scrobble: ${buildTrackString(closestMatch.scrobble, scoreTrackOpts)} => ${closestMatch.breakdowns.join(' | ')}`; - this.logger.debug(`(Existing Check) Source: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobble}`); - } - return existingScrobble; + return await this.existingScrobble(playObj) !== undefined; } scrobble = async (playObj: PlayObject) => { diff --git a/src/clients/ScrobbleClients.ts b/src/clients/ScrobbleClients.ts index 0978c045..49d0059c 100644 --- a/src/clients/ScrobbleClients.ts +++ b/src/clients/ScrobbleClients.ts @@ -1,5 +1,6 @@ import dayjs, {Dayjs} from "dayjs"; import { + buildTrackString, createAjvFactory, createLabelledLogger, playObjDataMatch, @@ -379,7 +380,11 @@ ${sources.join('\n')}`); newFromSource = false, } = {} } = playObj; - if (client.timeFrameIsValid(playObj, newFromSource) && !(await client.alreadyScrobbled(playObj, newFromSource))) { + const [timeFrameValid, timeFrameValidLog] = client.timeFrameIsValid(playObj); + if(playObj.data.track !== 'Por Supuesto') { + continue; + } + if (timeFrameValid && !(await client.alreadyScrobbled(playObj))) { await client.scrobble(playObj) client.tracksScrobbled++; // since this is what we return to the source only add to tracksScrobbled if not already in array @@ -387,6 +392,10 @@ ${sources.join('\n')}`); if(!tracksScrobbled.some(x => playObjDataMatch(x, playObj) && x.data.playDate === playObj.data.playDate)) { tracksScrobbled.push(playObj); } + } else { + if(!timeFrameValid) { + this.logger.debug(`Will not scrobble ${buildTrackString(playObj)} because it ${timeFrameValidLog}`); + } } } catch(e) { this.logger.error(`Encountered error while in scrobble loop for ${client.name}`); -- 2.51.2 From 3d86a38a2e1058c888de9e8a7ba2ac81de1e0184 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 11:27:25 -0500 Subject: [PATCH 14/41] Refactor: Create a Source of Truth for track discover by sources and consolidate/simplify scrobble behavior * Add a 'recentDiscoveredPlays' class property for abstract source * recentDiscoveredPlays is platform aware and configured based on source 'multiPlatform' class property * Consolidate scrobble behavior into abstract class * Check for existing discovered track before scrobbling * Add to discovered if not existing and increment discovered tracks counter * Decouple sources from clients by using event emitter when a newly discovered track should be scrobbled * Use discovered tracks for UI display instead of api data --- src/common/infrastructure/Atomic.ts | 5 + src/index.ts | 2 +- src/sources/AbstractSource.ts | 190 +++++++++++++++------------- src/sources/DeezerSource.ts | 8 +- src/sources/JellyfinSource.ts | 15 +-- src/sources/LastfmSource.ts | 4 +- src/sources/MPRISSource.ts | 2 + src/sources/MemorySource.ts | 53 ++++---- src/sources/PlexSource.ts | 6 +- src/sources/SpotifySource.ts | 10 +- src/sources/SubsonicSource.ts | 2 + src/sources/YTMusicSource.ts | 24 ++-- src/views/recent.ejs | 3 - 13 files changed, 168 insertions(+), 156 deletions(-) diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index c31b267b..10804bb4 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -1,4 +1,5 @@ import {Dayjs} from "dayjs"; +import {FixedSizeList} from 'fixed-size-list'; export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris'; export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris']; @@ -88,6 +89,10 @@ export interface ProgressAwarePlayObject extends PlayObject { } } +export type GroupedPlays = Map; + +export type GroupedFixedPlays = Map>; + export interface TrackStringOptions { include?: ('time' | 'artist' | 'track' | 'timeFromNow' | 'trackId')[] transformers?: { diff --git a/src/index.ts b/src/index.ts index 9200b505..95f5fdb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -428,7 +428,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) return res.status(400).send(`Specified source cannot retrieve recent plays (${source.type})`); } - const result = await (source as AbstractSource).getRecentlyPlayed({formatted: true, display: true}); + const result = (source as AbstractSource).getFlatRecentlyDiscoveredPlays(); const artistTruncFunc = truncateStringToLength(Math.min(40, longestString(result.map((x: any) => x.data.artists.join(' / ')).flat()))); const trackLength = longestString(result.map((x: any) => x.data.track)) const plays = result.map((x: PlayObject) => { diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index 86db974c..cfba83a1 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -1,9 +1,24 @@ import dayjs, {Dayjs} from "dayjs"; -import {buildTrackString, capitalize, createLabelledLogger, sleep} from "../utils.js"; -import {InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js"; +import { + buildTrackString, + capitalize, closePlayDate, + createLabelledLogger, + genGroupId, + playObjDataMatch, + sleep, sortByNewestPlayDate, + sortByOldestPlayDate +} from "../utils.js"; +import { + GroupedFixedPlays, + GroupedPlays, + InternalConfig, + PlayObject, ProgressAwarePlayObject, + SourceType +} from "../common/infrastructure/Atomic.js"; import {Logger} from "winston"; import {SourceConfig} from "../common/infrastructure/config/source/sources.js"; import {EventEmitter} from "events"; +import {FixedSizeList} from "fixed-size-list"; export interface RecentlyPlayedOptions { limit?: number @@ -27,6 +42,8 @@ export default abstract class AbstractSource { requiresAuthInteraction: boolean = false; authed: boolean = false; + multiPlatform: boolean = false; + localUrl: string; configDir: string; @@ -38,6 +55,8 @@ export default abstract class AbstractSource { emitter: EventEmitter; + protected recentDiscoveredPlays: GroupedFixedPlays = new Map(); + constructor(type: SourceType, name: string, config: SourceConfig, internal: InternalConfig, emitter: EventEmitter) { const {clients = [] } = config; this.type = type; @@ -74,14 +93,65 @@ export default abstract class AbstractSource { return true; } - protected scrobble = (plays: PlayObject[], options: { checkTime?: Dayjs, forceRefresh?: boolean } = {}) => { - this.emitter.emit('scrobble', { - data: plays, options: { - ...options, - scrobbleFrom: this.identifier, - scrobbleTo: this.clients + protected addPlayToDiscovered = (play: PlayObject) => { + const platformId = this.multiPlatform ? genGroupId(play) : 'SingleUser'; + const list = this.recentDiscoveredPlays.get(platformId) ?? new FixedSizeList(30); + list.add(play); + this.recentDiscoveredPlays.set(platformId, list); + this.tracksDiscovered++; + this.logger.info(`Discovered => ${buildTrackString(play)}`); + } + + getFlatRecentlyDiscoveredPlays = (): PlayObject[] => { + return Array.from(this.recentDiscoveredPlays.values()).map(x => x.data).flat(3).sort(sortByNewestPlayDate); + } + + getRecentlyDiscoveredPlaysByPlatform = (platformId): PlayObject[] => { + const list = this.recentDiscoveredPlays.get(platformId); + if (list !== undefined) { + const data = [...list.data]; + data.sort(sortByOldestPlayDate); + return data; + } + return []; + } + + existingDiscovered = (play: PlayObject): PlayObject | undefined => { + const list = this.getRecentlyDiscoveredPlaysByPlatform(this.multiPlatform ? genGroupId(play) : 'SingleUser'); + return list.find(x => playObjDataMatch(x, play) && closePlayDate(x, play)); + } + + alreadyDiscovered = (play: PlayObject): boolean => { + return this.existingDiscovered(play) !== undefined; + } + + + protected scrobble = (plays: PlayObject[], options: { forceRefresh?: boolean } = {}) => { + + const newDiscoveredPlays: PlayObject[] = []; + + for(const play of plays) { + if(!this.alreadyDiscovered(play)) { + this.addPlayToDiscovered(play); } - }); + newDiscoveredPlays.push(play); + } + + if(newDiscoveredPlays.length > 0) { + newDiscoveredPlays.sort(sortByOldestPlayDate); + + this.emitter.emit('scrobble', { + data: newDiscoveredPlays, + options: { + ...options, + checkTime: newDiscoveredPlays[newDiscoveredPlays.length-1].data.playDate.add(2, 'second'), + scrobbleFrom: this.identifier, + scrobbleTo: this.clients + } + }); + } + + return newDiscoveredPlays; } protected notify = (payload) => { @@ -156,26 +226,34 @@ export default abstract class AbstractSource { this.logger.info('Stopped polling due to user input'); break; } - let playObjs: PlayObject[] = []; this.logger.debug('Refreshing recently played'); - playObjs = await this.getRecentlyPlayed({formatted: true}); - - const { - lastTrackPlayedAt: lastPlayed, - scrobbleResult, - newTracksFound: newTracks - } = await this.ingestPlays(playObjs, allClients, lastTrackPlayedAt); + const playObjs = await this.getRecentlyPlayed({formatted: true}); - lastTrackPlayedAt = lastPlayed; + let newDiscovered: PlayObject[] = []; - if(newTracks) { - checkCount = 0; - checksOverThreshold = 0; + if(playObjs.length > 0) { + const now = dayjs().unix(); + const closeToInterval = playObjs.some(x => now - x.data.playDate.unix() < 5); + if (playObjs.length > 0 && closeToInterval) { + // because the interval check was so close to the play date we are going to delay client calls for a few secs + // this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups) + // additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully + this.logger.info('Potential plays were discovered close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first'); + await sleep(10 * 1000); + } + newDiscovered = this.scrobble(playObjs, + { + forceRefresh: closeToInterval + }); } - if (scrobbleResult.length > 0) { + lastTrackPlayedAt = newDiscovered.length > 0 ? newDiscovered[0].data.playDate : lastTrackPlayedAt; + + if(newDiscovered.length > 0) { checkCount = 0; - this.tracksDiscovered += scrobbleResult.length; + checksOverThreshold = 0; + } else { + this.logger.debug(`No new tracks discovered`); } const activeThreshold = lastTrackPlayedAt.add(checkActiveFor, 's'); @@ -206,70 +284,4 @@ export default abstract class AbstractSource { throw e; } } - - protected ingestPlays = async (playObjs: PlayObject[], allClients: any, lastTrackPlayedAt: Dayjs) => { - let newTracksFound = false; - let closeToInterval = false; - const now = dayjs(); - - const playInfo = playObjs.reduce((acc, playObj) => { - if(this.recentlyPlayedTrackIsValid(playObj)) { - const {data: { - playDate - } = {} - } = playObj; - if (playDate.unix() > lastTrackPlayedAt.unix()) { - newTracksFound = true; - this.logger.info(`New Track => ${buildTrackString(playObj)}`); - - if (closeToInterval === false) { - closeToInterval = Math.abs(now.unix() - playDate.unix()) < 5; - } - - return { - plays: [...acc.plays, {...playObj, meta: {...playObj.meta, newFromSource: true}}], - lastTrackPlayedAt: playDate - } - } - return { - ...acc, - plays: [...acc.plays, playObj] - } - } - return acc; - }, {plays: [], lastTrackPlayedAt}); - playObjs = playInfo.plays; - lastTrackPlayedAt = playInfo.lastTrackPlayedAt; - - if (playObjs.length > 0 && closeToInterval) { - // because the interval check was so close to the play date we are going to delay client calls for a few secs - // this way we don't accidentally scrobble ahead of any other clients (we always want to be behind so we can check for dups) - // additionally -- it should be ok to have this in the for loop because played_at will only decrease (be further in the past) so we should only hit this once, hopefully - this.logger.info('Track is close to polling interval! Delaying scrobble clients refresh by 10 seconds so other clients have time to scrobble first'); - await sleep(10 * 1000); - } - - if (newTracksFound === false) { - if (playObjs.length === 0) { - this.logger.debug(`No new tracks found`); - } else { - this.logger.debug(`No new tracks found. Newest track returned was ${buildTrackString(playObjs.slice(-1)[0])}`); - } - } - - let scrobbleResult = []; - - if(playObjs.length > 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 - this.scrobble(playObjs, {checkTime: lastTrackPlayedAt.add(2, 's'), - forceRefresh: closeToInterval}); - } - - return { - newTracksFound, - scrobbleResult, - lastTrackPlayedAt - } - } } diff --git a/src/sources/DeezerSource.ts b/src/sources/DeezerSource.ts index d569530b..6bba762f 100644 --- a/src/sources/DeezerSource.ts +++ b/src/sources/DeezerSource.ts @@ -1,5 +1,5 @@ import request from 'superagent'; -import {parseRetryAfterSecsFromObj, readJson, sleep, sortByPlayDate, writeFile} from "../utils.js"; +import {parseRetryAfterSecsFromObj, readJson, sleep, sortByOldestPlayDate, writeFile} from "../utils.js"; import {Strategy as DeezerStrategy} from 'passport-deezer'; import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import dayjs from "dayjs"; @@ -101,12 +101,8 @@ export default class DeezerSource extends AbstractSource { } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { - const {formatted = false} = options; const resp = await this.callApi(request.get(`${this.baseUrl}/user/me/history`)); - if(formatted) { - return resp.data.map((x: any) => DeezerSource.formatPlayObj(x)).sort(sortByPlayDate); - } - return resp.data; + return resp.data.map((x: any) => DeezerSource.formatPlayObj(x)).sort(sortByOldestPlayDate); } callApi = async (req: any, retries = 0) => { diff --git a/src/sources/JellyfinSource.ts b/src/sources/JellyfinSource.ts index d26ec18a..c9859394 100644 --- a/src/sources/JellyfinSource.ts +++ b/src/sources/JellyfinSource.ts @@ -11,6 +11,8 @@ export default class JellyfinSource extends MemorySource { users; servers; + multiPlatform: boolean = true; + declare config: JellySourceConfig; constructor(name: any, config: JellySourceConfig, internal: InternalConfig, emitter: EventEmitter) { @@ -154,7 +156,7 @@ export default class JellyfinSource extends MemorySource { } getRecentlyPlayed = async (options = {}) => { - return this.getFlatStatefulRecentlyPlayed(); + return this.getFlatRecentlyDiscoveredPlays(); } handle = async (playObj: any) => { @@ -164,18 +166,9 @@ export default class JellyfinSource extends MemorySource { 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 { - this.scrobble(newPlays, {checkTime: newestPlay.data.playDate}); - //await root.get('clients').scrobble(newPlays, {scrobbleTo: this.clients, scrobbleFrom: this.identifier, checkTime: newestPlay.data.playDate}); - // only gets hit if we scrobbled ok - this.tracksDiscovered++; + this.scrobble(newPlays); } catch (e) { this.logger.error('Encountered error while scrobbling') this.logger.error(e) diff --git a/src/sources/LastfmSource.ts b/src/sources/LastfmSource.ts index 1ba58d78..a1fcc24e 100644 --- a/src/sources/LastfmSource.ts +++ b/src/sources/LastfmSource.ts @@ -1,6 +1,6 @@ import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import LastfmApiClient from "../apis/LastfmApiClient.js"; -import {sortByPlayDate} from "../utils.js"; +import {sortByOldestPlayDate} from "../utils.js"; import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {UserGetRecentTracksResponse} from "lastfm-node-client"; @@ -80,6 +80,6 @@ export default class LastfmSource extends AbstractSource { this.logger.debug(x); return acc; } - }, []).sort(sortByPlayDate); + }, []).sort(sortByOldestPlayDate); } } diff --git a/src/sources/MPRISSource.ts b/src/sources/MPRISSource.ts index de3eb715..3883a4e0 100644 --- a/src/sources/MPRISSource.ts +++ b/src/sources/MPRISSource.ts @@ -21,6 +21,8 @@ export class MPRISSource extends MemorySource { whitelist: string[] = []; blacklist: string[] = []; + multiPlatform: boolean = true; + constructor(name: any, config: MPRISSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('mpris', name, config, internal, emitter); this.canPoll = true; diff --git a/src/sources/MemorySource.ts b/src/sources/MemorySource.ts index 40705b3a..b673158c 100644 --- a/src/sources/MemorySource.ts +++ b/src/sources/MemorySource.ts @@ -1,11 +1,14 @@ import AbstractSource from "./AbstractSource.js"; -import {playObjDataMatch, sortByPlayDate, buildTrackString, toProgressAwarePlayObject, getProgress} from "../utils.js"; +import { + playObjDataMatch, + sortByOldestPlayDate, + buildTrackString, + toProgressAwarePlayObject, + getProgress, + genGroupId +} from "../utils.js"; import dayjs from "dayjs"; -import {PlayObject, ProgressAwarePlayObject} from "../common/infrastructure/Atomic.js"; - -export type GroupedPlays = Map; - -const genGroupId = (play: PlayObject) => `${play.meta.deviceId ?? 'NoDevice'}-${play.meta.user ?? 'SingleUser'}`; +import {GroupedPlays, PlayObject, ProgressAwarePlayObject} from "../common/infrastructure/Atomic.js"; export default class MemorySource extends AbstractSource { /* @@ -25,7 +28,7 @@ export default class MemorySource extends AbstractSource { * * These are tracks that are actually used by the source to scrobble to clients * */ - statefulRecentlyPlayed: GroupedPlays = new Map(); + //statefulRecentlyPlayed: GroupedPlays = new Map(); /** * Tracks that are actively being tracked (discovered from source recently) to see if they will "qualify" as being played. * @@ -33,9 +36,9 @@ export default class MemorySource extends AbstractSource { * */ candidateRecentlyPlayed: GroupedPlays = new Map(); - getFlatStatefulRecentlyPlayed = (): PlayObject[] => { - return Array.from(this.statefulRecentlyPlayed.values()).flat().sort(sortByPlayDate); - } + // getFlatStatefulRecentlyPlayed = (): PlayObject[] => { + // return Array.from(this.statefulRecentlyPlayed.values()).flat().sort(sortByPlayDate); + // } processRecentPlays = (plays: PlayObject[], useExistingPlayDate = false) => { @@ -87,11 +90,11 @@ export default class MemorySource extends AbstractSource { }); // and then combine still playing with new tracks cRecentlyPlayed = cRecentlyPlayed.concat(newProgressAwareTracks); - cRecentlyPlayed.sort(sortByPlayDate); + cRecentlyPlayed.sort(sortByOldestPlayDate); this.candidateRecentlyPlayed.set(groupId, cRecentlyPlayed); - const sRecentlyPlayed = this.statefulRecentlyPlayed.get(groupId) ?? []; + //const sRecentlyPlayed = this.statefulRecentlyPlayed.get(groupId) ?? []; // now we check if all candidates pass tests for having been tracked long enough: // * Has been tracked for at least 30 seconds @@ -117,12 +120,12 @@ export default class MemorySource extends AbstractSource { if(firstSeenValid && progressValid) { // a prior candidate has been playing for more than 30 seconds and passed progress test, time to check statefuls - const matchingRecent = sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); + const matchingRecent = this.existingDiscovered(candidate); //sRecentlyPlayed.find(x => playObjDataMatch(x, candidate)); let stPrefix = `[Platform ${groupId}] (Stateful Play) ${buildTrackString(candidate, {include: ['trackId', '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); - sRecentlyPlayed.push(candidate); + //sRecentlyPlayed.push(candidate); } else { const {data: { playDate, duration }} = candidate; const {data: { playDate: rplayDate }} = matchingRecent; @@ -131,23 +134,27 @@ export default class MemorySource extends AbstractSource { 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); - sRecentlyPlayed.push(candidate); + //sRecentlyPlayed.push(candidate); + } + } else { + const discoveredPlays = this.getRecentlyDiscoveredPlaysByPlatform(candidate); + if(discoveredPlays.length === 0 || !playObjDataMatch(discoveredPlays[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); } - } else if(!playObjDataMatch(sRecentlyPlayed[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); - sRecentlyPlayed.push(candidate); + //sRecentlyPlayed.push(candidate); } } } } } - sRecentlyPlayed.sort(sortByPlayDate); - this.statefulRecentlyPlayed.set(groupId, sRecentlyPlayed); + //sRecentlyPlayed.sort(sortByPlayDate); + //this.statefulRecentlyPlayed.set(groupId, sRecentlyPlayed); } } - return this.getFlatStatefulRecentlyPlayed(); + return newStatefulPlays; + //return this.getFlatStatefulRecentlyPlayed(); //return newStatefulPlays; } diff --git a/src/sources/PlexSource.ts b/src/sources/PlexSource.ts index dd3b2efb..e1c68aa8 100644 --- a/src/sources/PlexSource.ts +++ b/src/sources/PlexSource.ts @@ -14,6 +14,8 @@ export default class PlexSource extends AbstractSource { libraries: string[]; servers: string[]; + multiPlatform: boolean = true; + declare config: PlexSourceConfig; constructor(name: any, config: PlexSourceConfig, internal: InternalConfig, type: SourceType = 'plex',emitter: EventEmitter) { @@ -170,12 +172,8 @@ export default class PlexSource extends AbstractSource { return; } - this.logger.info(`New Track => ${buildTrackString(playObj)}`); try { this.scrobble([playObj]); - //await root.get('clients').scrobble(playObj, {scrobbleTo: this.clients, scrobbleFrom: this.identifier}); - // only gets hit if we scrobbled ok - this.tracksDiscovered++; } catch (e) { this.logger.error('Encountered error while scrobbling') this.logger.error(e) diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index 3777657f..e1c6681f 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import { readJson, writeFile, - sortByPlayDate, sleep, parseRetryAfterSecsFromObj, buildTrackString, truncateStringToLength, combinePartsToString, + sortByOldestPlayDate, sleep, parseRetryAfterSecsFromObj, buildTrackString, truncateStringToLength, combinePartsToString, } from "../utils.js"; import SpotifyWebApi from "spotify-web-api-node"; import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; @@ -270,7 +270,7 @@ export default class SpotifySource extends MemorySource { limit }); const result = await this.callApi>(func); - return result.body.items.map((x: any) => SpotifySource.formatPlayObj(x)).sort(sortByPlayDate); + return result.body.items.map((x: any) => SpotifySource.formatPlayObj(x)).sort(sortByOldestPlayDate); } getNowPlaying = async () => { @@ -373,11 +373,7 @@ export default class SpotifySource extends MemorySource { this.logger.info('Checking recently played API for tracks to backlog...'); const backlogPlays = await this.getPlayHistory({formatted: true}); - const { - lastTrackPlayedAt: lastPlayed, - scrobbleResult, - newTracksFound: newTracks - } = await this.ingestPlays(backlogPlays, this.instantiatedAt); + this.scrobble(backlogPlays); this.logger.info('Backlog complete.'); await this.startPolling(); diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index b114fa5c..ea624ac5 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -15,6 +15,8 @@ export class SubsonicSource extends MemorySource { requiresAuth = true; + multiPlatform: boolean = true; + declare config: SubSonicSourceConfig; constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { diff --git a/src/sources/YTMusicSource.ts b/src/sources/YTMusicSource.ts index fc0caa77..3a8dc0b9 100644 --- a/src/sources/YTMusicSource.ts +++ b/src/sources/YTMusicSource.ts @@ -108,11 +108,14 @@ export default class YTMusicSource extends AbstractSource { } catch (e) { throw e; } + + let newPlays: PlayObject[] = []; + const plays = playlistDetail.tracks.map((x) => YTMusicSource.formatPlayObj(x, false)).slice(0, 20); if(this.polling === false) { this.recentlyPlayed = plays; + newPlays = plays; } else { - let newPlays: PlayObject[] = []; // iterate through each play until we find one that matched the "newest" from the recently played for (const [i, value] of plays.entries()) { @@ -154,13 +157,8 @@ export default class YTMusicSource extends AbstractSource { this.recentlyPlayed = newPlays.concat(this.recentlyPlayed).slice(0, 20); } } - - if(!display) { - // used by MS for newely monitored tracks where we know the play date - return this.recentlyPlayed.filter(x => x.meta.newFromSource); - } - // used by UI for returning all results - return this.recentlyPlayed; + + return newPlays; } @@ -182,9 +180,15 @@ export default class YTMusicSource extends AbstractSource { } poll = async () => { - if(this.authed) { + if(this.authed && !this.polling) { this.logger.verbose('Hydrating initial recently played tracks for reference.'); - await this.getRecentlyPlayed(); + const referencePlays = await this.getRecentlyPlayed(); + if(this.getFlatRecentlyDiscoveredPlays().length === 0) { + // and add to discovered since its empty + for(const refPlay of referencePlays) { + this.addPlayToDiscovered(refPlay); + } + } } await this.startPolling(); } diff --git a/src/views/recent.ejs b/src/views/recent.ejs index bd24266b..7982504b 100644 --- a/src/views/recent.ejs +++ b/src/views/recent.ejs @@ -38,9 +38,6 @@

Recently Played (<%= name %>)

- <% if (sourceType === 'spotify') { %> - This list was returned from the Spotify Api now. Sometimes Spotify "backlogs" user activity so your recent tracks may not show up for minutes (or hours). ¯\_(ツ)_/¯ - <% } %>
    <% plays.forEach(function (play){ %>
  • <%-play%>
  • -- 2.51.2 From 51b4ea3b161fec49858b51803469c74fb629886c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 11:51:36 -0500 Subject: [PATCH 15/41] Refactor: Consolidate formatPlayObj options --- src/apis/AbstractApiClient.ts | 4 ++-- src/apis/LastfmApiClient.ts | 4 ++-- src/clients/AbstractScrobbleClient.ts | 4 ++-- src/clients/LastfmScrobbler.ts | 9 +++++++-- src/clients/MalojaScrobbler.ts | 7 +++++-- src/common/infrastructure/Atomic.ts | 12 ++++++++++++ src/index.ts | 6 +++--- src/sources/DeezerSource.ts | 5 +++-- src/sources/JellyfinSource.ts | 5 +++-- src/sources/LastfmSource.ts | 6 +++--- src/sources/MPRISSource.ts | 5 +++-- src/sources/PlexSource.ts | 5 +++-- src/sources/SpotifySource.ts | 13 +++++++++---- src/sources/SubsonicSource.ts | 5 +++-- src/sources/TautulliSource.ts | 5 +++-- src/sources/YTMusicSource.ts | 7 ++++--- src/sources/ingressNotifiers/PlexNotifier.ts | 2 +- src/sources/ingressNotifiers/TautulliNotifier.ts | 2 +- 18 files changed, 69 insertions(+), 37 deletions(-) diff --git a/src/apis/AbstractApiClient.ts b/src/apis/AbstractApiClient.ts index 792e26f1..8d1fadcb 100644 --- a/src/apis/AbstractApiClient.ts +++ b/src/apis/AbstractApiClient.ts @@ -1,6 +1,6 @@ import {capitalize, createLabelledLogger} from "../utils.js"; import {Logger} from "winston"; -import {PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js"; export default abstract class AbstractApiClient { name: string; @@ -24,7 +24,7 @@ export default abstract class AbstractApiClient { this.options = options; } - static formatPlayObj = (obj: any): PlayObject => { + static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => { throw new Error('should be overridden'); } } diff --git a/src/apis/LastfmApiClient.ts b/src/apis/LastfmApiClient.ts index cbc97aee..f062d7e4 100644 --- a/src/apis/LastfmApiClient.ts +++ b/src/apis/LastfmApiClient.ts @@ -2,7 +2,7 @@ import LastFm, {AuthGetSessionResponse, TrackObject, UserGetInfoResponse} from " import AbstractApiClient from "./AbstractApiClient.js"; import dayjs from "dayjs"; import {readJson, sleep, writeFile} from "../utils.js"; -import {PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js"; import {LastfmData} from "../common/infrastructure/config/client/lastfm.js"; const badErrors = [ @@ -35,7 +35,7 @@ export default class LastfmApiClient extends AbstractApiClient { this.client = new LastFm(apiKey as string, secret, session); } - static formatPlayObj = (obj: TrackObject): PlayObject => { + static formatPlayObj = (obj: TrackObject, options: FormatPlayObjectOptions = {}): PlayObject => { const { artist: { '#text': artists, diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index b127ec2d..d1bf98ec 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -7,7 +7,7 @@ import { truncateStringToLength } from "../utils.js"; import { - ClientType, + ClientType, FormatPlayObjectOptions, INITIALIZED, INITIALIZING, InitState, @@ -135,7 +135,7 @@ export default abstract class AbstractScrobbleClient { return this.lastScrobbleCheck; } - formatPlayObj = (obj: any) => { + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => { this.logger.warn('formatPlayObj should be defined by concrete class!'); return obj; } diff --git a/src/clients/LastfmScrobbler.ts b/src/clients/LastfmScrobbler.ts index f66c04c3..b15d9c7e 100644 --- a/src/clients/LastfmScrobbler.ts +++ b/src/clients/LastfmScrobbler.ts @@ -9,7 +9,12 @@ import { truncateStringToLength, } from "../utils.js"; import LastfmApiClient from "../apis/LastfmApiClient.js"; -import {INITIALIZING, PlayObject, TrackStringOptions} from "../common/infrastructure/Atomic.js"; +import { + FormatPlayObjectOptions, + INITIALIZING, + PlayObject, + TrackStringOptions +} from "../common/infrastructure/Atomic.js"; import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; import {TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-client"; import {Notifiers} from "../notifier/Notifiers.js"; @@ -28,7 +33,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { this.api = new LastfmApiClient(name, config.data, options) } - formatPlayObj = (obj: any) => LastfmApiClient.formatPlayObj(obj); + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options); initialize = async () => { // @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'. diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index 7c98e442..4b821e96 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -12,6 +12,7 @@ import { parseRetryAfterSecsFromObj, capitalize, closePlayDate } from "../utils.js"; import { + FormatPlayObjectOptions, INITIALIZING, MalojaScrobbleData, MalojaScrobbleRequestData, @@ -46,13 +47,15 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { } } - static formatPlayObj(obj: MalojaScrobbleData, serverVersion = undefined): PlayObject { + static formatPlayObj(obj: MalojaScrobbleData, options: FormatPlayObjectOptions = {}): PlayObject { let artists, title, album, duration, time; + const {serverVersion} = options; + if(serverVersion === undefined || compareVersions(serverVersion, '3.0.0') >= 0) { // scrobble data structure changed for v3 const { @@ -115,7 +118,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { } } - formatPlayObj = (obj: any) => MalojaScrobbler.formatPlayObj(obj, this.serverVersion); + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => MalojaScrobbler.formatPlayObj(obj, {serverVersion: this.serverVersion}); callApi = async (req: any, retries = 0) => { const { diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 10804bb4..e3c5e98b 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -48,6 +48,11 @@ export interface PlayData { export interface PlayMeta { source?: string + + /** + * Specifies from what facet/data from the source this play was parsed from IE history, now playing, etc... + * */ + parsedFrom?: string /** * Unique ID for this track, given by the Source * */ @@ -83,6 +88,13 @@ export interface PlayObject { meta: PlayMeta } +export interface FormatPlayObjectOptions { + newFromSource?: boolean + parsedFrom?: string + + [key: string]: any +} + export interface ProgressAwarePlayObject extends PlayObject { meta: PlayMeta & { initialTrackProgressPosition?: number diff --git a/src/index.ts b/src/index.ts index 95f5fdb2..0be2ff77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -260,7 +260,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) app.postAsync('/tautulli', async function(this: any, req, res) { tauIngress.trackIngress(req, false); - const payload = TautulliSource.formatPlayObj(req.body, true); + const payload = TautulliSource.formatPlayObj(req.body, {newFromSource: true}); // try to get config name from payload if (req.body.scrobblerConfig !== undefined) { const source = scrobbleSources.getByName(req.body.scrobblerConfig); @@ -303,7 +303,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const { payload } = req as any; if(payload !== undefined) { - const playObj = PlexSource.formatPlayObj(payload, true); + const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true}); const pSources = scrobbleSources.getByType('plex') as PlexSource[]; if(pSources.length === 0) { @@ -334,7 +334,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const parts = remoteHostIdentifiers(req); const connectionId = `${parts.host}-${parts.proxy ?? ''}`; - const playObj = JellyfinSource.formatPlayObj({...req.body, connectionId}, true); + const playObj = JellyfinSource.formatPlayObj({...req.body, connectionId}, {newFromSource: true}); const pSources = scrobbleSources.getByType('jellyfin') as JellyfinSource[]; if(pSources.length === 0) { logger.warn('Received Jellyfin connection but no Jellyfin sources are configured'); diff --git a/src/sources/DeezerSource.ts b/src/sources/DeezerSource.ts index 6bba762f..caf262aa 100644 --- a/src/sources/DeezerSource.ts +++ b/src/sources/DeezerSource.ts @@ -4,7 +4,7 @@ import {Strategy as DeezerStrategy} from 'passport-deezer'; import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import dayjs from "dayjs"; import {DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import EventEmitter from "events"; export default class DeezerSource extends AbstractSource { @@ -36,7 +36,8 @@ export default class DeezerSource extends AbstractSource { this.canPoll = true; } - static formatPlayObj(obj: any, newFromSource = false): PlayObject { + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = false} = options; const { title: name, artist: { diff --git a/src/sources/JellyfinSource.ts b/src/sources/JellyfinSource.ts index c9859394..d66fbcc9 100644 --- a/src/sources/JellyfinSource.ts +++ b/src/sources/JellyfinSource.ts @@ -2,7 +2,7 @@ import MemorySource from "./MemorySource.js"; import dayjs from "dayjs"; import {buildTrackString, combinePartsToString, parseDurationFromTimestamp, truncateStringToLength} from "../utils.js"; import {JellySourceConfig} from "../common/infrastructure/config/source/jellyfin.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import EventEmitter from "events"; const shortDeviceId = truncateStringToLength(10, ''); @@ -49,7 +49,8 @@ export default class JellyfinSource extends MemorySource { this.initialized = true; } - static formatPlayObj(obj: any, newFromSource = false): PlayObject { + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = false} = options; const { ServerId, ServerName, diff --git a/src/sources/LastfmSource.ts b/src/sources/LastfmSource.ts index a1fcc24e..f52c53d3 100644 --- a/src/sources/LastfmSource.ts +++ b/src/sources/LastfmSource.ts @@ -2,7 +2,7 @@ import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import LastfmApiClient from "../apis/LastfmApiClient.js"; import {sortByOldestPlayDate} from "../utils.js"; import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {UserGetRecentTracksResponse} from "lastfm-node-client"; import EventEmitter from "events"; @@ -20,8 +20,8 @@ export default class LastfmSource extends AbstractSource { this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir}); } - static formatPlayObj(obj: any): PlayObject { - return LastfmApiClient.formatPlayObj(obj); + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { + return LastfmApiClient.formatPlayObj(obj, options); } initialize = async () => { diff --git a/src/sources/MPRISSource.ts b/src/sources/MPRISSource.ts index 3883a4e0..efd0a807 100644 --- a/src/sources/MPRISSource.ts +++ b/src/sources/MPRISSource.ts @@ -7,7 +7,7 @@ import { PlaybackStatus, PlayerInfo, PROPERTIES_IFACE } from "../common/infrastructure/config/source/mpris.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import MemorySource from "./MemorySource.js"; import {RecentlyPlayedOptions} from "./AbstractSource.js"; import {removeDuplicates} from "../utils.js"; @@ -40,7 +40,8 @@ export class MPRISSource extends MemorySource { } } - static formatPlayObj(obj: PlayerInfo, newFromSource = false): PlayObject { + static formatPlayObj(obj: PlayerInfo, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = false} = options; const { name, position, diff --git a/src/sources/PlexSource.ts b/src/sources/PlexSource.ts index e1c68aa8..4b2f8513 100644 --- a/src/sources/PlexSource.ts +++ b/src/sources/PlexSource.ts @@ -4,7 +4,7 @@ import AbstractSource from "./AbstractSource.js"; import formidable from 'formidable'; import concatStream from 'concat-stream'; import {PlexSourceConfig} from "../common/infrastructure/config/source/plex.js"; -import {InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js"; import EventEmitter from "events"; const shortDeviceId = truncateStringToLength(10, ''); @@ -51,7 +51,8 @@ export default class PlexSource extends AbstractSource { this.initialized = true; } - static formatPlayObj(obj: any, newFromSource = false): PlayObject { + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = false} = options; const { event, Account: { diff --git a/src/sources/SpotifySource.ts b/src/sources/SpotifySource.ts index e1c6681f..e04e9885 100644 --- a/src/sources/SpotifySource.ts +++ b/src/sources/SpotifySource.ts @@ -7,7 +7,7 @@ import { import SpotifyWebApi from "spotify-web-api-node"; import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; import {SpotifySourceConfig} from "../common/infrastructure/config/source/spotify.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import PlayHistoryObject = SpotifyApi.PlayHistoryObject; import EventEmitter from "events"; import CurrentlyPlayingObject = SpotifyApi.CurrentlyPlayingObject; @@ -52,7 +52,12 @@ export default class SpotifySource extends MemorySource { this.canPoll = true; } - static formatPlayObj(obj: object, newFromSource = false): PlayObject { + static formatPlayObj(obj: object, options: FormatPlayObjectOptions = {}): PlayObject { + + const { + newFromSource = false + } = options; + let artists: ArtistObjectSimplified[]; let album: AlbumObjectSimplified; let name: string; @@ -279,7 +284,7 @@ export default class SpotifySource extends MemorySource { const {body: {item}} = playingRes; if(item !== undefined && item !== null) { - return SpotifySource.formatPlayObj(playingRes.body, true); + return SpotifySource.formatPlayObj(playingRes.body, {newFromSource: true}); } return undefined; } @@ -294,7 +299,7 @@ export default class SpotifySource extends MemorySource { } = {}} = res; return { device: device === null ? undefined : device, - play: item !== null && item !== undefined ? SpotifySource.formatPlayObj(res.body, true) : undefined + play: item !== null && item !== undefined ? SpotifySource.formatPlayObj(res.body, {newFromSource: true}) : undefined } } catch (e) { if(logError) { diff --git a/src/sources/SubsonicSource.ts b/src/sources/SubsonicSource.ts index ea624ac5..aedb1775 100644 --- a/src/sources/SubsonicSource.ts +++ b/src/sources/SubsonicSource.ts @@ -5,7 +5,7 @@ import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; import {buildTrackString, parseRetryAfterSecsFromObj, removeDuplicates, sleep} from "../utils.js"; import MemorySource from "./MemorySource.js"; import {SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {RecentlyPlayedOptions} from "./AbstractSource.js"; import EventEmitter from "events"; @@ -46,7 +46,8 @@ export class SubsonicSource extends MemorySource { this.canPoll = true; } - static formatPlayObj(obj: any, newFromSource = false): PlayObject { + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = false} = options; const { id, title, diff --git a/src/sources/TautulliSource.ts b/src/sources/TautulliSource.ts index a4498e22..bb5ea0e3 100644 --- a/src/sources/TautulliSource.ts +++ b/src/sources/TautulliSource.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import PlexSource from "./PlexSource.js"; import {TautulliSourceConfig} from "../common/infrastructure/config/source/tautulli.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {combinePartsToString, truncateStringToLength} from "../utils.js"; import EventEmitter from "events"; @@ -14,7 +14,8 @@ export default class TautulliSource extends PlexSource { super(name, config, internal, 'tautulli', emitter); } - static formatPlayObj(obj: any, newFromSource = false): PlayObject { + static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = false} = options; const { artist_name, track_name, diff --git a/src/sources/YTMusicSource.ts b/src/sources/YTMusicSource.ts index 3a8dc0b9..88fa39d5 100644 --- a/src/sources/YTMusicSource.ts +++ b/src/sources/YTMusicSource.ts @@ -1,7 +1,7 @@ import YouTubeMusic from "youtube-music-ts-api"; import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; -import {InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; import {IYouTubeMusicAuthenticated} from "youtube-music-ts-api/interfaces-primary"; import dayjs from "dayjs"; import {parseDurationFromTimestamp, playObjDataMatch} from "../utils.js"; @@ -23,7 +23,8 @@ export default class YTMusicSource extends AbstractSource { this.canPoll = true; } - static formatPlayObj(obj: ITrackDetail, newFromSource = false): PlayObject { + static formatPlayObj(obj: ITrackDetail, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = false} = options; const { id, title, @@ -111,7 +112,7 @@ export default class YTMusicSource extends AbstractSource { let newPlays: PlayObject[] = []; - const plays = playlistDetail.tracks.map((x) => YTMusicSource.formatPlayObj(x, false)).slice(0, 20); + const plays = playlistDetail.tracks.map((x) => YTMusicSource.formatPlayObj(x, {newFromSource: false})).slice(0, 20); if(this.polling === false) { this.recentlyPlayed = plays; newPlays = plays; diff --git a/src/sources/ingressNotifiers/PlexNotifier.ts b/src/sources/ingressNotifiers/PlexNotifier.ts index 0fc583be..15e82db5 100644 --- a/src/sources/ingressNotifiers/PlexNotifier.ts +++ b/src/sources/ingressNotifiers/PlexNotifier.ts @@ -18,7 +18,7 @@ export class PlexNotifier extends IngressNotifier { } - const playObj = PlexSource.formatPlayObj(payload, true); + const playObj = PlexSource.formatPlayObj(payload, {newFromSource: true}); if(playObj.meta.server === undefined) { return [false, `Payload from Plex did not contain server info! Check Plex logs for any errors.`]; diff --git a/src/sources/ingressNotifiers/TautulliNotifier.ts b/src/sources/ingressNotifiers/TautulliNotifier.ts index e7d6d0dd..81743ae0 100644 --- a/src/sources/ingressNotifiers/TautulliNotifier.ts +++ b/src/sources/ingressNotifiers/TautulliNotifier.ts @@ -12,7 +12,7 @@ export class TautulliNotifier extends IngressNotifier { seenServers: string[] = []; notifyBySource(req: Request, isRaw: boolean): [boolean, (string | undefined)] { if(!isRaw) { - const playObj = TautulliSource.formatPlayObj(req, true); + const playObj = TautulliSource.formatPlayObj(req, {newFromSource: true}); if(!this.seenServers.includes(playObj.meta.server)) { this.seenServers.push(playObj.meta.server); -- 2.51.2 From ad1895d6e1a28c1ee88a625893bca150c3f6194b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 12:19:29 -0500 Subject: [PATCH 16/41] fix: Add fallback comparison when sorting plays by playDate --- src/utils.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index e8549fe7..951dae9a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -136,9 +136,51 @@ export const buildTrackString = (playObj: PlayObject, options: TrackStringOption } // sorts playObj formatted objects by playDate in ascending (oldest first) order -export const sortByOldestPlayDate = (a: any, b: any) => a.data.playDate.isAfter(b.data.playDate) ? 1 : -1; +export const sortByOldestPlayDate = (a: PlayObject, b: PlayObject) => { + const { + data: { + playDate: aPlayDate + } = {} + } = a; + const { + data: { + playDate: bPlayDate + } = {} + } = b; + if(aPlayDate === undefined && bPlayDate === undefined) { + return 0; + } + if(aPlayDate === undefined) { + return 1; + } + if(bPlayDate === undefined) { + return -1; + } + return aPlayDate.isAfter(bPlayDate) ? 1 : -1 +}; -export const sortByNewestPlayDate = (a: any, b: any) => a.data.playDate.isAfter(b.data.playDate) ? 1 : -1; +export const sortByNewestPlayDate = (a: PlayObject, b: PlayObject) => { + const { + data: { + playDate: aPlayDate + } = {} + } = a; + const { + data: { + playDate: bPlayDate + } = {} + } = b; + if(aPlayDate === undefined && bPlayDate === undefined) { + return 0; + } + if(aPlayDate === undefined) { + return 1; + } + if(bPlayDate === undefined) { + return -1; + } + return aPlayDate.isBefore(bPlayDate) ? 1 : -1 +}; const s = splat(); const SPLAT = Symbol.for('splat') -- 2.51.2 From b68fb9a9f97419630204736e3c7e1677dbb08f5f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 12:20:02 -0500 Subject: [PATCH 17/41] fix(youtube): Fix bad index when checking for repeat tracks --- src/sources/YTMusicSource.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sources/YTMusicSource.ts b/src/sources/YTMusicSource.ts index 88fa39d5..10a3c056 100644 --- a/src/sources/YTMusicSource.ts +++ b/src/sources/YTMusicSource.ts @@ -127,7 +127,7 @@ export default class YTMusicSource extends AbstractSource { const match = playObjDataMatch(value, this.recentlyPlayed[0]); if (!match) { newPlays.push(value) - } else if (match && playObjDataMatch(plays[i + 1], this.recentlyPlayed[0])) { // if it matches but next ALSO matches the current it's a repeat "new" + } else if (match && plays.length > i + 1 && playObjDataMatch(plays[i + 1], this.recentlyPlayed[0])) { // if it matches but next ALSO matches the current it's a repeat "new" // check if repeated track newPlays.push(value) } else { @@ -184,6 +184,8 @@ export default class YTMusicSource extends AbstractSource { if(this.authed && !this.polling) { this.logger.verbose('Hydrating initial recently played tracks for reference.'); const referencePlays = await this.getRecentlyPlayed(); + // actual order they were discovered in (oldest to newest) + referencePlays.reverse(); if(this.getFlatRecentlyDiscoveredPlays().length === 0) { // and add to discovered since its empty for(const refPlay of referencePlays) { -- 2.51.2 From ff953b4535df5741d6412f2b14f10969c51ae670 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 12:31:27 -0500 Subject: [PATCH 18/41] fix: oops left in debug statement --- src/clients/ScrobbleClients.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/clients/ScrobbleClients.ts b/src/clients/ScrobbleClients.ts index 49d0059c..825ca77e 100644 --- a/src/clients/ScrobbleClients.ts +++ b/src/clients/ScrobbleClients.ts @@ -381,9 +381,6 @@ ${sources.join('\n')}`); } = {} } = playObj; const [timeFrameValid, timeFrameValidLog] = client.timeFrameIsValid(playObj); - if(playObj.data.track !== 'Por Supuesto') { - continue; - } if (timeFrameValid && !(await client.alreadyScrobbled(playObj))) { await client.scrobble(playObj) client.tracksScrobbled++; -- 2.51.2 From 97fe411ac0fd43b58358667a29c915b69c2a9b1b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 7 Mar 2023 14:22:27 -0500 Subject: [PATCH 19/41] feat: improved last activity date tracking --- src/sources/AbstractSource.ts | 17 ++++++++++------- src/sources/MemorySource.ts | 6 ++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index cfba83a1..bc9ca16b 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -37,6 +37,7 @@ export default abstract class AbstractSource { clients: string[]; logger: Logger; instantiatedAt: Dayjs; + lastActivityAt: Dayjs; initialized: boolean = false; requiresAuth: boolean = false; requiresAuthInteraction: boolean = false; @@ -66,6 +67,7 @@ export default abstract class AbstractSource { this.config = config; this.clients = clients; this.instantiatedAt = dayjs(); + this.lastActivityAt = this.instantiatedAt; this.localUrl = internal.localUrl; this.configDir = internal.configDir; this.emitter = emitter; @@ -210,7 +212,7 @@ export default abstract class AbstractSource { } this.logger.info('Polling started'); this.notify({title: `${this.identifier} - Polling Started`, message: 'Polling Started', priority: 'info'}); - let lastTrackPlayedAt = this.instantiatedAt; + this.lastActivityAt = dayjs(); let checkCount = 0; let checksOverThreshold = 0; @@ -221,7 +223,7 @@ export default abstract class AbstractSource { try { this.polling = true; while (true) { - // @ts-expect-error TS(2367): This condition will always return 'false' since th... Remove this comment to see the full error message + // @ts-ignore if(this.polling === false) { this.logger.info('Stopped polling due to user input'); break; @@ -247,16 +249,17 @@ export default abstract class AbstractSource { }); } - lastTrackPlayedAt = newDiscovered.length > 0 ? newDiscovered[0].data.playDate : lastTrackPlayedAt; if(newDiscovered.length > 0) { + // only update date if the play date is after the current activity date (in the case of backlogged plays) + this.lastActivityAt = newDiscovered[0].data.playDate.isAfter(this.lastActivityAt) ? newDiscovered[0].data.playDate : this.lastActivityAt; checkCount = 0; checksOverThreshold = 0; } else { this.logger.debug(`No new tracks discovered`); } - const activeThreshold = lastTrackPlayedAt.add(checkActiveFor, 's'); + const activeThreshold = this.lastActivityAt.add(checkActiveFor, 's'); const inactiveFor = dayjs.duration(Math.abs(activeThreshold.diff(dayjs(), 'millisecond'))).humanize(false); if (activeThreshold.isBefore(dayjs())) { checksOverThreshold++; @@ -264,13 +267,13 @@ export default abstract class AbstractSource { const checkVal = Math.min(checksOverThreshold, 1000); const backoff = Math.round(Math.max(Math.min(Math.min(checkVal, 1000) * 2 * (1.1 * checkVal), maxBackoff), 5)); sleepTime = interval + backoff; - this.logger.debug(`Last activity was at ${lastTrackPlayedAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for interval ${interval} + ${backoff} seconds.`); + this.logger.debug(`Last activity was at ${this.lastActivityAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for interval ${interval} + ${backoff} seconds.`); } else { - this.logger.debug(`Last activity was at ${lastTrackPlayedAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for max interval ${maxInterval} seconds.`); + this.logger.debug(`Last activity was at ${this.lastActivityAt.format()} which is ${inactiveFor} outside of active polling period of (last activity + ${checkActiveFor} seconds). Will sleep for max interval ${maxInterval} seconds.`); } } else { sleepTime = interval; - this.logger.debug(`Last activity was at ${lastTrackPlayedAt.format()}. Will sleep for interval ${sleepTime} seconds.`); + this.logger.debug(`Last activity was at ${this.lastActivityAt.format()}. Will sleep for interval ${sleepTime} seconds.`); } this.logger.verbose(`Sleeping for ${sleepTime}s`); diff --git a/src/sources/MemorySource.ts b/src/sources/MemorySource.ts index b673158c..dc6191ad 100644 --- a/src/sources/MemorySource.ts +++ b/src/sources/MemorySource.ts @@ -63,6 +63,9 @@ export default class MemorySource extends AbstractSource { // if no candidates exist new plays are new candidates if(cRecentlyPlayed.length === 0) { this.logger.debug(`[Platform ${groupId}] No prior candidate recent plays!`) + // update activity date here so that polling interval decreases *before* we get a new valid play + // so that we don't miss a play due to long polling interval + this.lastActivityAt = dayjs(); const progressAware: ProgressAwarePlayObject[] = []; for(const p of lockedPlays) { progressAware.push(toProgressAwarePlayObject(p)); @@ -74,6 +77,9 @@ export default class MemorySource extends AbstractSource { const newTracks = lockedPlays.filter((x: any) => cRecentlyPlayed.every(y => !playObjDataMatch(y, x))); const newProgressAwareTracks: ProgressAwarePlayObject[] = []; if(newTracks.length > 0) { + // update activity date here so that polling interval decreases *before* we get a new valid play + // so that we don't miss a play due to long polling interval + this.lastActivityAt = dayjs(); this.logger.debug(`[Platform ${groupId}] New plays found that do not match existing candidates.`) for(const p of newTracks) { this.logger.debug(`[Platform ${groupId}] Adding new locked play: ${buildTrackString(p, {include: ['trackId', 'artist', 'track']})}`); -- 2.51.2 From d2134f346790f67778b3eec35a0bcfafafafc6cf Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 8 Mar 2023 09:57:34 -0500 Subject: [PATCH 20/41] fix: fix reversing referenced array --- src/sources/YTMusicSource.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sources/YTMusicSource.ts b/src/sources/YTMusicSource.ts index 10a3c056..d8085c06 100644 --- a/src/sources/YTMusicSource.ts +++ b/src/sources/YTMusicSource.ts @@ -184,11 +184,12 @@ export default class YTMusicSource extends AbstractSource { if(this.authed && !this.polling) { this.logger.verbose('Hydrating initial recently played tracks for reference.'); const referencePlays = await this.getRecentlyPlayed(); + const reversedPlays = [...referencePlays]; // actual order they were discovered in (oldest to newest) - referencePlays.reverse(); + reversedPlays.reverse(); if(this.getFlatRecentlyDiscoveredPlays().length === 0) { // and add to discovered since its empty - for(const refPlay of referencePlays) { + for(const refPlay of reversedPlays) { this.addPlayToDiscovered(refPlay); } } -- 2.51.2 From 03cf3d1e3c7eadaf5691f8c402b767e24e2475b5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 8 Mar 2023 15:16:37 -0500 Subject: [PATCH 21/41] feat: Implement Mopidy source #24 --- README.md | 1 + docs/configuration.md | 96 +++++++++ package-lock.json | 142 ++++++++---- package.json | 5 +- src/clients/MalojaScrobbler.ts | 1 + src/common/infrastructure/Atomic.ts | 4 +- .../infrastructure/config/source/mopidy.ts | 76 +++++++ .../infrastructure/config/source/sources.ts | 5 +- src/common/schema/aio-source.json | 124 +++++++++++ src/common/schema/aio.json | 124 +++++++++++ src/common/schema/source.json | 117 ++++++++++ src/index.ts | 6 +- src/sources/MopidySource.ts | 202 ++++++++++++++++++ src/sources/ScrobbleSources.ts | 5 + tsconfig.json | 2 +- 15 files changed, 859 insertions(+), 51 deletions(-) create mode 100644 src/common/infrastructure/config/source/mopidy.ts create mode 100644 src/sources/MopidySource.ts diff --git a/README.md b/README.md index a8b238b1..156b4909 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git * [Last.fm](/docs/configuration.md#lastfm-source) * [Deezer](/docs/configuration.md#deezer) * [MPRIS (Linux Desktop)](/docs/configuration.md#mpris) + * [Mopidy](/docs/configuration.md#mopidy) * Supports scrobbling to many clients * [Maloja](/docs/configuration.md#maloja) * [Last.fm](/docs/configuration.md#lastfm) diff --git a/docs/configuration.md b/docs/configuration.md index 816401bb..133ed767 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,7 @@ * [Deezer](#deezer) * [Youtube Music](#youtube-music) * [MPRIS (Linux Desktop)](#mpris) + * [Mopidy](#mopidy) * [Client Configurations](#client-configurations) * [Maloja](#maloja) * [Last.fm](#lastfm) @@ -347,6 +348,101 @@ multi-scrobbler can listen to this interface and scrobble tracks played by **any See [`mpris.json.example`](/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) +## [Mopidy](https://mopidy.com/) + +Mopidy is a headless music server that supports playing music from many [standard and non-standard sources such as Pandora, Bandcamp, and Tunein.](https://mopidy.com/ext/) + +multi-scrobbler can scrobble tracks played from any Mopidy backend source, regardless of where you listen to them. + +### File-Based + +See [`mopidy.json.example`](/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMopidySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) + +Configuration Options: + +##### `url` + +The URL used to connect to the Mopidy server. You MUST have [Mopidy-HTTP extension](https://mopidy.com/ext/http) enabled. + +If no `url` is provided a default is used which assumes Mopidy is installed on the same server as multi-scrobbler: `ws://localhost:6680/mopidy/ws/` + +Make sure the hostname and port number match what is found in the Mopidy configuration file `mopidy.conf`: + +``` +... + +[http] +hostname = localhost +port = 6680 + +... +``` + +The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]` +If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it. + +Part => Default Value + +* Protocol => `ws://` +* Hostname => `localhost` +* Port => `6680` +* Path => `/mopidy/ws/` + +EX + +```json +{ + "url": "mopidy.mydomain.com" +} +``` + +MS transforms this to: `ws://mopidy.mydomain.com:6680/mopidy/ws/` + +```json +{ + "url": "192.168.0.101:3456" +} +``` + +MS transforms this to: `ws://192.168.0.101:3456/mopidy/ws/` + +```json +{ + "url": "mopidy.mydomain.com:80/MOPWS" +} +``` + +MS transforms this to: `ws://mopidy.mydomain.com:80/MOPWS` + + +#### URI Blacklist/Whitelist + +If you wish to disallow or only allow scrobbling from some sources played through Mopidy you can specify these using `uriBlacklist` or `uriWhitelist` in your config. multi-scrobbler will check the list to see if any string matches the START of the `uri` on a track. If whitelist is used then blacklist is ignored. All strings are case-insensitive. + +EX: + +```json +{ + "uriBlacklist": ["soundcloud"] +} +``` + +Will prevent multi-scrobbler from scrobbling any Mopidy track that start with a `uri` like `soundcloud:song:MySong-1234` + +#### Album Blacklist + +For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake Album name to this list. Multi-scrobbler will still scrobble the track, just without the bad data. All strings are case-insensitive. + +EX: + +```json +{ + "albumBlacklist": ["SoundCloud", "Mixcloud"] +} +``` + +If a track would be scrobbled like `Album: Soundcloud, Track: My Cool Track, Artist: A Cool Artist` +then multi-scrobbler will instead scrobble `Track: My Cool Track, Artist: A Cool Artist` # Client Configurations diff --git a/package-lock.json b/package-lock.json index d7f1587f..6f25ac4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,12 +26,15 @@ "iti": "^0.6.0", "json5": "^2.2.3", "lastfm-node-client": "^2.2.0", + "mopidy": "^1.3.0", + "normalize-url": "^6.1.0", "ntfy": "^1.0.5", + "p-event": "^4.2.0", "passport": "^0.6.0", "passport-deezer": "^0.2.0", "pony-cause": "^1.1.1", "safe-stable-stringify": "^1.1.1", - "socket.io": "4.1.3", + "socket.io": "^4.6.1", "spotify-web-api-node": "^5.0.2", "superagent": "^8.0.9", "triple-beam": "^1.3.0", @@ -149,6 +152,11 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -211,11 +219,6 @@ "@types/responselike": "^1.0.0" } }, - "node_modules/@types/component-emitter": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.11.tgz", - "integrity": "sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ==" - }, "node_modules/@types/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-2.0.0.tgz", @@ -596,14 +599,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/base64-arraybuffer": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", - "integrity": "sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -1168,31 +1163,31 @@ } }, "node_modules/engine.io": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-5.1.1.tgz", - "integrity": "sha512-aMWot7H5aC8L4/T8qMYbLdvKlZOdJTH54FxfdFunTGvhMx1BHkJOntWArsVfgAZVwAO9LC2sryPWRcEeUzCe5w==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.4.1.tgz", + "integrity": "sha512-JFYQurD/nbsA5BSPmbaOSLa3tSVj8L6o4srSwXXY3NqE+gGUNmmPTbhn8tjzcCtSqhFgIeqef81ngny8JM25hw==", "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.4.1", "cors": "~2.8.5", "debug": "~4.3.1", - "engine.io-parser": "~4.0.0", - "ws": "~7.4.2" + "engine.io-parser": "~5.0.3", + "ws": "~8.11.0" }, "engines": { "node": ">=10.0.0" } }, "node_modules/engine.io-parser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-4.0.3.tgz", - "integrity": "sha512-xEAAY0msNnESNPc00e19y5heTPX4y/TJ36gr8t1voOaNmTojP9b3oK3BbJLFufW2XFPQaaijpFewm2g2Um3uqA==", - "dependencies": { - "base64-arraybuffer": "0.1.4" - }, + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz", + "integrity": "sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==", "engines": { - "node": ">=8.0.0" + "node": ">=10.0.0" } }, "node_modules/engine.io/node_modules/cookie": { @@ -1224,6 +1219,26 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -1975,6 +1990,14 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "optional": true }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -2291,6 +2314,18 @@ "node": "*" } }, + "node_modules/mopidy": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/mopidy/-/mopidy-1.3.0.tgz", + "integrity": "sha512-wBk5XKXpZY5tMedeWtiS8mZlwOu5RZYibNe5TW85dQMovN4ZW0gl6SPNAwfqiwQ9YiCDOQwwEQ6CnPNPab6QYA==", + "dependencies": { + "isomorphic-ws": "^4.0.1", + "ws": "^7.3.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -2955,36 +2990,55 @@ } }, "node_modules/socket.io": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.1.3.tgz", - "integrity": "sha512-tLkaY13RcO4nIRh1K2hT5iuotfTaIQw7cVIe0FUykN3SuQi0cm7ALxuyT5/CtDswOMWUzMGTibxYNx/gU7In+Q==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.6.1.tgz", + "integrity": "sha512-KMcaAi4l/8+xEjkRICl6ak8ySoxsYG+gG6/XfRCPJPQ/haCRIJBTL4wIl8YCsmtaBovcAXGLOShyVWQ/FG8GZA==", "dependencies": { - "@types/cookie": "^0.4.0", - "@types/cors": "^2.8.10", - "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "~2.0.0", - "debug": "~4.3.1", - "engine.io": "~5.1.1", - "socket.io-adapter": "~2.3.1", - "socket.io-parser": "~4.0.4" + "debug": "~4.3.2", + "engine.io": "~6.4.1", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.1" }, "engines": { "node": ">=10.0.0" } }, "node_modules/socket.io-adapter": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.3.3.tgz", - "integrity": "sha512-Qd/iwn3VskrpNO60BeRyCyr8ZWw9CPZyitW4AQwmRZ8zCiyDiL+znRnWX6tDHXnWn1sJrM1+b6Mn6wEDJJ4aYQ==" + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz", + "integrity": "sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==", + "dependencies": { + "ws": "~8.11.0" + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } }, "node_modules/socket.io-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.5.tgz", - "integrity": "sha512-sNjbT9dX63nqUFIOv95tTVm6elyIU4RvB1m8dOeZt+IgWwcWklFDOdmGcfo3zSiRsnR/3pJkjY5lfoGqEe4Eig==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz", + "integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==", "dependencies": { - "@types/component-emitter": "^1.2.10", - "component-emitter": "~1.3.0", + "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" }, "engines": { diff --git a/package.json b/package.json index 368daa91..fc28a138 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,15 @@ "iti": "^0.6.0", "json5": "^2.2.3", "lastfm-node-client": "^2.2.0", + "mopidy": "^1.3.0", + "normalize-url": "^6.1.0", "ntfy": "^1.0.5", + "p-event": "^4.2.0", "passport": "^0.6.0", "passport-deezer": "^0.2.0", "pony-cause": "^1.1.1", "safe-stable-stringify": "^1.1.1", - "socket.io": "4.1.3", + "socket.io": "^4.6.1", "spotify-web-api-node": "^5.0.2", "superagent": "^8.0.9", "triple-beam": "^1.3.0", diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index 4b821e96..871c4030 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -357,6 +357,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { scrobble = async (playObj: PlayObject) => { const {url, apiKey} = this.config.data; + return true; const { data: { artists, diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index e3c5e98b..6a775b0e 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -1,8 +1,8 @@ import {Dayjs} from "dayjs"; import {FixedSizeList} from 'fixed-size-list'; -export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris'; -export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris']; +export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy'; +export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy']; export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic']; diff --git a/src/common/infrastructure/config/source/mopidy.ts b/src/common/infrastructure/config/source/mopidy.ts new file mode 100644 index 00000000..455766d9 --- /dev/null +++ b/src/common/infrastructure/config/source/mopidy.ts @@ -0,0 +1,76 @@ +import {CommonSourceConfig, CommonSourceData} from "./index.js"; +import {PollingOptions} from "../common.js"; + +export interface MopidyData extends CommonSourceData, PollingOptions { + /** + * URL of the Mopidy HTTP server to connect to + * + * You MUST have Mopidy-HTTP extension enabled: https://mopidy.com/ext/http + * + * multi-scrobbler connects to the WebSocket endpoint that ultimately looks like this => `ws://localhost:6680/mopidy/ws/` + * + * The URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them. + * + * Parts => [default value] + * + * * Protocol => `ws://` + * * Hostname => `localhost` + * * Port => `6680` + * * Path => `/mopidy/ws/` + * + * + * @examples ["ws://localhost:6680/mopidy/ws/"] + * @default "ws://localhost:6680/mopidy/ws/" + * */ + url?: string + + /** + * Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive + * + * EX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list. + * + * List is ignored if uriWhitelist is used. + * */ + uriBlacklist?: string[] + + /** + * Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive + * + * EX: Only scrobble tracks from soundcloud by adding 'soundcloud' to this list. + * + * */ + uriWhitelist?: string[] + + /** + * Remove album data that matches any case-insensitive string from this list when scrobbling, + * + * For certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use "Soundcloud" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake name to this list. Multi-scrobbler will still scrobble the track, just without the bad data. + * + * @examples [["Soundcloud", "Mixcloud"]] + * @default ["Soundcloud"] + * */ + albumBlacklist?: string[] + + /** + * How long to wait before polling the source API for new tracks (in seconds) + * + * @default 10 + * @examples [10] + * */ + interval?: number + + /** + * When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds) + * + * @default 30 + * @examples [30] + * */ + maxInterval?: number +} +export interface MopidySourceConfig extends CommonSourceConfig { + data: MopidyData +} + +export interface MopidySourceAIOConfig extends MopidySourceConfig { + type: 'mopidy' +} diff --git a/src/common/infrastructure/config/source/sources.ts b/src/common/infrastructure/config/source/sources.ts index 5a4d12dc..8322b68e 100644 --- a/src/common/infrastructure/config/source/sources.ts +++ b/src/common/infrastructure/config/source/sources.ts @@ -7,7 +7,8 @@ import {JellySourceAIOConfig, JellySourceConfig} from "./jellyfin.js"; import {LastFmSouceAIOConfig, LastfmSourceConfig} from "./lastfm.js"; import {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.js"; import {MPRISSourceAIOConfig, MPRISSourceConfig} from "./mpris.js"; +import {MopidySourceAIOConfig, MopidySourceConfig} from "./mopidy.js"; -export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig; +export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig; -export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig; +export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig; diff --git a/src/common/schema/aio-source.json b/src/common/schema/aio-source.json index c9d6eb1d..daa59da3 100644 --- a/src/common/schema/aio-source.json +++ b/src/common/schema/aio-source.json @@ -435,6 +435,127 @@ ], "type": "object" }, + "MopidyData": { + "properties": { + "albumBlacklist": { + "default": [ + "Soundcloud" + ], + "description": "Remove album data that matches any case-insensitive string from this list when scrobbling,\n\nFor certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use \"Soundcloud\" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake name to this list. Multi-scrobbler will still scrobble the track, just without the bad data.", + "examples": [ + [ + "Soundcloud", + "Mixcloud" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "type": "number" + }, + "maxInterval": { + "default": 30, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "uriBlacklist": { + "description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.", + "items": { + "type": "string" + }, + "type": "array" + }, + "uriWhitelist": { + "description": "Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Only scrobble tracks from soundcloud by adding 'soundcloud' to this list.", + "items": { + "type": "string" + }, + "type": "array" + }, + "url": { + "default": "ws://localhost:6680/mopidy/ws/", + "description": "URL of the Mopidy HTTP server to connect to\n\nYou MUST have Mopidy-HTTP extension enabled: https://mopidy.com/ext/http\n\nmulti-scrobbler connects to the WebSocket endpoint that ultimately looks like this => `ws://localhost:6680/mopidy/ws/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `6680`\n* Path => `/mopidy/ws/`", + "examples": [ + "ws://localhost:6680/mopidy/ws/" + ], + "type": "string" + } + }, + "type": "object" + }, + "MopidySourceAIOConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "data": { + "$ref": "#/definitions/MopidyData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + }, + "type": { + "enum": [ + "mopidy" + ], + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "type": "object" + }, "PlexSourceAIOConfig": { "properties": { "clients": { @@ -592,6 +713,9 @@ }, { "$ref": "#/definitions/MPRISSourceAIOConfig" + }, + { + "$ref": "#/definitions/MopidySourceAIOConfig" } ] }, diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index 9f7faaf3..943702ac 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -762,6 +762,127 @@ }, "type": "object" }, + "MopidyData": { + "properties": { + "albumBlacklist": { + "default": [ + "Soundcloud" + ], + "description": "Remove album data that matches any case-insensitive string from this list when scrobbling,\n\nFor certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use \"Soundcloud\" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake name to this list. Multi-scrobbler will still scrobble the track, just without the bad data.", + "examples": [ + [ + "Soundcloud", + "Mixcloud" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "type": "number" + }, + "maxInterval": { + "default": 30, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "uriBlacklist": { + "description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.", + "items": { + "type": "string" + }, + "type": "array" + }, + "uriWhitelist": { + "description": "Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Only scrobble tracks from soundcloud by adding 'soundcloud' to this list.", + "items": { + "type": "string" + }, + "type": "array" + }, + "url": { + "default": "ws://localhost:6680/mopidy/ws/", + "description": "URL of the Mopidy HTTP server to connect to\n\nYou MUST have Mopidy-HTTP extension enabled: https://mopidy.com/ext/http\n\nmulti-scrobbler connects to the WebSocket endpoint that ultimately looks like this => `ws://localhost:6680/mopidy/ws/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `6680`\n* Path => `/mopidy/ws/`", + "examples": [ + "ws://localhost:6680/mopidy/ws/" + ], + "type": "string" + } + }, + "type": "object" + }, + "MopidySourceAIOConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "data": { + "$ref": "#/definitions/MopidyData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + }, + "type": { + "enum": [ + "mopidy" + ], + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "type": "object" + }, "NtfyConfig": { "properties": { "name": { @@ -1016,6 +1137,9 @@ }, { "$ref": "#/definitions/MPRISSourceAIOConfig" + }, + { + "$ref": "#/definitions/MopidySourceAIOConfig" } ] }, diff --git a/src/common/schema/source.json b/src/common/schema/source.json index 8407d6fe..04ded56f 100644 --- a/src/common/schema/source.json +++ b/src/common/schema/source.json @@ -27,6 +27,9 @@ }, { "$ref": "#/definitions/MPRISSourceConfig" + }, + { + "$ref": "#/definitions/MopidySourceConfig" } ], "definitions": { @@ -436,6 +439,120 @@ ], "type": "object" }, + "MopidyData": { + "properties": { + "albumBlacklist": { + "default": [ + "Soundcloud" + ], + "description": "Remove album data that matches any case-insensitive string from this list when scrobbling,\n\nFor certain sources (Soundcloud) Mopidy does not have all track info (Album) and will instead use \"Soundcloud\" as the Album name. You can prevent multi-scrobbler from using this bad Album data by adding the fake name to this list. Multi-scrobbler will still scrobble the track, just without the bad data.", + "examples": [ + [ + "Soundcloud", + "Mixcloud" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "type": "number" + }, + "maxInterval": { + "default": 30, + "description": "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + "examples": [ + 30 + ], + "type": "number" + }, + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "uriBlacklist": { + "description": "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Don't scrobble tracks from soundcloud by adding 'soundcloud' to this list.\n\nList is ignored if uriWhitelist is used.", + "items": { + "type": "string" + }, + "type": "array" + }, + "uriWhitelist": { + "description": "Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive\n\nEX: Only scrobble tracks from soundcloud by adding 'soundcloud' to this list.", + "items": { + "type": "string" + }, + "type": "array" + }, + "url": { + "default": "ws://localhost:6680/mopidy/ws/", + "description": "URL of the Mopidy HTTP server to connect to\n\nYou MUST have Mopidy-HTTP extension enabled: https://mopidy.com/ext/http\n\nmulti-scrobbler connects to the WebSocket endpoint that ultimately looks like this => `ws://localhost:6680/mopidy/ws/`\n\nThe URL you provide here will have all parts not explicitly defined filled in for you so if these are not the default you must define them.\n\nParts => [default value]\n\n* Protocol => `ws://`\n* Hostname => `localhost`\n* Port => `6680`\n* Path => `/mopidy/ws/`", + "examples": [ + "ws://localhost:6680/mopidy/ws/" + ], + "type": "string" + } + }, + "type": "object" + }, + "MopidySourceConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "data": { + "$ref": "#/definitions/MopidyData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "PlexSourceConfig": { "properties": { "clients": { diff --git a/src/index.ts b/src/index.ts index 0be2ff77..3ce51da4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import {addAsync, Router} from '@awaitjs/express'; import express from 'express'; import bodyParser from 'body-parser'; -import winston from 'winston'; +import winston, {info} from 'winston'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; import isBetween from 'dayjs/plugin/isBetween.js'; @@ -10,6 +10,7 @@ import duration from 'dayjs/plugin/duration.js'; import passport from 'passport'; import session from 'express-session'; import {Writable} from 'stream'; +import {NullTransport} from 'winston-null'; import 'winston-daily-rotate-file'; import { buildTrackString, @@ -124,6 +125,9 @@ const loggerOptions: winston.LoggerOptions = { winston.loggers.add('default', loggerOptions); +winston.loggers.add('noop', {transports: [new NullTransport()]}); + + const logger = winston.loggers.get('default'); const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); diff --git a/src/sources/MopidySource.ts b/src/sources/MopidySource.ts new file mode 100644 index 00000000..bbc85635 --- /dev/null +++ b/src/sources/MopidySource.ts @@ -0,0 +1,202 @@ +import MemorySource from "./MemorySource.js"; +import {MopidySourceConfig} from "../common/infrastructure/config/source/mopidy.js"; +import {FormatPlayObjectOptions, InternalConfig, PlayObject} from "../common/infrastructure/Atomic.js"; +import dayjs from "dayjs"; +import Mopidy, {models} from "mopidy"; +import {URL} from "url"; +import normalizeUrl from 'normalize-url'; +import {EventEmitter} from "events"; +import pEvent from 'p-event'; +import winston from 'winston'; +import {RecentlyPlayedOptions} from "./AbstractSource.js"; +import request from "superagent"; +import {buildTrackString, removeDuplicates} from "../utils.js"; +import {SubsonicSource} from "./SubsonicSource.js"; + +export class MopidySource extends MemorySource { + declare config: MopidySourceConfig; + + albumBlacklist: string[] = []; + + uriWhitelist: string[] = []; + + uriBlacklist: string[] = []; + + url: URL; + + client: Mopidy; + clientReady: boolean = false; + + constructor(name: any, config: MopidySourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data = {} + } = config; + const { + albumBlacklist = ['Soundcloud'], + uriWhitelist = [], + uriBlacklist = [], + interval = 10, + maxInterval = 30, + ...rest + } = data; + super('mopidy', name, {...config, data: {interval, maxInterval, ...rest}}, internal, emitter); + + this.albumBlacklist = albumBlacklist.map(x => x.toLocaleLowerCase()); + this.uriWhitelist = uriWhitelist.map(x => x.toLocaleLowerCase()); + this.uriBlacklist = uriBlacklist.map(x => x.toLocaleLowerCase()); + + const { + data: { + url = 'ws://localhost:6680/mopidy/ws/' + } = {} + } = config; + this.url = MopidySource.parseConnectionUrl(url); + this.client = new Mopidy({ + autoConnect: false, + webSocketUrl: this.url.toString(), + // @ts-ignore + console: winston.loggers.get('noop') + }); + this.client.on('state:offline', () => { + this.logger.verbose('Lost connection to server'); + this.clientReady = false; + }); + this.client.on('state:online', () => { + this.logger.verbose('Connected to server'); + this.clientReady = true; + }); + this.client.on('reconnecting', () => { + this.logger.verbose('Retrying connection to server...'); + }); + this.canPoll = true; + } + + static parseConnectionUrl(val: string) { + const normal = normalizeUrl(val, {removeTrailingSlash: false, normalizeProtocol: true}) + const url = new URL(normal); + + // default WS + if (url.protocol === 'http:') { + url.protocol = 'ws'; + } else if (url.protocol === 'https:') { + url.protocol = 'wss'; + } + + if (url.port === null || url.port === '') { + url.port = '6680'; + } + if (url.pathname === '/') { + url.pathname = '/mopidy/ws/'; + } else if (url.pathname === '/mopidy/ws') { + url.pathname = '/mopidy/ws/'; + } + return url; + } + + initialize = async () => { + const { + data: { + url + } = {} + } = this.config; + this.logger.debug(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${this.url.toString()}'`) + + this.client.connect(); + const res = await Promise.race([ + pEvent(this.client, 'state:online'), + pEvent(this.client, 'websocket:error'), + pEvent(this.client, 'websocket:close'), + ]); + if (res === undefined) { + this.logger.info('Connection OK'); + this.initialized = true; + return true; + } else { + this.logger.error(`Could not connect. Error => ${(res as Error).message}`); + this.client.close(); + return false; + } + } + + formatPlayObj(obj: models.Track, options: FormatPlayObjectOptions = {}): PlayObject { + const {newFromSource = true, trackProgressPosition = undefined} = options; + + const { + artists: artistsVal, + album: albumVal, + name, + uri, // like 'local:track...' 'soundcloud:song...' + length: lengthVal, + composers = [], + performers = [] + } = obj; + + let artists = artistsVal === null ? [] : artistsVal; + let album: models.Album = albumVal === null ? {} as models.Album : albumVal; + if (this.albumBlacklist.length > 0 && album.name !== undefined && this.albumBlacklist.some(x => album.name.toLocaleLowerCase().includes(x))) { + album = {} as models.Album; + } + const length = lengthVal === null ? undefined : lengthVal; + + const { + name: albumName, + artists: albumArtists = [] + } = album as models.Album; + + if ((artists.length === 0 || artists.every(x => x.name.toLocaleLowerCase().includes('various'))) && albumArtists.length > 0) { + artists = albumArtists; + } + if (artists.length === 0 && composers.length > 0) { + artists = composers; + } + if (artists.length === 0 && performers.length > 0) { + artists = performers; + } + + return { + data: { + track: name, + album: albumName, + artists: artists.length > 0 ? artists.map(x => x.name) : [], + duration: Math.round(length / 1000), + playDate: dayjs() + }, + meta: { + source: 'mopidy', + trackId: uri, + newFromSource, + trackProgressPosition: trackProgressPosition !== undefined ? Math.round(trackProgressPosition / 1000) : undefined, + deviceId: name, + } + } + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + if (!this.clientReady) { + this.logger.warn('Cannot actively poll since client is not connected.'); + return []; + } + + const currTrack = await this.client.playback.getCurrentTrack(); + const playback = await this.client.playback.getTimePosition(); + + let play: PlayObject | undefined = this.formatPlayObj(currTrack, {trackProgressPosition: playback}); + + if (this.uriWhitelist.length > 0) { + const match = this.uriWhitelist.find(x => currTrack.uri.includes(x)); + if (match === undefined) { + this.logger.debug(`URI for currently playing (${currTrack.uri}) did not match any in whitelist. Will not track play ${buildTrackString(play)}`); + play = undefined; + } + } else if (this.uriBlacklist.length > 0) { + const match = this.uriWhitelist.find(x => currTrack.uri.includes(x)); + if (match !== undefined) { + this.logger.debug(`URI for currently playing (${currTrack.uri}) matched from blacklist (${match}). Will not track play ${buildTrackString(play)}`); + play = undefined; + } + } + + return this.processRecentPlays(play === undefined ? [] : [play]); + } + +} diff --git a/src/sources/ScrobbleSources.ts b/src/sources/ScrobbleSources.ts index f1904587..a0bcffb6 100644 --- a/src/sources/ScrobbleSources.ts +++ b/src/sources/ScrobbleSources.ts @@ -27,6 +27,8 @@ import {YTMusicSourceConfig} from "../common/infrastructure/config/source/ytmusi import {MPRISData, MPRISSourceConfig} from "../common/infrastructure/config/source/mpris.js"; import {MPRISSource} from "./MPRISSource.js"; import EventEmitter from "events"; +import {MopidySource} from "./MopidySource.js"; +import {MopidySourceConfig} from "../common/infrastructure/config/source/mopidy.js"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -396,6 +398,9 @@ export default class ScrobbleSources { case 'mpris': newSource = await new MPRISSource(name, compositeConfig as MPRISSourceConfig, internal, this.emitter); break; + case 'mopidy': + newSource = await new MopidySource(name, compositeConfig as MopidySourceConfig, internal, this.emitter); + break; default: break; } diff --git a/tsconfig.json b/tsconfig.json index c720e5b4..a7f95c2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "resolveJsonModule": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, - "lib": ["es2020"], + "lib": ["es2020", "dom"], "module": "commonjs", "target": "es2020", "esModuleInterop": true, -- 2.51.2 From 4d77ca9c110ef02f6c9d761446e48b8e08933a64 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 8 Mar 2023 15:18:24 -0500 Subject: [PATCH 22/41] Add missing mopidy example --- config/mopidy.json.example | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 config/mopidy.json.example diff --git a/config/mopidy.json.example b/config/mopidy.json.example new file mode 100644 index 00000000..58c6ff03 --- /dev/null +++ b/config/mopidy.json.example @@ -0,0 +1,11 @@ +[ + { + "name": "MyMopidy", + "data": { + "url": "localhost", + "uriBlacklist": [], + "uriWhitelist": [], + "albumBlacklist": [] + } + } +] -- 2.51.2 From 93b543e1a43ff47b2bbaec224391d149c8e8db6e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 8 Mar 2023 15:21:10 -0500 Subject: [PATCH 23/41] Fix mopidy json schema url --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 133ed767..b98198f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -356,7 +356,7 @@ multi-scrobbler can scrobble tracks played from any Mopidy backend source, regar ### File-Based -See [`mopidy.json.example`](/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMopidySourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) +See [`mopidy.json.example`](/config/mopidy.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMopidySourceConfig/%23%2Fdefinitions%2FMopidyData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json) Configuration Options: -- 2.51.2 From c5190fc2a6b5830e8b132d670460b6f06a63fb91 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 9 Mar 2023 13:02:46 -0500 Subject: [PATCH 24/41] feat: Improve logging * use child loggers instead of creating new loggers for each part of the app * cleans up and automates nested labels in log output * fixes "event emitters may be leaky" node warning * add ability to specify log levels per output type (console, ui, file) using aioconfig * improve UI logging experience * use more color-blind friendly colors (borrowed from context-mod) * add verbose log level output * increase log buffer and store all logs, then filter at render time, instead of discarding based on min log level so that we always have logs to display * fix level selection in ui to work for all levels --- package-lock.json | 33 ++- package.json | 2 +- src/apis/AbstractApiClient.ts | 5 +- src/clients/AbstractScrobbleClient.ts | 8 +- src/clients/LastfmScrobbler.ts | 5 +- src/clients/MalojaScrobbler.ts | 5 +- src/clients/ScrobbleClients.ts | 23 +- src/common/infrastructure/Atomic.ts | 55 +++++ src/common/infrastructure/config/aioConfig.ts | 3 + src/common/logging.ts | 210 ++++++++++++++++++ src/common/schema/aio.json | 42 ++++ src/index.ts | 101 ++++++--- src/ioc.ts | 36 +-- src/notifier/AbstractWebhookNotifier.ts | 6 +- src/notifier/GotifyWebhookNotifier.ts | 5 +- src/notifier/Notifiers.ts | 10 +- src/notifier/NtfyWebhookNotifier.ts | 5 +- src/sources/AbstractSource.ts | 5 +- src/sources/PlexSource.ts | 10 +- src/sources/ScrobbleSources.ts | 23 +- .../ingressNotifiers/IngressNotifier.ts | 7 +- src/utils.ts | 100 ++++----- src/views/status.ejs | 27 ++- 23 files changed, 536 insertions(+), 190 deletions(-) create mode 100644 src/common/logging.ts diff --git a/package-lock.json b/package-lock.json index 6f25ac4f..57f6616b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "spotify-web-api-node": "^5.0.2", "superagent": "^8.0.9", "triple-beam": "^1.3.0", - "winston": "^3.3.3", + "winston": "github:FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e", "winston-daily-rotate-file": "^4.5.0", "winston-duplex": "^0.1.1", "winston-null": "^2.0.0", @@ -2112,6 +2112,11 @@ "node": ">=8.10.0" } }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, "node_modules/logform": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/logform/-/logform-2.5.1.tgz", @@ -3632,24 +3637,24 @@ } }, "node_modules/winston": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.8.2.tgz", - "integrity": "sha512-MsE1gRx1m5jdTTO9Ld/vND4krP2To+lgDoMEHGGa4HIlAUyXJtfc7CxQcGXVyz2IBpw5hbFkj2b/AtUdQwyRew==", + "version": "3.3.3", + "resolved": "git+ssh://git@github.com/FoxxMD/winston.git#fbab8de969ecee578981c77846156c7f43b5f01e", + "integrity": "sha512-OnunfctuocKBmG0uzkBkVYGSW13VYmzglQPwj4ZFOBEtV2e2ECqe65eCaYRYHSvKynGc8T5hiaEWAVR2hVj+Yg==", + "license": "MIT", "dependencies": { - "@colors/colors": "1.5.0", "@dabh/diagnostics": "^2.0.2", - "async": "^3.2.3", + "async": "^3.1.0", "is-stream": "^2.0.0", - "logform": "^2.4.0", + "lodash.mergewith": "^4.6.2", + "logform": "^2.2.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", - "winston-transport": "^4.5.0" + "winston-transport": "^4.4.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 6.4.0" } }, "node_modules/winston-compat": { @@ -3754,14 +3759,6 @@ "node": ">= 6.4.0" } }, - "node_modules/winston/node_modules/safe-stable-stringify": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.2.tgz", - "integrity": "sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==", - "engines": { - "node": ">=10" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index fc28a138..34f3954c 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "spotify-web-api-node": "^5.0.2", "superagent": "^8.0.9", "triple-beam": "^1.3.0", - "winston": "^3.3.3", + "winston": "github:FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e", "winston-daily-rotate-file": "^4.5.0", "winston-duplex": "^0.1.1", "winston-null": "^2.0.0", diff --git a/src/apis/AbstractApiClient.ts b/src/apis/AbstractApiClient.ts index 8d1fadcb..2fdae713 100644 --- a/src/apis/AbstractApiClient.ts +++ b/src/apis/AbstractApiClient.ts @@ -1,6 +1,7 @@ -import {capitalize, createLabelledLogger} from "../utils.js"; +import {capitalize, mergeArr} from "../utils.js"; import {Logger} from "winston"; import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js"; +import winston from 'winston'; export default abstract class AbstractApiClient { name: string; @@ -19,7 +20,7 @@ export default abstract class AbstractApiClient { this.type = type; this.name = name; const identifier = `API - ${capitalize(this.type)} - ${name}`; - this.logger = createLabelledLogger(identifier, identifier); + this.logger = winston.loggers.get('app').child({labels: identifier}, mergeArr); this.config = config; this.options = options; } diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index d1bf98ec..7348aefb 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -2,7 +2,7 @@ import dayjs, {Dayjs} from "dayjs"; import { buildTrackString, capitalize, closePlayDate, - createLabelledLogger, + mergeArr, playObjDataMatch, setIntersection, truncateStringToLength } from "../utils.js"; @@ -14,7 +14,7 @@ import { NOT_INITIALIZED, PlayObject, ScrobbledPlayObject, TrackStringOptions } from "../common/infrastructure/Atomic.js"; -import {Logger} from "winston"; +import winston, {Logger} from "winston"; import {CommonClientConfig} from "../common/infrastructure/config/client/index.js"; import {ClientConfig} from "../common/infrastructure/config/client/clients.js"; import {Notifiers} from "../notifier/Notifiers.js"; @@ -46,11 +46,11 @@ export default abstract class AbstractScrobbleClient { notifier: Notifiers; - constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers) { + constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, logger: Logger) { this.type = type; this.name = name; const identifier = `Client ${capitalize(this.type)} - ${name}`; - this.logger = createLabelledLogger(identifier, identifier); + this.logger = logger.child({labels: [identifier]}, mergeArr); this.notifier = notifier; const { diff --git a/src/clients/LastfmScrobbler.ts b/src/clients/LastfmScrobbler.ts index b15d9c7e..9d9040ad 100644 --- a/src/clients/LastfmScrobbler.ts +++ b/src/clients/LastfmScrobbler.ts @@ -18,6 +18,7 @@ import { import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; import {TrackScrobbleResponse, UserGetRecentTracksResponse} from "lastfm-node-client"; import {Notifiers} from "../notifier/Notifiers.js"; +import {Logger} from "winston"; export default class LastfmScrobbler extends AbstractScrobbleClient { @@ -27,8 +28,8 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { declare config: LastfmClientConfig; - constructor(name: any, config: LastfmClientConfig, options = {}, notifier: Notifiers) { - super('lastfm', name, config, notifier); + constructor(name: any, config: LastfmClientConfig, options = {}, notifier: Notifiers, logger: Logger) { + super('lastfm', name, config, notifier, logger); // @ts-ignore this.api = new LastfmApiClient(name, config.data, options) } diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index 871c4030..e66b857e 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -25,6 +25,7 @@ import { } from "../common/infrastructure/Atomic.js"; import {MalojaClientConfig} from "../common/infrastructure/config/client/maloja.js"; import {Notifiers} from "../notifier/Notifiers.js"; +import {Logger} from "winston"; const feat = ["ft.", "ft", "feat.", "feat", "featuring", "Ft.", "Ft", "Feat.", "Feat", "Featuring"]; @@ -36,8 +37,8 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { declare config: MalojaClientConfig - constructor(name: any, config: MalojaClientConfig, notifier: Notifiers) { - super('maloja', name, config, notifier); + constructor(name: any, config: MalojaClientConfig, notifier: Notifiers, logger: Logger) { + super('maloja', name, config, notifier, logger); const {url, apiKey} = config.data; if (apiKey === undefined) { this.logger.warn("'apiKey' not found in config! Client will most likely fail when trying to scrobble"); diff --git a/src/clients/ScrobbleClients.ts b/src/clients/ScrobbleClients.ts index 825ca77e..f3c985e0 100644 --- a/src/clients/ScrobbleClients.ts +++ b/src/clients/ScrobbleClients.ts @@ -2,7 +2,7 @@ import dayjs, {Dayjs} from "dayjs"; import { buildTrackString, createAjvFactory, - createLabelledLogger, + mergeArr, playObjDataMatch, readJson, returnDuplicateStrings, validateJson @@ -19,6 +19,7 @@ import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm. import {Notifiers} from "../notifier/Notifiers.js"; import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; import {EventEmitter} from "events"; +import winston from "winston"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -39,7 +40,7 @@ export default class ScrobbleClients { this.emitter = emitter; this.sourceEmitter = sourceEmitter; this.configDir = configDir; - this.logger = createLabelledLogger('scrobblers', 'Scrobblers'); + this.logger = winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr); this.sourceEmitter.on('scrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => { await this.scrobble(payload.data, payload.options); @@ -273,13 +274,13 @@ ${sources.join('\n')}`); // add defaults const data = {...defaults, ...d}; let newClient; - this.logger.debug(`(${name}) Constructing ${type} client...`); + this.logger.debug(`Constructing ${type} (${name}) client...`); switch (type) { case 'maloja': - newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier); + newClient = new MalojaScrobbler(name, ({...clientConfig, data} as unknown as MalojaClientConfig), notifier, this.logger); break; case 'lastfm': - newClient = new LastfmScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as LastfmClientConfig, {}, notifier); + newClient = new LastfmScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as LastfmClientConfig, {}, notifier, this.logger); break; default: break; @@ -290,15 +291,15 @@ ${sources.join('\n')}`); throw new Error(`Client of type ${type} was not recognized??`); } if(newClient.initialized === false) { - this.logger.debug(`(${name}) Attempting ${type} initialization...`); + this.logger.debug(`Attempting ${type} (${name}) initialization...`); if ((await newClient.initialize()) === false) { - this.logger.error(`(${name}) ${type} client failed to initialize. Client needs to be successfully initialized before scrobbling.`); + this.logger.error(`${type} (${name}) client failed to initialize. Client needs to be successfully initialized before scrobbling.`); } else { - this.logger.info(`(${name}) ${type} client initialized`); + this.logger.info(`${type} (${name}) client initialized`); } } if(newClient.requiresAuth && !newClient.authed) { - this.logger.debug(`(${name}) Checking ${type} client auth...`); + this.logger.debug(`Checking ${type} (${name}) client auth...`); let success; try { success = await newClient.testAuth(); @@ -306,9 +307,9 @@ ${sources.join('\n')}`); success = false; } if(!success) { - this.logger.warn(`(${name}) ${type} client auth failed.`); + this.logger.warn(`${type} (${name}) client auth failed.`); } else { - this.logger.info(`(${name}) ${type} client auth OK`); + this.logger.info(`${type} (${name}) client auth OK`); } } this.clients.push(newClient); diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 6a775b0e..26561f09 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -1,5 +1,7 @@ import {Dayjs} from "dayjs"; import {FixedSizeList} from 'fixed-size-list'; +import {MESSAGE} from 'triple-beam'; +import {Logger} from "winston"; export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy'; export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy']; @@ -24,6 +26,8 @@ export const readyStates: ReadyState[] = [NOT_READY, GETTING_READY, READY]; export interface InternalConfig { localUrl: string configDir: string + + logger: Logger } export interface ConfigMeta { @@ -180,3 +184,54 @@ export interface RemoteIdentityParts { proxy: string | undefined, agent: string | undefined } + +export type LogLevel = "error" | "warn" | "info" | "verbose" | "debug"; +export const logLevels = ['error', 'warn', 'info', 'verbose', 'debug']; + +export interface LogConfig { + level?: string + file?: string | false + stream?: string + console?: string +} + +export interface LogOptions { + /** + * Specify the minimum log level for all log outputs without their own level specified. + * + * Defaults to env `LOG_LEVEL` or `info` if not specified. + * + * @default 'info' + * */ + level?: LogLevel + /** + * Specify the minimum log level to output to rotating files. If `false` no log files will be created. + * */ + file?: LogLevel | false + /** + * Specify the minimum log level streamed to the UI + * */ + stream?: LogLevel + /** + * Specify the minimum log level streamed to the console (or docker container) + * */ + console?: LogLevel +} + +export const asLogOptions = (obj: LogConfig = {}): obj is LogOptions => { + return Object.entries(obj).every(([key, val]) => { + if(key !== 'file') { + return val === undefined || logLevels.includes(val.toLocaleLowerCase()); + } + return val === undefined || val === false || logLevels.includes(val.toLocaleLowerCase()); + }); +} + +export interface LogInfo { + message: string + [MESSAGE]: string, + level: string + timestamp: string + labels?: string[] + transport?: string[] +} diff --git a/src/common/infrastructure/config/aioConfig.ts b/src/common/infrastructure/config/aioConfig.ts index f95cb8d7..38b0ffa5 100644 --- a/src/common/infrastructure/config/aioConfig.ts +++ b/src/common/infrastructure/config/aioConfig.ts @@ -3,6 +3,7 @@ import {RequestRetryOptions} from "./common.js"; import {SourceAIOConfig} from "./source/sources.js"; import {ClientAIOConfig} from "./client/clients.js"; import {WebhookConfig} from "./health/webhooks.js"; +import {LogOptions} from "../Atomic.js"; export interface AIOConfig { sourceDefaults?: SourceRetryOptions @@ -11,6 +12,8 @@ export interface AIOConfig { clients?: ClientAIOConfig[] webhooks?: WebhookConfig[] + + logging?: LogOptions } export interface AIOClientConfig { diff --git a/src/common/logging.ts b/src/common/logging.ts new file mode 100644 index 00000000..f732c121 --- /dev/null +++ b/src/common/logging.ts @@ -0,0 +1,210 @@ +import path from "path"; +import {projectDir} from "./index.js"; +import winston, {format, Logger} from "winston"; +import {DuplexTransport} from "winston-duplex"; +import {asLogOptions, LogConfig, LogInfo, LogLevel, LogOptions} from "./infrastructure/Atomic.js"; +import process from "process"; +import {fileOrDirectoryIsWriteable} from "../utils.js"; +import {ErrorWithCause} from "pony-cause"; +import {NullTransport} from 'winston-null'; +import 'winston-daily-rotate-file'; +import dayjs from "dayjs"; + +const {combine, printf, timestamp, label, splat, errors} = format; + + +const {transports} = winston; + +export let logPath = path.resolve(projectDir, `./logs`); +if (typeof process.env.CONFIG_DIR === 'string') { + logPath = path.resolve(process.env.CONFIG_DIR, './logs'); +} + +winston.loggers.add('noop', {transports: [new NullTransport()]}); + +export const getLogger = (config: LogConfig = {}, name = 'app'): Logger => { + + if (!winston.loggers.has(name)) { + const errors: (Error | string)[] = []; + + let options: LogOptions = {}; + if (asLogOptions(config)) { + options = config; + } else { + errors.push(`Logging levels were not valid. Must be one of: 'error', 'warn', 'info', 'verbose', 'debug' -- 'file' may be false.`); + } + + const {level: configLevel} = options; + const defaultLevel = process.env.LOG_LEVEL || 'info'; + const { + level = configLevel || defaultLevel, + file = configLevel || defaultLevel, + stream = configLevel || 'debug', + console = configLevel || 'debug' + } = options; + + const consoleTransport = new transports.Console({level: console}); + + const myTransports = [ + consoleTransport, + new DuplexTransport({ + stream: { + transform(chunk, e, cb) { + cb(null, chunk); + }, + objectMode: true, + }, + name: 'duplex', + handleExceptions: true, + handleRejections: true, + level: stream, + dump: false, + }), + ]; + + if (file !== false) { + const rotateTransport = new winston.transports.DailyRotateFile({ + dirname: logPath, + createSymlink: true, + symlinkName: 'scrobble-current.log', + filename: 'scrobble-%DATE%.log', + datePattern: 'YYYY-MM-DD', + maxSize: '5m', + level: file + }); + + try { + fileOrDirectoryIsWriteable(logPath); + // @ts-ignore + myTransports.push(rotateTransport); + } catch (e: any) { + let msg = 'WILL NOT write logs to rotating file due to an error while trying to access the specified logging directory'; + errors.push(new ErrorWithCause(msg, {cause: e})); + } + } + + const loggerOptions: winston.LoggerOptions = { + level: level, + format: labelledFormat(), + transports: myTransports, + }; + + winston.loggers.add(name, loggerOptions); + + const logger = winston.loggers.get(name); + if (errors.length > 0) { + for (const e of errors) { + logger.error(e); + } + } + return logger; + } + return winston.loggers.get(name); +} + +const breakSymbol = '
    '; +export const formatLogToHtml = (chunk: any) => { + const line = chunk.toString().replace('\n', breakSymbol) + .replace(/(debug)\s/gi, '$1 ') + .replace(/(warn)\s/gi, '$1 ') + .replace(/(info)\s/gi, '$1 ') + .replace(/(verbose)\s/gi, '$1 ') + .replace(/(error)\s/gi, '$1 ') + .trim(); + if(line.slice(-6) !== breakSymbol) { + return `${line}${breakSymbol}`; + } + return line; +} + +const s = splat(); +const SPLAT = Symbol.for('splat') +const errorsFormat = errors({stack: true}); +const CWD = process.cwd(); + +let longestLabel = 3; +export const defaultFormat = (defaultLabel = 'App') => printf(({ + level, + message, + labels = [defaultLabel], + leaf, + timestamp, + durationMs, + // @ts-ignore + [SPLAT]: splatObj, + stack, + ...rest + }) => { + let stringifyValue = splatObj !== undefined ? JSON.stringify(splatObj) : ''; + let msg = message; + let stackMsg = ''; + if (stack !== undefined) { + const stackArr = stack.split('\n'); + const stackTop = stackArr[0]; + const cleanedStack = stackArr + .slice(1) // don't need actual error message since we are showing it as msg + .map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy + .join('\n'); // rejoin with newline to preserve formatting + stackMsg = `\n${cleanedStack}`; + if (msg === undefined || msg === null || typeof message === 'object') { + msg = stackTop; + } else { + stackMsg = `\n${stackTop}${stackMsg}` + } + } + + let nodes = Array.isArray(labels) ? labels : [labels]; + if (leaf !== null && leaf !== undefined && !nodes.includes(leaf)) { + nodes.push(leaf); + } + const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`; + + return `${timestamp} ${level.padEnd(8)}: ${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`; +}); + +export const labelledFormat = (labelName = 'App') => { + const l = label({label: labelName, message: false}); + return combine( + timestamp( + { + format: () => dayjs().local().format(), + } + ), + l, + s, + errorsFormat, + defaultFormat(labelName), + ); +} + +export const logLevels = { + error: 0, + warn: 1, + info: 2, + http: 3, + verbose: 4, + debug: 5, + trace: 5, + silly: 6 +}; + +export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i +export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel): boolean => { + // @ts-ignore + const minLevel = logLevels[minLevelText]; + let level: number; + + if(typeof log === 'string') { + const lineLevelMatch = log.match(LOG_LEVEL_REGEX) + if (lineLevelMatch === null) { + return false; + } + // @ts-ignore + level = logLevels[lineLevelMatch[1]]; + } else { + const lineLevelMatch = log.level; + // @ts-ignore + level = logLevels[lineLevelMatch]; + } + return level <= minLevel; +} diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index 943702ac..baa04149 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -529,6 +529,45 @@ ], "type": "object" }, + "LogLevel": { + "enum": [ + "debug", + "error", + "info", + "verbose", + "warn" + ], + "type": "string" + }, + "LogOptions": { + "properties": { + "console": { + "$ref": "#/definitions/LogLevel", + "description": "Specify the minimum log level streamed to the console (or docker container)" + }, + "file": { + "description": "Specify the minimum log level to output to rotating files. If `false` no log files will be created.", + "enum": [ + "debug", + "error", + false, + "info", + "verbose", + "warn" + ] + }, + "level": { + "$ref": "#/definitions/LogLevel", + "default": "'info'", + "description": "Specify the minimum log level for all log outputs without their own level specified.\n\nDefaults to env `LOG_LEVEL` or `info` if not specified." + }, + "stream": { + "$ref": "#/definitions/LogLevel", + "description": "Specify the minimum log level streamed to the UI" + } + }, + "type": "object" + }, "MPRISData": { "properties": { "blacklist": { @@ -1541,6 +1580,9 @@ }, "type": "array" }, + "logging": { + "$ref": "#/definitions/LogOptions" + }, "sourceDefaults": { "$ref": "#/definitions/SourceRetryOptions" }, diff --git a/src/index.ts b/src/index.ts index 3ce51da4..c38cc76d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import {addAsync, Router} from '@awaitjs/express'; import express from 'express'; import bodyParser from 'body-parser'; -import winston, {info} from 'winston'; +import winston, {info, Logger} from 'winston'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; import isBetween from 'dayjs/plugin/isBetween.js'; @@ -10,36 +10,37 @@ import duration from 'dayjs/plugin/duration.js'; import passport from 'passport'; import session from 'express-session'; import {Writable} from 'stream'; -import {NullTransport} from 'winston-null'; -import 'winston-daily-rotate-file'; +//import {NullTransport} from 'winston-null'; +//import 'winston-daily-rotate-file'; import { buildTrackString, - capitalize, createLabelledLogger, - labelledFormat, - longestString, - readJson, remoteHostIdentifiers, sleep, + capitalize, + longestString, mergeArr, + readJson, + remoteHostIdentifiers, + sleep, truncateStringToLength } from "./utils.js"; - -import ScrobbleSources from "./sources/ScrobbleSources.js"; import {makeClientCheckMiddle, makeSourceCheckMiddle} from "./server/middleware.js"; import TautulliSource from "./sources/TautulliSource.js"; import PlexSource, {plexRequestMiddle} from "./sources/PlexSource.js"; import JellyfinSource from "./sources/JellyfinSource.js"; -import { Server } from "socket.io"; +import {Server} from "socket.io"; import * as path from "path"; import {projectDir} from "./common/index.js"; import LastfmSource from "./sources/LastfmSource.js"; import LastfmScrobbler from "./clients/LastfmScrobbler.js"; import DeezerSource from "./sources/DeezerSource.js"; import AbstractSource from "./sources/AbstractSource.js"; -import {PlayObject, TrackStringOptions} from "./common/infrastructure/Atomic.js"; +import {LogInfo, LogLevel, PlayObject, TrackStringOptions} from "./common/infrastructure/Atomic.js"; import SpotifySource from "./sources/SpotifySource.js"; import {JellyfinNotifier} from "./sources/ingressNotifiers/JellyfinNotifier.js"; import {PlexNotifier} from "./sources/ingressNotifiers/PlexNotifier.js"; import {TautulliNotifier} from "./sources/ingressNotifiers/TautulliNotifier.js"; import {AIOConfig} from "./common/infrastructure/config/aioConfig.js"; -import root from "./ioc.js"; +import createRoot from "./ioc.js"; +import {formatLogToHtml, getLogger, isLogLineMinLevel} from "./common/logging.js"; +import {MESSAGE} from "triple-beam"; dayjs.extend(utc) @@ -66,8 +67,8 @@ app.use(passport.session()); const {transports} = winston; -let output: any = [] -const stream = new Writable() +let output: LogInfo[] = [] +/*const stream = new Writable() stream._write = (chunk, encoding, next) => { let formatString = chunk.toString().replace('\n', '
    ') .replace(/(debug)\s/gi, '$1 ') @@ -78,19 +79,13 @@ stream._write = (chunk, encoding, next) => { output = output.slice(0, 101); io.emit('log', formatString); next(); -} -const streamTransport = new winston.transports.Stream({ +}*/ +/*const streamTransport = new winston.transports.Stream({ stream, -}) +})*/ -const logConfig = { - level: process.env.LOG_LEVEL || 'info', - sort: 'descending', - limit: 50, -} -const availableLevels = ['info', 'debug']; -let logPath = path.resolve(projectDir, `./logs`); +/*let logPath = path.resolve(projectDir, `./logs`); if(typeof process.env.CONFIG_DIR === 'string') { logPath = path.resolve(process.env.CONFIG_DIR, './logs'); } @@ -125,30 +120,62 @@ const loggerOptions: winston.LoggerOptions = { winston.loggers.add('default', loggerOptions); -winston.loggers.add('noop', {transports: [new NullTransport()]}); +winston.loggers.add('noop', {transports: [new NullTransport()]});*/ -const logger = winston.loggers.get('default'); +const initLogger = getLogger({}, 'init'); +initLogger.stream().on('log', (log: LogInfo) => { + output.unshift(log); + output = output.slice(0, 301); + io.emit('log', formatLogToHtml(log[MESSAGE])); +}); + +let logger: Logger; // = winston.loggers.get('default'); const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); try { // try to read a configuration file + let appConfigFail = false; let config = {}; try { config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false}); } catch (e) { - logger.warn('App config file exists but could not be parsed!'); + appConfigFail = true; } - const notifiers = root.get('notifiers'); const { - webhooks = [] + webhooks = [], + logging = {}, } = (config || {}) as AIOConfig; + const logConfig: {level: LogLevel, sort: string, limit: number} = { + level: logging.level || (process.env.LOG_LEVEL || 'info') as LogLevel, + sort: 'descending', + limit: 50, + } + + logger = getLogger(logging, 'app'); + logger.stream().on('log', (log: LogInfo) => { + output.unshift(log); + output = output.slice(0, 301); + if(isLogLineMinLevel(log, logConfig.level)) { + io.emit('log', formatLogToHtml(log[MESSAGE])); + } + }); + + if(appConfigFail) { + logger.warn('App config file exists but could not be parsed!'); + } + const root = createRoot(); + const localUrl = root.get('localUrl'); + + const notifiers = root.get('notifiers'); await notifiers.buildWebhooks(webhooks); + const availableLevels = ['error', 'warn', 'info', 'verbose', 'debug']; + /* * setup clients @@ -182,7 +209,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) } app.getAsync('/', async function (req, res) { - let slicedLog = output.slice(0, logConfig.limit + 1); + let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE])); if (logConfig.sort === 'ascending') { slicedLog.reverse(); } @@ -255,7 +282,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) output: slicedLog, limit: [10, 20, 50, 100].map(x => `${x}`).join(' | '), sort: ['ascending', 'descending'].map(x => `${x}`).join(' | '), - level: availableLevels.map(x => `${x}`).join(' | ') + level: availableLevels.map(x => `${x}`).join(' | ') } }); }) @@ -293,7 +320,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) }); const plexMiddle = plexRequestMiddle(); - const plexLog = createLabelledLogger('plexReq', 'Plex Request'); + const plexLog = logger.child({labels: ['Plex Request']}, mergeArr); const plexIngress = new PlexNotifier(); app.postAsync('/plex', async function (req, res, next) { @@ -469,14 +496,14 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) logConfig.sort = val as string; break; case 'level': - logConfig.level = val as string; - for (const [key, logger] of winston.loggers.loggers) { - logger.level = val as string; - } + logConfig.level = val as LogLevel; + // for (const [key, logger] of winston.loggers.loggers) { + // logger.level = val as string; + // } break; } } - let slicedLog = output.slice(0, logConfig.limit + 1); + let slicedLog = output.filter(x => isLogLineMinLevel(x, logConfig.level)).slice(0, logConfig.limit + 1).map(x => formatLogToHtml(x[MESSAGE])); if (logConfig.sort === 'ascending') { slicedLog.reverse(); } diff --git a/src/ioc.ts b/src/ioc.ts index 7eeabc7c..480f5d36 100644 --- a/src/ioc.ts +++ b/src/ioc.ts @@ -5,27 +5,35 @@ import ScrobbleClients from "./clients/ScrobbleClients.js"; import ScrobbleSources from "./sources/ScrobbleSources.js"; import {Notifiers} from "./notifier/Notifiers.js"; import {EventEmitter} from "events"; +import {logPath} from "./common/logging.js"; +import {Container} from "winston"; //import ScrobbleClients from "./clients/ScrobbleClients.js"; const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); const port = process.env.PORT ?? 9078; -let logPath = path.resolve(projectDir, `./logs`); +/*let logPath = path.resolve(projectDir, `./logs`); if(typeof process.env.CONFIG_DIR === 'string') { logPath = path.resolve(process.env.CONFIG_DIR, './logs'); +}*/ + +//let root: Container; + +export const createRoot = () => { + return createContainer().add({ + configDir: configDir, + logDir: logPath, + localUrl: `http://localhost:${port}`, + clientEmitter: () => new EventEmitter(), + sourceEmitter: () => new EventEmitter(), + notifierEmitter: () => new EventEmitter(), + }).add((items) => ({ + clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir), + sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir), + notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter), + })); } -const root = createContainer().add({ - configDir: configDir, - logDir: logPath, - localUrl: `http://localhost:${port}`, - clientEmitter: () => new EventEmitter(), - sourceEmitter: () => new EventEmitter(), - notifierEmitter: () => new EventEmitter(), -}).add((items) => ({ - clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, items.configDir), - sources: () => new ScrobbleSources(items.sourceEmitter, items.localUrl, items.configDir), - notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter), -})); -export default root; + +export default createRoot; diff --git a/src/notifier/AbstractWebhookNotifier.ts b/src/notifier/AbstractWebhookNotifier.ts index ff70afc2..df5ace71 100644 --- a/src/notifier/AbstractWebhookNotifier.ts +++ b/src/notifier/AbstractWebhookNotifier.ts @@ -1,6 +1,6 @@ import {GotifyConfig, NtfyConfig, WebhookPayload} from "../common/infrastructure/config/health/webhooks.js"; import {Logger} from "winston"; -import {createLabelledLogger} from "../utils.js"; +import {mergeArr} from "../utils.js"; export abstract class AbstractWebhookNotifier { @@ -11,10 +11,10 @@ export abstract class AbstractWebhookNotifier { requiresAuth: boolean = false; authed: boolean = false; - protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig) { + protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig, logger: Logger) { this.config = config; const label = `${type} - ${config.name ?? defaultName}` - this.logger = createLabelledLogger(label, label); + this.logger = logger.child({labels: [label]}, mergeArr); } initialize = async () => { diff --git a/src/notifier/GotifyWebhookNotifier.ts b/src/notifier/GotifyWebhookNotifier.ts index 9a3fcbe1..634c9f0a 100644 --- a/src/notifier/GotifyWebhookNotifier.ts +++ b/src/notifier/GotifyWebhookNotifier.ts @@ -3,6 +3,7 @@ import {GotifyConfig, PrioritiesConfig, WebhookPayload} from "../common/infrastr import {gotify} from 'gotify'; import request from 'superagent'; import {HTTPError} from "got"; +import {Logger} from "winston"; export class GotifyWebhookNotifier extends AbstractWebhookNotifier { @@ -10,8 +11,8 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier { priorities: PrioritiesConfig; - constructor(defaultName: string, config: GotifyConfig) { - super('Gotify', defaultName, config); + constructor(defaultName: string, config: GotifyConfig, logger: Logger) { + super('Gotify', defaultName, config, logger); this.requiresAuth = true; const { info = 5, diff --git a/src/notifier/Notifiers.ts b/src/notifier/Notifiers.ts index 287725a6..c5608e13 100644 --- a/src/notifier/Notifiers.ts +++ b/src/notifier/Notifiers.ts @@ -1,5 +1,5 @@ -import {config, format, Logger} from "winston"; -import {createLabelledLogger} from "../utils.js"; +import winston, {config, format, Logger} from "winston"; +import {mergeArr} from "../utils.js"; import { GotifyConfig, NtfyConfig, @@ -27,7 +27,7 @@ export class Notifiers { this.clientEmitter = clientEmitter; this.sourceEmitter = sourceEmitter; - this.logger = createLabelledLogger('Notifiers', 'Notifiers'); + this.logger = winston.loggers.get('app').child({labels: ['Notifiers']}, mergeArr); this.sourceEmitter.on('notify', async (payload: WebhookPayload) => { await this.notify(payload); @@ -40,10 +40,10 @@ export class Notifiers { const defaultName = `Config ${i}` switch (config.type) { case 'gotify': - webhook = new GotifyWebhookNotifier(defaultName, config as GotifyConfig); + webhook = new GotifyWebhookNotifier(defaultName, config as GotifyConfig, this.logger); break; case 'ntfy': - webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig); + webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig, this.logger); break; default: this.logger.error(`'${config.type}' is not a valid webhook type`); diff --git a/src/notifier/NtfyWebhookNotifier.ts b/src/notifier/NtfyWebhookNotifier.ts index 0df6194f..9c414f7b 100644 --- a/src/notifier/NtfyWebhookNotifier.ts +++ b/src/notifier/NtfyWebhookNotifier.ts @@ -6,6 +6,7 @@ import { } from "../common/infrastructure/config/health/webhooks.js"; import {publish} from 'ntfy'; import request from "superagent"; +import {Logger} from "winston"; export class NtfyWebhookNotifier extends AbstractWebhookNotifier { @@ -13,8 +14,8 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier { priorities: PrioritiesConfig; - constructor(defaultName: string, config: NtfyConfig) { - super('Ntfy', defaultName, config); + constructor(defaultName: string, config: NtfyConfig, logger: Logger) { + super('Ntfy', defaultName, config, logger); const { info = 3, warn = 4, diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index bc9ca16b..991959d6 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -2,8 +2,7 @@ import dayjs, {Dayjs} from "dayjs"; import { buildTrackString, capitalize, closePlayDate, - createLabelledLogger, - genGroupId, + genGroupId, mergeArr, playObjDataMatch, sleep, sortByNewestPlayDate, sortByOldestPlayDate @@ -63,7 +62,7 @@ export default abstract class AbstractSource { this.type = type; this.name = name; this.identifier = `Source - ${capitalize(this.type)} - ${name}`; - this.logger = createLabelledLogger(this.identifier, this.identifier); + this.logger = internal.logger.child({labels: [`${capitalize(this.type)} - ${name}`]}, mergeArr); this.config = config; this.clients = clients; this.instantiatedAt = dayjs(); diff --git a/src/sources/PlexSource.ts b/src/sources/PlexSource.ts index 4b2f8513..87a9165f 100644 --- a/src/sources/PlexSource.ts +++ b/src/sources/PlexSource.ts @@ -1,11 +1,17 @@ import dayjs from "dayjs"; -import {buildTrackString, combinePartsToString, createLabelledLogger, truncateStringToLength} from "../utils.js"; +import { + buildTrackString, + combinePartsToString, + mergeArr, + truncateStringToLength +} from "../utils.js"; import AbstractSource from "./AbstractSource.js"; import formidable from 'formidable'; import concatStream from 'concat-stream'; import {PlexSourceConfig} from "../common/infrastructure/config/source/plex.js"; import {FormatPlayObjectOptions, InternalConfig, PlayObject, SourceType} from "../common/infrastructure/Atomic.js"; import EventEmitter from "events"; +import winston from "winston"; const shortDeviceId = truncateStringToLength(10, ''); @@ -184,7 +190,7 @@ export default class PlexSource extends AbstractSource { export const plexRequestMiddle = () => { - const plexLog = createLabelledLogger('plexReq', 'Plex Request'); + const plexLog = winston.loggers.get('app').child({labels: ['Plex Request']}, mergeArr); return async (req: any, res: any, next: any) => { diff --git a/src/sources/ScrobbleSources.ts b/src/sources/ScrobbleSources.ts index a0bcffb6..0c842dd4 100644 --- a/src/sources/ScrobbleSources.ts +++ b/src/sources/ScrobbleSources.ts @@ -1,4 +1,4 @@ -import {createLabelledLogger, parseBool, readJson, validateJson} from "../utils.js"; +import {mergeArr, parseBool, readJson, validateJson} from "../utils.js"; import SpotifySource from "./SpotifySource.js"; import PlexSource from "./PlexSource.js"; import TautulliSource from "./TautulliSource.js"; @@ -8,7 +8,7 @@ import LastfmSource from "./LastfmSource.js"; import DeezerSource from "./DeezerSource.js"; import {ConfigMeta, InternalConfig, SourceType, sourceTypes} from "../common/infrastructure/Atomic.js"; import {configDir as defaultConfigDir} from "../common/index.js"; -import {Logger} from "winston"; +import winston, {Logger} from "winston"; import {SourceAIOConfig, SourceConfig} from "../common/infrastructure/config/source/sources.js"; import {DeezerData, DeezerSourceConfig} from "../common/infrastructure/config/source/deezer.js"; import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; @@ -47,7 +47,7 @@ export default class ScrobbleSources { this.emitter = emitter; this.configDir = configDir; this.localUrl = localUrl; - this.logger = createLabelledLogger('sources', 'Sources'); + this.logger = winston.loggers.get('app').child({labels: ['Sources']}, mergeArr); } getByName = (name: any) => { @@ -359,7 +359,8 @@ export default class ScrobbleSources { const internal: InternalConfig = { localUrl: this.localUrl, - configDir: this.configDir + configDir: this.configDir, + logger: this.logger }; const {type, name, data: d = {}} = clientConfig; @@ -410,19 +411,19 @@ export default class ScrobbleSources { throw new Error(`Source of type ${type} was not recognized??`); } if(newSource.initialized === false) { - this.logger.debug(`(${name}) Attempting ${type} initialization...`); + this.logger.debug(`Attempting ${type} (${name}) initialization...`); if ((await newSource.initialize()) === false) { - this.logger.error(`(${name}) ${type} source failed to initialize. Source needs to be successfully initialized before activity capture can begin.`); + this.logger.error(`${type} (${name}) source failed to initialize. Source needs to be successfully initialized before activity capture can begin.`); return; } else { - this.logger.info(`(${name}) ${type} source initialized`); + this.logger.info(`${type} (${name}) source initialized`); } } else { - this.logger.info(`(${name}) ${type} source initialized`); + this.logger.info(`${type} (${name}) source initialized`); } if(newSource.requiresAuth && !newSource.authed) { - this.logger.debug(`(${name}) Checking ${type} source auth...`); + this.logger.debug(`Checking ${type} (${name}) source auth...`); let success; try { success = await newSource.testAuth(); @@ -430,9 +431,9 @@ export default class ScrobbleSources { success = false; } if(!success) { - this.logger.warn(`(${name}) ${type} source auth failed.`); + this.logger.warn(`${type} (${name}) source auth failed.`); } else { - this.logger.info(`(${name}) ${type} source auth OK`); + this.logger.info(`${type} (${name}) source auth OK`); } } diff --git a/src/sources/ingressNotifiers/IngressNotifier.ts b/src/sources/ingressNotifiers/IngressNotifier.ts index b9652f36..db5ab7f3 100644 --- a/src/sources/ingressNotifiers/IngressNotifier.ts +++ b/src/sources/ingressNotifiers/IngressNotifier.ts @@ -1,5 +1,5 @@ -import {Logger} from "winston"; -import {createLabelledLogger, remoteHostIdentifiers, remoteHostStr} from "../../utils.js"; +import winston, {Logger} from "winston"; +import {mergeArr, remoteHostIdentifiers, remoteHostStr} from "../../utils.js"; import {Request} from "express"; import {RemoteIdentityParts} from "../../common/infrastructure/Atomic.js"; @@ -13,8 +13,7 @@ export abstract class IngressNotifier { protected constructor(name: string) { this.identifier = name; - const label = `Ingress - ${name}`; - this.logger = createLabelledLogger(label, label); + this.logger = winston.loggers.get('app').child({labels: ['Ingress', name]}, mergeArr); } public trackIngress(req: Request, isRaw: boolean) { diff --git a/src/utils.ts b/src/utils.ts index 951dae9a..fef3e924 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,7 @@ -import {promises, constants} from "fs"; +import {promises, constants, accessSync} from "fs"; import dayjs, {Dayjs} from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; -import winston, {Logger} from "winston"; -import stringify from 'safe-stable-stringify'; +import {Logger} from "winston"; import JSON5 from 'json5'; import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js"; import Ajv, {Schema} from 'ajv'; @@ -13,9 +12,8 @@ import { TrackStringOptions } from "./common/infrastructure/Atomic.js"; import {Request} from "express"; - -const {format} = winston; -const {combine, printf, timestamp, label, splat, errors} = format; +import pathUtil from "path"; +import {ErrorWithCause} from "pony-cause"; dayjs.extend(utc); @@ -182,60 +180,6 @@ export const sortByNewestPlayDate = (a: PlayObject, b: PlayObject) => { return aPlayDate.isBefore(bPlayDate) ? 1 : -1 }; -const s = splat(); -const SPLAT = Symbol.for('splat') -const errorsFormat = errors({stack: true}); -const CWD = process.cwd(); - -let longestLabel = 3; -export const defaultFormat = printf(({level, message, label = 'App', timestamp, [SPLAT]: splatObj, stack, ...rest}) => { - let stringifyValue = splatObj !== undefined ? stringify(splatObj) : ''; - if (label.length > longestLabel) { - longestLabel = label.length; - } - let msg = message; - let stackMsg = ''; - if (stack !== undefined) { - const stackArr = stack.split('\n'); - msg = stackArr[0]; - const cleanedStack = stackArr - .slice(1) // don't need actual error message since we are showing it as msg - .map((x: any) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy - .join('\n'); // rejoin with newline to preserve formatting - stackMsg = `\n${cleanedStack}`; - } - - return `${timestamp} ${level.padEnd(7)}: [${label.padEnd(longestLabel)}] ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`; -}); - -export const labelledFormat = (labelName = 'App') => { - const l = label({label: labelName, message: false}); - return combine( - timestamp( - { - format: () => dayjs().local().format(), - } - ), - l, - s, - errorsFormat, - defaultFormat, - ); -} - -export const createLabelledLogger = (name = 'default', label = 'App'): Logger => { - if (winston.loggers.has(name)) { - return winston.loggers.get(name); - } - const def = winston.loggers.get('default'); - winston.loggers.add(name, { - transports: def.transports, - level: def.level, - format: labelledFormat(label) - }); - return winston.loggers.get(name); -} - export const setIntersection = (setA: any, setB: any) => { let _intersection = new Set() for (let elem of setB) { @@ -638,3 +582,39 @@ export function parseBool(value: any, prev: any = false): boolean { } export const genGroupId = (play: PlayObject) => `${play.meta.deviceId ?? 'NoDevice'}-${play.meta.user ?? 'SingleUser'}`; + +export const fileOrDirectoryIsWriteable = (location: string) => { + const pathInfo = pathUtil.parse(location); + const isDir = pathInfo.ext === ''; + try { + accessSync(location, constants.R_OK | constants.W_OK); + return true; + } catch (err: any) { + const {code} = err; + if (code === 'ENOENT') { + // file doesn't exist, see if we can write to directory in which case we are good + try { + accessSync(pathInfo.dir, constants.R_OK | constants.W_OK) + // we can write to dir + return true; + } catch (accessError: any) { + if(accessError.code === 'EACCES') { + // also can't access directory :( + throw new Error(`No ${isDir ? 'directory' : 'file'} exists at ${location} and application does not have permission to write to the parent directory`); + } else { + throw new ErrorWithCause(`No ${isDir ? 'directory' : 'file'} exists at ${location} and application is unable to access the parent directory due to a system error`, {cause: accessError}); + } + } + } else if(code === 'EACCES') { + throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application does not have permission to write to it.`); + } else { + throw new ErrorWithCause(`${isDir ? 'Directory' : 'File'} exists at ${location} but application is unable to access it due to a system error`, {cause: err}); + } + } +} + +export const mergeArr = (objValue: [], srcValue: []): (any[] | undefined) => { + if (Array.isArray(objValue)) { + return objValue.concat(srcValue); + } +} diff --git a/src/views/status.ejs b/src/views/status.ejs index c99b83d3..3c99ef49 100644 --- a/src/views/status.ejs +++ b/src/views/status.ejs @@ -18,6 +18,21 @@ .dark .loading { fill: white; } + .blue { + color: rgb(97, 175, 239) + } + .red { + color: rgb(255 123 133); + } + .green { + color: rgb(170 255 109); + } + .purple { + color: rgb(224 124 253); + } + .yellow { + color: rgb(253 198 94); + } Multi Scrobbler @@ -154,13 +169,11 @@ e.preventDefault() fetch(e.target.href).then(req => { if (req.ok) { - if (e.target.dataset.log === 'info') { - document.querySelector('[data-log="debug"]').classList.remove('font-bold', 'no-underline', 'pointer-events-none') - document.querySelector('[data-log="info"]').classList.add('font-bold', 'no-underline', 'pointer-events-none') - } else { - document.querySelector('[data-log="info"]').classList.remove('font-bold', 'no-underline', 'pointer-events-none') - document.querySelector('[data-log="debug"]').classList.add('font-bold', 'no-underline', 'pointer-events-none') - } + document.querySelectorAll('.log-level').forEach(elm => { + elm.classList.remove('font-bold', 'no-underline', 'pointer-events-none'); + }) + //document.querySelector('.log-level').classList.remove('font-bold', 'no-underline', 'pointer-events-none'); + document.querySelector(`[data-log="${e.target.dataset.log}"]`).classList.add('font-bold', 'no-underline', 'pointer-events-none'); } }) }) -- 2.51.2 From a9a6c6ae15f6e47d2912f0100872a0478cd45ad5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 9 Mar 2023 13:09:26 -0500 Subject: [PATCH 25/41] feat: Improve scrobble client debug logging during scrobble loop Use client logger for cleaner labels --- src/clients/ScrobbleClients.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/clients/ScrobbleClients.ts b/src/clients/ScrobbleClients.ts index f3c985e0..56484d3d 100644 --- a/src/clients/ScrobbleClients.ts +++ b/src/clients/ScrobbleClients.ts @@ -337,32 +337,32 @@ ${sources.join('\n')}`); for (const client of this.clients) { if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) { - this.logger.debug(`Client '${client.name}' was filtered out by '${scrobbleFrom}'`); + client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`); continue; } if(!client.initialized) { if(client.initializing) { - this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is still initializing`); + client.logger.warn(`Cannot scrobble because it is still initializing`); continue; } if(!(await client.initialize())) { - this.logger.warn(`Cannot scrobble to Client '${client.name}' because it could not be initialized`); + client.logger.warn(`Cannot scrobble because it could not be initialized`); continue; } } if(client.requiresAuth && !client.authed) { if (client.requiresAuthInteraction) { - this.logger.warn(`Cannot scrobble to Client '${client.name}' because user interaction is required for authentication`); + client.logger.warn(`Cannot scrobble because user interaction is required for authentication`); continue; } else if (!(await client.testAuth())) { - this.logger.warn(`Cannot scrobble to Client '${client.name}' because auth test failed`); + client.logger.warn(`Cannot scrobble because auth test failed`); continue; } } if(!(await client.isReady())) { - this.logger.warn(`Cannot scrobble to Client '${client.name}' because it is not ready`); + client.logger.warn(`Cannot scrobble because it is not ready`); continue; } @@ -370,7 +370,7 @@ ${sources.join('\n')}`); try { await client.refreshScrobbles(); } catch(e) { - this.logger.error(`Encountered error while refreshing scrobbles for ${client.name}`); + client.logger.error(`Encountered error while refreshing scrobbles`); this.logger.error(e); } } @@ -392,12 +392,12 @@ ${sources.join('\n')}`); } } else { if(!timeFrameValid) { - this.logger.debug(`Will not scrobble ${buildTrackString(playObj)} because it ${timeFrameValidLog}`); + client.logger.debug(`Will not scrobble ${buildTrackString(playObj)} from Source '${scrobbleFrom}' because it ${timeFrameValidLog}`); } } } catch(e) { - this.logger.error(`Encountered error while in scrobble loop for ${client.name}`); - this.logger.error(e); + client.logger.error(`Encountered error while in scrobble loop`); + client.logger.error(e); // for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue if(e.continueScrobbling !== true) { break; -- 2.51.2 From 7a4f0f0d3fc3d349108dae9d8f663cae379a3ba6 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 9 Mar 2023 13:12:31 -0500 Subject: [PATCH 26/41] fix: Fix mopidy response handling when no track is being played --- src/sources/MopidySource.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/sources/MopidySource.ts b/src/sources/MopidySource.ts index bbc85635..0cfeb985 100644 --- a/src/sources/MopidySource.ts +++ b/src/sources/MopidySource.ts @@ -180,19 +180,21 @@ export class MopidySource extends MemorySource { const currTrack = await this.client.playback.getCurrentTrack(); const playback = await this.client.playback.getTimePosition(); - let play: PlayObject | undefined = this.formatPlayObj(currTrack, {trackProgressPosition: playback}); - - if (this.uriWhitelist.length > 0) { - const match = this.uriWhitelist.find(x => currTrack.uri.includes(x)); - if (match === undefined) { - this.logger.debug(`URI for currently playing (${currTrack.uri}) did not match any in whitelist. Will not track play ${buildTrackString(play)}`); - play = undefined; - } - } else if (this.uriBlacklist.length > 0) { - const match = this.uriWhitelist.find(x => currTrack.uri.includes(x)); - if (match !== undefined) { - this.logger.debug(`URI for currently playing (${currTrack.uri}) matched from blacklist (${match}). Will not track play ${buildTrackString(play)}`); - play = undefined; + let play: PlayObject | undefined = currTrack === null ? undefined : this.formatPlayObj(currTrack, {trackProgressPosition: playback}); + + if(play !== undefined) { + if (this.uriWhitelist.length > 0) { + const match = this.uriWhitelist.find(x => currTrack.uri.includes(x)); + if (match === undefined) { + this.logger.debug(`URI for currently playing (${currTrack.uri}) did not match any in whitelist. Will not track play ${buildTrackString(play)}`); + play = undefined; + } + } else if (this.uriBlacklist.length > 0) { + const match = this.uriWhitelist.find(x => currTrack.uri.includes(x)); + if (match !== undefined) { + this.logger.debug(`URI for currently playing (${currTrack.uri}) matched from blacklist (${match}). Will not track play ${buildTrackString(play)}`); + play = undefined; + } } } -- 2.51.2 From 80ba6e6c79b88dec4821118e7699df3d24fb8bfc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 9 Mar 2023 14:33:16 -0500 Subject: [PATCH 27/41] remove commented out code --- src/index.ts | 64 +++------------------------------------------------- 1 file changed, 3 insertions(+), 61 deletions(-) diff --git a/src/index.ts b/src/index.ts index c38cc76d..ee56651a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import {addAsync, Router} from '@awaitjs/express'; import express from 'express'; import bodyParser from 'body-parser'; -import winston, {info, Logger} from 'winston'; +import {Logger} from 'winston'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc.js'; import isBetween from 'dayjs/plugin/isBetween.js'; @@ -9,9 +9,7 @@ import relativeTime from 'dayjs/plugin/relativeTime.js'; import duration from 'dayjs/plugin/duration.js'; import passport from 'passport'; import session from 'express-session'; -import {Writable} from 'stream'; -//import {NullTransport} from 'winston-null'; -//import 'winston-daily-rotate-file'; + import { buildTrackString, capitalize, @@ -65,63 +63,7 @@ app.use(session({secret: 'keyboard cat', resave: false, saveUninitialized: false app.use(passport.initialize()); app.use(passport.session()); -const {transports} = winston; - let output: LogInfo[] = [] -/*const stream = new Writable() -stream._write = (chunk, encoding, next) => { - let formatString = chunk.toString().replace('\n', '
    ') - .replace(/(debug)\s/gi, '$1 ') - .replace(/(warn)\s/gi, '$1 ') - .replace(/(info)\s/gi, '$1 ') - .replace(/(error)\s/gi, '$1 ') - output.unshift(formatString); - output = output.slice(0, 101); - io.emit('log', formatString); - next(); -}*/ -/*const streamTransport = new winston.transports.Stream({ - stream, -})*/ - - -/*let logPath = path.resolve(projectDir, `./logs`); -if(typeof process.env.CONFIG_DIR === 'string') { - logPath = path.resolve(process.env.CONFIG_DIR, './logs'); -} -const localUrl = `http://localhost:${port}`; - -const rotateTransport = new winston.transports.DailyRotateFile({ - dirname: logPath, - createSymlink: true, - symlinkName: 'scrobble-current.log', - filename: 'scrobble-%DATE%.log', - datePattern: 'YYYY-MM-DD', - maxSize: '5m' -}); - -const consoleTransport = new transports.Console(); - -const myTransports = [ - consoleTransport, - streamTransport, -]; - -if (typeof logPath === 'string') { - // @ts-ignore - myTransports.push(rotateTransport); -} - -const loggerOptions: winston.LoggerOptions = { - level: logConfig.level, - format: labelledFormat(), - transports: myTransports, -}; - -winston.loggers.add('default', loggerOptions); - -winston.loggers.add('noop', {transports: [new NullTransport()]});*/ - const initLogger = getLogger({}, 'init'); initLogger.stream().on('log', (log: LogInfo) => { @@ -130,7 +72,7 @@ initLogger.stream().on('log', (log: LogInfo) => { io.emit('log', formatLogToHtml(log[MESSAGE])); }); -let logger: Logger; // = winston.loggers.get('default'); +let logger: Logger; const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); -- 2.51.2 From 99a73a80db188ed4353f134c681fa13c17867962 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 09:54:20 -0500 Subject: [PATCH 28/41] feat: Implement Listenbrainz source --- README.md | 3 +- config/listenbrainz.json.example | 9 + docs/configuration.md | 21 ++- src/apis/AbstractApiClient.ts | 2 +- src/apis/ListenbrainzApiClient.ts | 155 ++++++++++++++++++ src/clients/ListenbrainzScrobbler.ts | 134 +++++++++++++++ src/clients/ScrobbleClients.ts | 27 ++- src/common/infrastructure/Atomic.ts | 4 +- .../infrastructure/config/client/clients.ts | 5 +- .../config/client/listenbrainz.ts | 31 ++++ src/common/schema/aio-client.json | 104 ++++++++++++ src/common/schema/aio.json | 104 ++++++++++++ src/common/schema/client.json | 97 +++++++++++ 13 files changed, 681 insertions(+), 15 deletions(-) create mode 100644 config/listenbrainz.json.example create mode 100644 src/apis/ListenbrainzApiClient.ts create mode 100644 src/clients/ListenbrainzScrobbler.ts create mode 100644 src/common/infrastructure/config/client/listenbrainz.ts diff --git a/README.md b/README.md index 156b4909..6e43c265 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler) -A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and other clients (eventually!) +A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and other clients * Supports scrobbling for many sources * [Spotify](/docs/configuration.md#spotify) @@ -19,6 +19,7 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git * Supports scrobbling to many clients * [Maloja](/docs/configuration.md#maloja) * [Last.fm](/docs/configuration.md#lastfm) + * [Listenbrainz](/docs/configuration.md#listenbrainz) * Monitor status of sources and clients using [webhooks (Gotify or Ntfy)](/docs/configuration.md#webhook-configurations) or [healthcheck endpoint](/docs/configuration.md#health-endpoint) * Supports configuring for single or multiple users (scrobbling for your friends and family!) * Web server interface for stats, basic control, and detailed logs diff --git a/config/listenbrainz.json.example b/config/listenbrainz.json.example new file mode 100644 index 00000000..50204a33 --- /dev/null +++ b/config/listenbrainz.json.example @@ -0,0 +1,9 @@ +[ + { + "name": "brainz", + "data": { + "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b", + "username": "FoxxMD" + } + } +] diff --git a/docs/configuration.md b/docs/configuration.md index b98198f1..db26db58 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ * [Client Configurations](#client-configurations) * [Maloja](#maloja) * [Last.fm](#lastfm) + * [Listenbrainz](#listenbrainz) * [Monitoring](#monitoring) * [Webhooks](#webhook-configurations) * [Health Endpoint](#health-endpoint) @@ -469,18 +470,24 @@ http://localhost:9078/lastfm/callback ``` or replace `localhost:9078` with your own base URL +## [Listenbrainz](https://listenbrainz.org) + +You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/) + +On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration. + ### ENV-Based -| Environmental Variable | Required? | Default | Description | -|----------------------------|-----------|-----------------------------------------|-------------------------------| -| `LASTFM_API_KEY` | Yes | | Api Key from your API Account | -| `LASTFM_SECRET` | Yes | | Shared secret from your API Account | -| `LASTFM_REDIRECT_URI` | No | `http://localhost:9078/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it | -| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. | + +| Environmental Variable | Required? | Default | Description | +|------------------------|-----------|-------------------------------|---------------------------------| +| LZ_TOKEN | Yes | | User token from your LZ profile | +| LZ_USER | Yes | | Your LZ username | +| LZ_URL | No | https://api.listenbrainz.org/ | The base URL for the LZ server | ### File-Based -See [`lastfm.json.example`](/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) +See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FListenbrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) # Monitoring diff --git a/src/apis/AbstractApiClient.ts b/src/apis/AbstractApiClient.ts index 2fdae713..e6f0fffe 100644 --- a/src/apis/AbstractApiClient.ts +++ b/src/apis/AbstractApiClient.ts @@ -20,7 +20,7 @@ export default abstract class AbstractApiClient { this.type = type; this.name = name; const identifier = `API - ${capitalize(this.type)} - ${name}`; - this.logger = winston.loggers.get('app').child({labels: identifier}, mergeArr); + this.logger = winston.loggers.get('app').child({labels: [identifier]}, mergeArr); this.config = config; this.options = options; } diff --git a/src/apis/ListenbrainzApiClient.ts b/src/apis/ListenbrainzApiClient.ts new file mode 100644 index 00000000..3d3ef6ac --- /dev/null +++ b/src/apis/ListenbrainzApiClient.ts @@ -0,0 +1,155 @@ +import AbstractApiClient from "./AbstractApiClient.js"; +import request, {Request} from 'superagent'; +import {ListenBrainzClientData} from "../common/infrastructure/config/client/listenbrainz.js"; +import {PlayObject} from "../common/infrastructure/Atomic.js"; +import dayjs from "dayjs"; + +export interface ListenPayload { + count: number; + listens: Listen[]; +} + +export interface Track { + artist_name: string; + track_name: string; + release_name?: string; + artist_mbids?: string[]; + artist_msid?: string; + recording_mbid?: string; + release_mbid?: string; + release_msid?: string; + tags?: string[]; + + duration?: number +} + +export interface Listen { + listened_at: Date | number; + recording_msid?: string; + track_metadata: Track; +} + +export class ListenbrainzApiClient extends AbstractApiClient { + + declare config: ListenBrainzClientData; + url: string; + + constructor(name: any, config: ListenBrainzClientData, options = {}) { + super('ListenBrainz', name, config, options); + const { + url = 'https://api.listenbrainz.org/' + } = config; + this.url = url; + } + + + callApi = async (req: Request, retries = 0): Promise => { + const { + maxRequestRetries = 2, + retryMultiplier = 1.5 + } = this.config; + + try { + req.set('Authorization', `Token ${this.config.token}`); + return await req as T; + } catch (e) { + const { + message, + } = e; + throw e; + } + } + + testConnection = async () => { + try { + const resp = await this.callApi(request.get(this.url)) + return true; + } catch (e) { + if(e.status === 410) { + return true; + } + return false; + } + } + + testAuth = async () => { + try { + const resp = await this.callApi(request.get(`${this.url}1/validate-token`)); + return true; + } catch (e) { + return false; + } + } + + getUserListens = async (user?: string): Promise => { + try { + + const resp = await this.callApi(request.get(`${this.url}1/user/${user ?? this.config.username}/listens`).query({count: 25})); + const {body: {payload}} = resp as any; + + let response: ListenPayload = { + count: payload.count, + listens: payload.listens.map((i: any) => { + let listen: Listen = { + listened_at: i.listened_at, + recording_msid: i.recording_msid, + track_metadata: { + artist_name: i.track_metadata.artist_name, + track_name: i.track_metadata.track_name, + release_name: i.track_metadata.additional_info.release_name, + artist_mbids: i.track_metadata.additional_info.artist_mbids, + artist_msid: i.track_metadata.additional_info.artist_msid, + recording_mbid: i.track_metadata.additional_info.recording_mbid, + release_mbid: i.track_metadata.additional_info.release_mbid, + release_msid: i.track_metadata.additional_info.release_msid, + tags: i.track_metadata.additional_info.tags, + }, + } + return listen; + }) + } + + return response.listens.map(x => ListenbrainzApiClient.listenToPlay(x)); + } catch (e) { + return []; + } + } + + submitListen = async (play: PlayObject) => { + try { + const listenPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListen(play)]}; + await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload)); + return listenPayload; + } catch (e) { + throw e; + } + } + + static playToListen = (play: PlayObject): Listen => { + return { + listened_at: (play.data.playDate ?? dayjs()).unix(), + track_metadata: { + artist_name: play.data.artists[0], + track_name: play.data.track, + duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined + } + } + } + + static listenToPlay = (listen: Listen): PlayObject => { + const {listened_at, recording_msid, track_metadata} = listen; + return { + data: { + playDate: dayjs(listened_at), + track: track_metadata.track_name, + artists: [track_metadata.artist_name], + album: track_metadata.release_name, + duration: track_metadata.duration + }, + meta: { + source: 'listenbrainz', + trackId: recording_msid + } + } + } +} diff --git a/src/clients/ListenbrainzScrobbler.ts b/src/clients/ListenbrainzScrobbler.ts new file mode 100644 index 00000000..08fa3489 --- /dev/null +++ b/src/clients/ListenbrainzScrobbler.ts @@ -0,0 +1,134 @@ +import dayjs from 'dayjs'; + +import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; +import { + buildTrackString, capitalize, + playObjDataMatch, removeUndefinedKeys, + setIntersection, sleep, + sortByOldestPlayDate, + truncateStringToLength, +} from "../utils.js"; +import LastfmApiClient from "../apis/LastfmApiClient.js"; +import { + FormatPlayObjectOptions, + INITIALIZING, + PlayObject, + TrackStringOptions +} from "../common/infrastructure/Atomic.js"; +import {Notifiers} from "../notifier/Notifiers.js"; +import {Logger} from "winston"; +import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js"; +import {ListenbrainzApiClient} from "../apis/ListenbrainzApiClient.js"; + +export default class ListenbrainzScrobbler extends AbstractScrobbleClient { + + api: ListenbrainzApiClient; + requiresAuth = true; + requiresAuthInteraction = false; + + declare config: ListenBrainzClientConfig; + + constructor(name: any, config: ListenBrainzClientConfig, options = {}, notifier: Notifiers, logger: Logger) { + super('listenbrainz', name, config, notifier, logger); + const { + data: { + url + } = {} + } = config; + this.api = new ListenbrainzApiClient(name, config.data); + } + + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options); + + initialize = async () => { + // @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'. + this.initialized = INITIALIZING; + if(this.config.data.token === undefined) { + this.logger.error('Must provide a User Token'); + this.initialized = false; + } else { + try { + await this.api.testConnection(); + this.initialized = true; + } catch (e) { + this.logger.error(e); + this.initialized = false; + } + } + return this.initialized; + } + + testAuth = async () => { + + try { + this.authed = await this.api.testAuth(); + } catch (e) { + this.logger.error('Could not successfully communicate with Last.fm API'); + this.logger.error(e); + this.authed = false; + } + return this.authed; + } + + refreshScrobbles = async () => { + if (this.refreshEnabled) { + this.logger.debug('Refreshing recent scrobbles'); + const resp = await this.api.getUserListens(this.config.data.username); + this.recentScrobbles = resp.sort(sortByOldestPlayDate); + if (this.recentScrobbles.length > 0) { + const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1); + const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1); + this.newestScrobbleTime = newestScrobbleTime; + this.oldestScrobbleTime = oldestScrobbleTime; + + this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)[0]); + } + } + this.lastScrobbleCheck = dayjs(); + } + + alreadyScrobbled = async (playObj: PlayObject, log = false) => { + return await this.existingScrobble(playObj) !== undefined; + } + + scrobble = async (playObj: PlayObject) => { + const { + meta: { + source, + newFromSource = false, + } = {} + } = playObj; + + let rawPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListen(playObj)]}; + + try { + const resp = await this.api.submitListen(playObj); + rawPayload = resp; + this.addScrobbledTrack(playObj, ListenbrainzApiClient.playToListen(playObj)); + if (newFromSource) { + this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); + } else { + this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`); + } + + // last fm has rate limits but i can't find a specific example of what that limit is. going to default to 1 scrobble/sec to be safe + await sleep(1000); + } catch (e) { + let message = e.message; + if(e.response !== undefined) { + if(e.response.body !== undefined) { + message = e.response.body.messsage; + } else if(e.response.text !== undefined) { + message = e.response.text; + } + } + await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${message}`, priority: 'error'}); + this.logger.error(`Failed to scrobble => ${message} | payload => ${JSON.stringify(rawPayload)}`); + throw e; + } finally { + this.logger.debug(`Raw Payload: ${JSON.stringify(rawPayload)}`); + } + + return true; + } +} diff --git a/src/clients/ScrobbleClients.ts b/src/clients/ScrobbleClients.ts index 56484d3d..d90bd811 100644 --- a/src/clients/ScrobbleClients.ts +++ b/src/clients/ScrobbleClients.ts @@ -20,6 +20,8 @@ import {Notifiers} from "../notifier/Notifiers.js"; import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; import {EventEmitter} from "events"; import winston from "winston"; +import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js"; +import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -128,7 +130,7 @@ export default class ScrobbleClients { if (url !== undefined || apiKey !== undefined) { configs.push({ type: 'maloja', - name: 'unnamed', + name: 'unnamed-mlj', source: 'ENV', mode: 'single', configureAs: 'client', @@ -150,7 +152,7 @@ export default class ScrobbleClients { if (!Object.values(lfm).every(x => x === undefined)) { configs.push({ type: 'lastfm', - name: 'unnamed', + name: 'unnamed-lfm', source: 'ENV', mode: 'single', configureAs: 'client', @@ -159,6 +161,24 @@ export default class ScrobbleClients { }) } break; + case 'listenbrainz': + const lz = { + url: process.env.LZ_URL, + token: process.env.LZ_TOKEN, + username: process.env.LZ_USER + }; + if (!Object.values(lz).every(x => x === undefined)) { + configs.push({ + type: 'listenbrainz', + name: 'unnamed-lz', + source: 'ENV', + mode: 'single', + configureAs: 'client', + // @ts-ignore + data: lz + }) + } + break; default: break; } @@ -282,6 +302,9 @@ ${sources.join('\n')}`); case 'lastfm': newClient = new LastfmScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as LastfmClientConfig, {}, notifier, this.logger); break; + case 'listenbrainz': + newClient = new ListenbrainzScrobbler(name, {...clientConfig, data: {configDir: this.configDir, ...data} } as unknown as ListenBrainzClientConfig, {}, notifier, this.logger); + break; default: break; } diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 26561f09..769cf4a1 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -8,8 +8,8 @@ export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subson export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic']; -export type ClientType = 'maloja' | 'lastfm'; -export const clientTypes: ClientType[] = ['maloja', 'lastfm']; +export type ClientType = 'maloja' | 'lastfm' | 'listenbrainz'; +export const clientTypes: ClientType[] = ['maloja', 'lastfm', 'listenbrainz']; export type InitState = 0 | 1 | 2; export const NOT_INITIALIZED: InitState = 0; diff --git a/src/common/infrastructure/config/client/clients.ts b/src/common/infrastructure/config/client/clients.ts index bc4ce0c2..936e2af9 100644 --- a/src/common/infrastructure/config/client/clients.ts +++ b/src/common/infrastructure/config/client/clients.ts @@ -1,6 +1,7 @@ import {MalojaClientAIOConfig, MalojaClientConfig} from "./maloja.js"; import {LastfmClientAIOConfig, LastfmClientConfig} from "./lastfm.js"; +import {ListenBrainzClientAIOConfig, ListenBrainzClientConfig} from "./listenbrainz.js"; -export type ClientConfig = MalojaClientConfig | LastfmClientConfig; +export type ClientConfig = MalojaClientConfig | LastfmClientConfig | ListenBrainzClientConfig; -export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig; +export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | ListenBrainzClientAIOConfig; diff --git a/src/common/infrastructure/config/client/listenbrainz.ts b/src/common/infrastructure/config/client/listenbrainz.ts new file mode 100644 index 00000000..2335c7a3 --- /dev/null +++ b/src/common/infrastructure/config/client/listenbrainz.ts @@ -0,0 +1,31 @@ +import {CommonClientConfig, CommonClientData} from "./index.js"; +import {RequestRetryOptions} from "../common.js"; + +export interface ListenBrainzClientData extends RequestRetryOptions, CommonClientData { + /** + * URL for the ListenBrainz server, if not using the default + * + * @examples ["https://api.listenbrainz.org/"] + * @default "https://api.listenbrainz.org/" + * */ + url?: string + /** + * User token for the user to scrobble for + * + * @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"] + * */ + token: string + + /** + * Username of the user to scrobble for + * */ + username: string +} + +export interface ListenBrainzClientConfig extends CommonClientConfig { + data: ListenBrainzClientData +} + +export interface ListenBrainzClientAIOConfig extends ListenBrainzClientConfig { + type: 'listenbrainz' +} diff --git a/src/common/schema/aio-client.json b/src/common/schema/aio-client.json index 6c3ccdff..c04d12c4 100644 --- a/src/common/schema/aio-client.json +++ b/src/common/schema/aio-client.json @@ -8,6 +8,9 @@ }, { "$ref": "#/definitions/MalojaClientAIOConfig" + }, + { + "$ref": "#/definitions/ListenBrainzClientAIOConfig" } ] }, @@ -159,6 +162,107 @@ ], "type": "object" }, + "ListenBrainzClientAIOConfig": { + "properties": { + "data": { + "$ref": "#/definitions/ListenBrainzClientData", + "description": "Specific data required to configure this client" + }, + "name": { + "description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.", + "examples": [ + "MyConfig" + ], + "type": "string" + }, + "type": { + "enum": [ + "listenbrainz" + ], + "type": "string" + } + }, + "required": [ + "data", + "name", + "type" + ], + "type": "object" + }, + "ListenBrainzClientData": { + "properties": { + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "properties": { + "checkExistingScrobbles": { + "default": true, + "description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.", + "examples": [ + true + ], + "type": "boolean" + }, + "refreshEnabled": { + "default": true, + "description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history", + "examples": [ + true + ], + "type": "boolean" + }, + "verbose": { + "description": "Options used for increasing verbosity of logging in MS (used for debugging)", + "properties": { + "match": { + "$ref": "#/definitions/MatchLoggingOptions" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "token": { + "description": "User token for the user to scrobble for", + "examples": [ + "6794186bf-1157-4de6-80e5-uvb411f3ea2b" + ], + "type": "string" + }, + "url": { + "default": "https://api.listenbrainz.org/", + "description": "URL for the ListenBrainz server, if not using the default", + "examples": [ + "https://api.listenbrainz.org/" + ], + "type": "string" + }, + "username": { + "description": "Username of the user to scrobble for", + "type": "string" + } + }, + "required": [ + "token", + "username" + ], + "type": "object" + }, "MalojaClientAIOConfig": { "properties": { "data": { diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index baa04149..3ab31e01 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -8,6 +8,9 @@ }, { "$ref": "#/definitions/MalojaClientAIOConfig" + }, + { + "$ref": "#/definitions/ListenBrainzClientAIOConfig" } ] }, @@ -529,6 +532,107 @@ ], "type": "object" }, + "ListenBrainzClientAIOConfig": { + "properties": { + "data": { + "$ref": "#/definitions/ListenBrainzClientData", + "description": "Specific data required to configure this client" + }, + "name": { + "description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.", + "examples": [ + "MyConfig" + ], + "type": "string" + }, + "type": { + "enum": [ + "listenbrainz" + ], + "type": "string" + } + }, + "required": [ + "data", + "name", + "type" + ], + "type": "object" + }, + "ListenBrainzClientData": { + "properties": { + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "properties": { + "checkExistingScrobbles": { + "default": true, + "description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.", + "examples": [ + true + ], + "type": "boolean" + }, + "refreshEnabled": { + "default": true, + "description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history", + "examples": [ + true + ], + "type": "boolean" + }, + "verbose": { + "description": "Options used for increasing verbosity of logging in MS (used for debugging)", + "properties": { + "match": { + "$ref": "#/definitions/MatchLoggingOptions" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "token": { + "description": "User token for the user to scrobble for", + "examples": [ + "6794186bf-1157-4de6-80e5-uvb411f3ea2b" + ], + "type": "string" + }, + "url": { + "default": "https://api.listenbrainz.org/", + "description": "URL for the ListenBrainz server, if not using the default", + "examples": [ + "https://api.listenbrainz.org/" + ], + "type": "string" + }, + "username": { + "description": "Username of the user to scrobble for", + "type": "string" + } + }, + "required": [ + "token", + "username" + ], + "type": "object" + }, "LogLevel": { "enum": [ "debug", diff --git a/src/common/schema/client.json b/src/common/schema/client.json index ffbe8205..d3c07cdc 100644 --- a/src/common/schema/client.json +++ b/src/common/schema/client.json @@ -6,6 +6,9 @@ }, { "$ref": "#/definitions/MalojaClientConfig" + }, + { + "$ref": "#/definitions/ListenBrainzClientConfig" } ], "definitions": { @@ -150,6 +153,100 @@ ], "type": "object" }, + "ListenBrainzClientConfig": { + "properties": { + "data": { + "$ref": "#/definitions/ListenBrainzClientData", + "description": "Specific data required to configure this client" + }, + "name": { + "description": "Unique identifier for this client. Used with sources to restrict where scrobbles are sent.", + "examples": [ + "MyConfig" + ], + "type": "string" + } + }, + "required": [ + "data", + "name" + ], + "type": "object" + }, + "ListenBrainzClientData": { + "properties": { + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "properties": { + "checkExistingScrobbles": { + "default": true, + "description": "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled.", + "examples": [ + true + ], + "type": "boolean" + }, + "refreshEnabled": { + "default": true, + "description": "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history", + "examples": [ + true + ], + "type": "boolean" + }, + "verbose": { + "description": "Options used for increasing verbosity of logging in MS (used for debugging)", + "properties": { + "match": { + "$ref": "#/definitions/MatchLoggingOptions" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "token": { + "description": "User token for the user to scrobble for", + "examples": [ + "6794186bf-1157-4de6-80e5-uvb411f3ea2b" + ], + "type": "string" + }, + "url": { + "default": "https://api.listenbrainz.org/", + "description": "URL for the ListenBrainz server, if not using the default", + "examples": [ + "https://api.listenbrainz.org/" + ], + "type": "string" + }, + "username": { + "description": "Username of the user to scrobble for", + "type": "string" + } + }, + "required": [ + "token", + "username" + ], + "type": "object" + }, "MalojaClientConfig": { "properties": { "data": { -- 2.51.2 From 9ab046891375460bebec43843bd66544d1ffc784 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 11:22:21 -0500 Subject: [PATCH 29/41] feat: Improve listenbrainz api * The response track data is different than submission data so make sure we are accurately parsing that data to PlayObjects * Add separate method for getting listens as recently played (play objects) and refactor getUserListens to return raw payload --- src/apis/ListenbrainzApiClient.ts | 178 +++++++++++++++++++++++------- 1 file changed, 137 insertions(+), 41 deletions(-) diff --git a/src/apis/ListenbrainzApiClient.ts b/src/apis/ListenbrainzApiClient.ts index 3d3ef6ac..c5f6dced 100644 --- a/src/apis/ListenbrainzApiClient.ts +++ b/src/apis/ListenbrainzApiClient.ts @@ -1,12 +1,42 @@ import AbstractApiClient from "./AbstractApiClient.js"; import request, {Request} from 'superagent'; import {ListenBrainzClientData} from "../common/infrastructure/config/client/listenbrainz.js"; -import {PlayObject} from "../common/infrastructure/Atomic.js"; +import {FormatPlayObjectOptions, PlayObject} from "../common/infrastructure/Atomic.js"; import dayjs from "dayjs"; -export interface ListenPayload { - count: number; - listens: Listen[]; + +export interface ArtistMBIDMapping { + artist_credit_name: string + artist_mbid: string + join_phrase: string +} + +export interface MinimumTrack { + artist_name: string; + track_name: string; + release_name?: string; +} + +export interface AdditionalTrackInfo { + artist_mbids?: string[] + release_mbid?: string + release_group_mbid?: string + recording_mbid?: string + submission_client?: string + submission_client_version?: string + spotify_id?: string + media_player?: string + media_player_version?: string + + music_service?: string + music_service_name?: string + origin_url?: string + tags?: string[] + duration?: number + + duration_ms?: number + track_mbid?: string + work_mbids?: string[] } export interface Track { @@ -23,10 +53,49 @@ export interface Track { duration?: number } -export interface Listen { +export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo { + recording_msid?: string +} + +export interface TrackPayload extends MinimumTrack { + additional_info?: AdditionalTrackInfo +} + +export interface ListenPayload { listened_at: Date | number; recording_msid?: string; - track_metadata: Track; + track_metadata: TrackPayload; +} + +export interface SubmitPayload { + listen_type: 'single', + payload: [ListenPayload] +} + +export interface TrackResponse extends MinimumTrack { + duration: number + additional_info: AdditionalTrackInfoResponse + mbid_mapping: { + artist_mbids?: string[] + artists?: ArtistMBIDMapping[] + caa_id?: number + caa_release_mbid?: string + recording_mbid?: string + release_mbid?: string + } +} + +export interface ListensResponse { + count: number; + listens: ListenResponse[]; +} + +export interface ListenResponse { + + inserted_at: number + listened_at: number; + recording_msid?: string; + track_metadata: TrackResponse; } export class ListenbrainzApiClient extends AbstractApiClient { @@ -81,43 +150,30 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } - getUserListens = async (user?: string): Promise => { + getUserListens = async (user?: string): Promise => { try { const resp = await this.callApi(request.get(`${this.url}1/user/${user ?? this.config.username}/listens`).query({count: 25})); const {body: {payload}} = resp as any; + return payload as ListensResponse; + } catch (e) { + throw e; + } + } - let response: ListenPayload = { - count: payload.count, - listens: payload.listens.map((i: any) => { - let listen: Listen = { - listened_at: i.listened_at, - recording_msid: i.recording_msid, - track_metadata: { - artist_name: i.track_metadata.artist_name, - track_name: i.track_metadata.track_name, - release_name: i.track_metadata.additional_info.release_name, - artist_mbids: i.track_metadata.additional_info.artist_mbids, - artist_msid: i.track_metadata.additional_info.artist_msid, - recording_mbid: i.track_metadata.additional_info.recording_mbid, - release_mbid: i.track_metadata.additional_info.release_mbid, - release_msid: i.track_metadata.additional_info.release_msid, - tags: i.track_metadata.additional_info.tags, - }, - } - return listen; - }) - } - - return response.listens.map(x => ListenbrainzApiClient.listenToPlay(x)); + getRecentlyPlayed = async (user?: string): Promise => { + try { + const resp = await this.getUserListens(user); + return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x)); } catch (e) { + this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); return []; } } submitListen = async (play: PlayObject) => { try { - const listenPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListen(play)]}; + const listenPayload: SubmitPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(play)]}; await this.callApi(request.post(`${this.url}1/submit-listens`).type('json').send(listenPayload)); return listenPayload; } catch (e) { @@ -125,31 +181,71 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } - static playToListen = (play: PlayObject): Listen => { + static playToListenPayload = (play: PlayObject): ListenPayload => { return { listened_at: (play.data.playDate ?? dayjs()).unix(), track_metadata: { artist_name: play.data.artists[0], track_name: play.data.track, - duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined + additional_info: { + duration: play.data.duration !== undefined ? Math.round(play.data.duration) : undefined + } } } } - static listenToPlay = (listen: Listen): PlayObject => { - const {listened_at, recording_msid, track_metadata} = listen; + static listenResponseToPlay = (listen: ListenResponse): PlayObject => { + const { + listened_at, + recording_msid, + track_metadata: { + track_name, + artist_name, + release_name, + duration, + additional_info: { + recording_msid: aRecordingMsid, + recording_mbid: aRecordingMbid, + duration: aDuration, + duration_ms: aDurationMs, + } = {}, + mbid_mapping: { + artists: artistMappings = [], + recording_mbid: mRecordingMbid + } = {} + } = {} + } = listen; + + const playId = recording_msid ?? aRecordingMsid; + const trackId = aRecordingMbid ?? mRecordingMbid; + let dur = duration ?? aDuration; + if (dur === undefined && aDurationMs !== undefined) { + dur = Math.round(aDurationMs / 1000); + } + + let artists: string[] = [artist_name]; + if (artistMappings.length > 0) { + const secondaryArtists = artistMappings.filter(x => x.artist_credit_name !== artist_name); + artists = artists.concat(secondaryArtists.map(x => x.artist_credit_name)); + } + return { data: { - playDate: dayjs(listened_at), - track: track_metadata.track_name, - artists: [track_metadata.artist_name], - album: track_metadata.release_name, - duration: track_metadata.duration + playDate: dayjs.unix(listened_at), + track: track_name, + artists: artists, + album: release_name, + duration: dur }, meta: { source: 'listenbrainz', - trackId: recording_msid + trackId, + playId } } } + + static formatPlayObj = (obj: any, options: FormatPlayObjectOptions): PlayObject => { + return ListenbrainzApiClient.listenResponseToPlay(obj); + } } -- 2.51.2 From ca148f490ed48d6f49da1ee1020399ac54163923 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 11:23:15 -0500 Subject: [PATCH 30/41] refactor: Make scrobbled data transform responsibility of concrete scrobbler class --- src/clients/AbstractScrobbleClient.ts | 4 ++-- src/clients/LastfmScrobbler.ts | 2 +- src/clients/MalojaScrobbler.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index 7348aefb..f8ae57c5 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -158,8 +158,8 @@ export default abstract class AbstractScrobbleClient { return [validTime, log] } - addScrobbledTrack = (playObj: any, scrobbleResp: any) => { - this.scrobbledPlayObjs.push({play: playObj, scrobble: this.formatPlayObj(scrobbleResp)}); + addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObject) => { + this.scrobbledPlayObjs.push({play: playObj, scrobble: scrobbledPlay}); } cleanSourceSearchTitle = (playObj: PlayObject) => { diff --git a/src/clients/LastfmScrobbler.ts b/src/clients/LastfmScrobbler.ts index 9d9040ad..c1c6784b 100644 --- a/src/clients/LastfmScrobbler.ts +++ b/src/clients/LastfmScrobbler.ts @@ -179,7 +179,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { this.initialized = false; throw new Error('Service reported daily scrobble limit exceeded! 😬 Disabling client'); } - this.addScrobbledTrack(playObj, {...rest, date: { uts: timestamp}, name: trackName}); + this.addScrobbledTrack(playObj, this.formatPlayObj({...rest, date: { uts: timestamp}, name: trackName})); if (newFromSource) { this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); } else { diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index e66b857e..ad5409e7 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -436,7 +436,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { } = {}} = response; scrobbleResponse = {...rest, album: mAlbum, time: mTime, duration: mDuration}; } - this.addScrobbledTrack(playObj, scrobbleResponse); + this.addScrobbledTrack(playObj, this.formatPlayObj(scrobbleResponse)); if (newFromSource) { this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); } else { -- 2.51.2 From 9375a6fce46184bf6e8f4fa4f3773f4e31a71495 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 11:24:15 -0500 Subject: [PATCH 31/41] feat: Improve listenbrainz scrobbler source of truth behavior Use updated listenbrainz api methods and scrobbled play method --- src/clients/ListenbrainzScrobbler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/clients/ListenbrainzScrobbler.ts b/src/clients/ListenbrainzScrobbler.ts index 08fa3489..77e33abc 100644 --- a/src/clients/ListenbrainzScrobbler.ts +++ b/src/clients/ListenbrainzScrobbler.ts @@ -38,7 +38,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { this.api = new ListenbrainzApiClient(name, config.data); } - formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options); + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options); initialize = async () => { // @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'. @@ -73,7 +73,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { refreshScrobbles = async () => { if (this.refreshEnabled) { this.logger.debug('Refreshing recent scrobbles'); - const resp = await this.api.getUserListens(this.config.data.username); + const resp = await this.api.getRecentlyPlayed(); this.recentScrobbles = resp.sort(sortByOldestPlayDate); if (this.recentScrobbles.length > 0) { const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1); @@ -99,12 +99,12 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { } = {} } = playObj; - let rawPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListen(playObj)]}; + let rawPayload = {listen_type: 'single', payload: [ListenbrainzApiClient.playToListenPayload(playObj)]}; try { const resp = await this.api.submitListen(playObj); rawPayload = resp; - this.addScrobbledTrack(playObj, ListenbrainzApiClient.playToListen(playObj)); + this.addScrobbledTrack(playObj, playObj); if (newFromSource) { this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); } else { -- 2.51.2 From bf3b81f1cde39dc2e952876fe7fee7dce3fe232c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 11:34:41 -0500 Subject: [PATCH 32/41] feat(listenbrainz): Add timeout for listens response --- src/apis/ListenbrainzApiClient.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apis/ListenbrainzApiClient.ts b/src/apis/ListenbrainzApiClient.ts index c5f6dced..789e309b 100644 --- a/src/apis/ListenbrainzApiClient.ts +++ b/src/apis/ListenbrainzApiClient.ts @@ -153,7 +153,14 @@ export class ListenbrainzApiClient extends AbstractApiClient { getUserListens = async (user?: string): Promise => { try { - const resp = await this.callApi(request.get(`${this.url}1/user/${user ?? this.config.username}/listens`).query({count: 25})); + const resp = await this.callApi(request + .get(`${this.url}1/user/${user ?? this.config.username}/listens`) + // this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble + .timeout({ + response: 15000, // wait 15 seconds before timeout if server doesn't response at all + deadline: 30000 // wait 30 seconds overall for request to complete + }) + .query({count: 25})); const {body: {payload}} = resp as any; return payload as ListensResponse; } catch (e) { -- 2.51.2 From b8f2b1e3f86baeb0d8517414efe3123ea5a381b5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 11:38:24 -0500 Subject: [PATCH 33/41] feat: Increase number of recent scrobbles to fetch --- src/apis/ListenbrainzApiClient.ts | 4 +++- src/clients/LastfmScrobbler.ts | 3 ++- src/clients/ListenbrainzScrobbler.ts | 1 + src/clients/MalojaScrobbler.ts | 3 ++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/apis/ListenbrainzApiClient.ts b/src/apis/ListenbrainzApiClient.ts index 789e309b..62e86fcf 100644 --- a/src/apis/ListenbrainzApiClient.ts +++ b/src/apis/ListenbrainzApiClient.ts @@ -160,7 +160,9 @@ export class ListenbrainzApiClient extends AbstractApiClient { response: 15000, // wait 15 seconds before timeout if server doesn't response at all deadline: 30000 // wait 30 seconds overall for request to complete }) - .query({count: 25})); + .query({ + count: 40 + })); const {body: {payload}} = resp as any; return payload as ListensResponse; } catch (e) { diff --git a/src/clients/LastfmScrobbler.ts b/src/clients/LastfmScrobbler.ts index c1c6784b..8dbc9281 100644 --- a/src/clients/LastfmScrobbler.ts +++ b/src/clients/LastfmScrobbler.ts @@ -57,7 +57,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { refreshScrobbles = async () => { if (this.refreshEnabled) { this.logger.debug('Refreshing recent scrobbles'); - const resp = await this.api.callApi((client: any) => client.userGetRecentTracks({user: this.api.user, limit: 20, extended: true})); + const resp = await this.api.callApi((client: any) => client.userGetRecentTracks({user: this.api.user, limit: 40, extended: true})); const { recenttracks: { track: list = [], @@ -94,6 +94,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { return acc; } }, []).sort(sortByOldestPlayDate); + this.logger.debug(`Found ${this.recentScrobbles.length} recent scrobbles`); if (this.recentScrobbles.length > 0) { const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1); const [{data: {playDate: oldestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(0, 1); diff --git a/src/clients/ListenbrainzScrobbler.ts b/src/clients/ListenbrainzScrobbler.ts index 77e33abc..b4ed2cbe 100644 --- a/src/clients/ListenbrainzScrobbler.ts +++ b/src/clients/ListenbrainzScrobbler.ts @@ -74,6 +74,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { if (this.refreshEnabled) { this.logger.debug('Refreshing recent scrobbles'); const resp = await this.api.getRecentlyPlayed(); + this.logger.debug(`Found ${resp.length} recent scrobbles`); this.recentScrobbles = resp.sort(sortByOldestPlayDate); if (this.recentScrobbles.length > 0) { const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1); diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index ad5409e7..38bf809f 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -309,12 +309,13 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { if (this.refreshEnabled) { this.logger.debug('Refreshing recent scrobbles'); const {url} = this.config.data; - const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=20`)); + const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=40`)); const { body: { list = [], } = {}, } = resp; + this.logger.debug(`Found ${list.length} recent scrobbles`); this.recentScrobbles = list.map((x: any) => this.formatPlayObj(x)).sort(sortByOldestPlayDate); if (this.recentScrobbles.length > 0) { const [{data: {playDate: newestScrobbleTime = dayjs()} = {}} = {}] = this.recentScrobbles.slice(-1); -- 2.51.2 From f587ad4bbcfc23f431391e91741dbb8b4b046a7b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 11:58:57 -0500 Subject: [PATCH 34/41] feat: Use fixed length array to store scrobbled plays May reduce memory usage as we don't store all scrobbles ever --- src/apis/ListenbrainzApiClient.ts | 8 ++++---- src/clients/AbstractScrobbleClient.ts | 15 ++++++++++++--- src/clients/LastfmScrobbler.ts | 6 +++--- src/clients/ListenbrainzScrobbler.ts | 4 ++-- src/clients/MalojaScrobbler.ts | 5 ++--- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/apis/ListenbrainzApiClient.ts b/src/apis/ListenbrainzApiClient.ts index 62e86fcf..1e65e4fc 100644 --- a/src/apis/ListenbrainzApiClient.ts +++ b/src/apis/ListenbrainzApiClient.ts @@ -150,7 +150,7 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } - getUserListens = async (user?: string): Promise => { + getUserListens = async (maxTracks: number, user?: string): Promise => { try { const resp = await this.callApi(request @@ -161,7 +161,7 @@ export class ListenbrainzApiClient extends AbstractApiClient { deadline: 30000 // wait 30 seconds overall for request to complete }) .query({ - count: 40 + count: maxTracks })); const {body: {payload}} = resp as any; return payload as ListensResponse; @@ -170,9 +170,9 @@ export class ListenbrainzApiClient extends AbstractApiClient { } } - getRecentlyPlayed = async (user?: string): Promise => { + getRecentlyPlayed = async (maxTracks: number, user?: string): Promise => { try { - const resp = await this.getUserListens(user); + const resp = await this.getUserListens(maxTracks, user); return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x)); } catch (e) { this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index f8ae57c5..40442c8d 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -18,6 +18,7 @@ import winston, {Logger} from "winston"; import {CommonClientConfig} from "../common/infrastructure/config/client/index.js"; import {ClientConfig} from "../common/infrastructure/config/client/clients.js"; import {Notifiers} from "../notifier/Notifiers.js"; +import {FixedSizeList} from 'fixed-size-list'; export default abstract class AbstractScrobbleClient { @@ -26,12 +27,14 @@ export default abstract class AbstractScrobbleClient { #initState: InitState = NOT_INITIALIZED; + protected MAX_STORED_SCROBBLES = 40; + requiresAuth: boolean = false; requiresAuthInteraction: boolean = false; authed: boolean = false; recentScrobbles: PlayObject[] = []; - scrobbledPlayObjs: ScrobbledPlayObject[] = []; + scrobbledPlayObjs: FixedSizeList; newestScrobbleTime?: Dayjs oldestScrobbleTime: Dayjs = dayjs(); tracksScrobbled: number = 0; @@ -53,6 +56,8 @@ export default abstract class AbstractScrobbleClient { this.logger = logger.child({labels: [identifier]}, mergeArr); this.notifier = notifier; + this.scrobbledPlayObjs = new FixedSizeList(this.MAX_STORED_SCROBBLES); + const { data: { options: { @@ -159,7 +164,11 @@ export default abstract class AbstractScrobbleClient { } addScrobbledTrack = (playObj: PlayObject, scrobbledPlay: PlayObject) => { - this.scrobbledPlayObjs.push({play: playObj, scrobble: scrobbledPlay}); + this.scrobbledPlayObjs.add({play: playObj, scrobble: scrobbledPlay}); + } + + filterScrobbledTracks = () => { + this.scrobbledPlayObjs = new FixedSizeList(this.MAX_STORED_SCROBBLES, this.scrobbledPlayObjs.data.filter(x => this.timeFrameIsValid(x.play)[0])) ; } cleanSourceSearchTitle = (playObj: PlayObject) => { @@ -182,7 +191,7 @@ export default abstract class AbstractScrobbleClient { } = {} } = playObj; - const dtInvariantMatches = this.scrobbledPlayObjs.filter(x => playObjDataMatch(playObj, x.play)); + const dtInvariantMatches = this.scrobbledPlayObjs.data.filter(x => playObjDataMatch(playObj, x.play)); if (dtInvariantMatches.length === 0) { return [undefined, undefined]; diff --git a/src/clients/LastfmScrobbler.ts b/src/clients/LastfmScrobbler.ts index 8dbc9281..c5e3df9b 100644 --- a/src/clients/LastfmScrobbler.ts +++ b/src/clients/LastfmScrobbler.ts @@ -12,7 +12,7 @@ import LastfmApiClient from "../apis/LastfmApiClient.js"; import { FormatPlayObjectOptions, INITIALIZING, - PlayObject, + PlayObject, ScrobbledPlayObject, TrackStringOptions } from "../common/infrastructure/Atomic.js"; import {LastfmClientConfig} from "../common/infrastructure/config/client/lastfm.js"; @@ -57,7 +57,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { refreshScrobbles = async () => { if (this.refreshEnabled) { this.logger.debug('Refreshing recent scrobbles'); - const resp = await this.api.callApi((client: any) => client.userGetRecentTracks({user: this.api.user, limit: 40, extended: true})); + const resp = await this.api.callApi((client: any) => client.userGetRecentTracks({user: this.api.user, limit: this.MAX_STORED_SCROBBLES, extended: true})); const { recenttracks: { track: list = [], @@ -101,7 +101,7 @@ export default class LastfmScrobbler extends AbstractScrobbleClient { this.newestScrobbleTime = newestScrobbleTime; this.oldestScrobbleTime = oldestScrobbleTime; - this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)[0]); + this.filterScrobbledTracks(); } } this.lastScrobbleCheck = dayjs(); diff --git a/src/clients/ListenbrainzScrobbler.ts b/src/clients/ListenbrainzScrobbler.ts index b4ed2cbe..bfb690bf 100644 --- a/src/clients/ListenbrainzScrobbler.ts +++ b/src/clients/ListenbrainzScrobbler.ts @@ -73,7 +73,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { refreshScrobbles = async () => { if (this.refreshEnabled) { this.logger.debug('Refreshing recent scrobbles'); - const resp = await this.api.getRecentlyPlayed(); + const resp = await this.api.getRecentlyPlayed(this.MAX_STORED_SCROBBLES); this.logger.debug(`Found ${resp.length} recent scrobbles`); this.recentScrobbles = resp.sort(sortByOldestPlayDate); if (this.recentScrobbles.length > 0) { @@ -82,7 +82,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { this.newestScrobbleTime = newestScrobbleTime; this.oldestScrobbleTime = oldestScrobbleTime; - this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)[0]); + this.filterScrobbledTracks(); } } this.lastScrobbleCheck = dayjs(); diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index 38bf809f..532958ec 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -309,7 +309,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { if (this.refreshEnabled) { this.logger.debug('Refreshing recent scrobbles'); const {url} = this.config.data; - const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=40`)); + const resp = await this.callApi(request.get(`${url}/apis/mlj_1/scrobbles?max=${this.MAX_STORED_SCROBBLES}`)); const { body: { list = [], @@ -323,7 +323,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { this.newestScrobbleTime = newestScrobbleTime; this.oldestScrobbleTime = oldestScrobbleTime; - this.scrobbledPlayObjs = this.scrobbledPlayObjs.filter(x => this.timeFrameIsValid(x.play)[0]); + this.filterScrobbledTracks(); } } this.lastScrobbleCheck = dayjs(); @@ -359,7 +359,6 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { scrobble = async (playObj: PlayObject) => { const {url, apiKey} = this.config.data; - return true; const { data: { artists, -- 2.51.2 From bd00a5f414507663b8635591db465097a4f57000 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 12:41:07 -0500 Subject: [PATCH 35/41] feat: Improve error and json logging * Properly handle ErrorWithCause stacks * Output json when log meta exists --- src/clients/ListenbrainzScrobbler.ts | 4 +- src/clients/MalojaScrobbler.ts | 3 +- src/clients/ScrobbleClients.ts | 4 +- src/common/logging.ts | 147 +++++++++++++++++++++++++-- 4 files changed, 145 insertions(+), 13 deletions(-) diff --git a/src/clients/ListenbrainzScrobbler.ts b/src/clients/ListenbrainzScrobbler.ts index bfb690bf..caa588a4 100644 --- a/src/clients/ListenbrainzScrobbler.ts +++ b/src/clients/ListenbrainzScrobbler.ts @@ -124,10 +124,10 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { } } await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${message}`, priority: 'error'}); - this.logger.error(`Failed to scrobble => ${message} | payload => ${JSON.stringify(rawPayload)}`); + this.logger.error(`Failed to scrobble => ${message}`, {payload: rawPayload}); throw e; } finally { - this.logger.debug(`Raw Payload: ${JSON.stringify(rawPayload)}`); + this.logger.debug(`Raw Payload:`, {rawPayload}); } return true; diff --git a/src/clients/MalojaScrobbler.ts b/src/clients/MalojaScrobbler.ts index 532958ec..b526341d 100644 --- a/src/clients/MalojaScrobbler.ts +++ b/src/clients/MalojaScrobbler.ts @@ -442,11 +442,12 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { } else { this.logger.info(`Scrobbled (Backlog) => (${source}) ${buildTrackString(playObj)}`); } - this.logger.debug('Payload:', scrobbleData); } catch (e) { await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); this.logger.error(`Scrobble Error (${sType})`, {playInfo: buildTrackString(playObj), payload: scrobbleData}); throw e; + } finally { + this.logger.debug('Raw Payload:', scrobbleData); } return true; diff --git a/src/clients/ScrobbleClients.ts b/src/clients/ScrobbleClients.ts index d90bd811..2cd09acf 100644 --- a/src/clients/ScrobbleClients.ts +++ b/src/clients/ScrobbleClients.ts @@ -22,6 +22,7 @@ import {EventEmitter} from "events"; import winston from "winston"; import ListenbrainzScrobbler from "./ListenbrainzScrobbler.js"; import {ListenBrainzClientConfig} from "../common/infrastructure/config/client/listenbrainz.js"; +import {ErrorWithCause} from "pony-cause"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -419,8 +420,7 @@ ${sources.join('\n')}`); } } } catch(e) { - client.logger.error(`Encountered error while in scrobble loop`); - client.logger.error(e); + client.logger.error(new ErrorWithCause(`Encountered error while in scrobble loop`, {cause: e})); // for now just stop scrobbling plays for this client and move on. the client should deal with logging the issue if(e.continueScrobbling !== true) { break; diff --git a/src/common/logging.ts b/src/common/logging.ts index f732c121..fc8c0548 100644 --- a/src/common/logging.ts +++ b/src/common/logging.ts @@ -4,11 +4,14 @@ import winston, {format, Logger} from "winston"; import {DuplexTransport} from "winston-duplex"; import {asLogOptions, LogConfig, LogInfo, LogLevel, LogOptions} from "./infrastructure/Atomic.js"; import process from "process"; -import {fileOrDirectoryIsWriteable} from "../utils.js"; -import {ErrorWithCause} from "pony-cause"; +import {fileOrDirectoryIsWriteable, truncateStringToLength} from "../utils.js"; +import {ErrorWithCause, stackWithCauses} from "pony-cause"; import {NullTransport} from 'winston-null'; import 'winston-daily-rotate-file'; import dayjs from "dayjs"; +import stringify from 'safe-stable-stringify'; +import {SPLAT, LEVEL, MESSAGE} from 'triple-beam'; +import {Symbol} from "typescript-json-schema"; const {combine, printf, timestamp, label, splat, errors} = format; @@ -117,25 +120,28 @@ export const formatLogToHtml = (chunk: any) => { return line; } +const levelSymbol = Symbol.for('level'); const s = splat(); -const SPLAT = Symbol.for('splat') -const errorsFormat = errors({stack: true}); +//const errorsFormat = errors({stack: true}); const CWD = process.cwd(); -let longestLabel = 3; +const causeKeys = ['name', 'cause'] + export const defaultFormat = (defaultLabel = 'App') => printf(({ + label, + [levelSymbol]: levelSym, level, message, labels = [defaultLabel], leaf, timestamp, durationMs, - // @ts-ignore [SPLAT]: splatObj, stack, ...rest }) => { - let stringifyValue = splatObj !== undefined ? JSON.stringify(splatObj) : ''; + const keys = Object.keys(rest); + let stringifyValue = keys.length > 0 && !keys.every(x => causeKeys.some(y => y == x)) ? stringify(rest) : ''; let msg = message; let stackMsg = ''; if (stack !== undefined) { @@ -172,7 +178,7 @@ export const labelledFormat = (labelName = 'App') => { ), l, s, - errorsFormat, + errorAwareFormat, defaultFormat(labelName), ); } @@ -208,3 +214,128 @@ export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: LogLevel) } return level <= minLevel; } + +const isProbablyError = (val: any, explicitErrorName?: string) => { + if(typeof val !== 'object' || val === null) { + return false; + } + const {name, stack} = val; + if(explicitErrorName !== undefined) { + if(name !== undefined && name.toLowerCase().includes(explicitErrorName)) { + return true; + } + if(stack !== undefined && stack.trim().toLowerCase().indexOf(explicitErrorName.toLowerCase()) === 0) { + return true; + } + return false; + } else if(stack !== undefined) { + return true; + } else if(name !== undefined && name.toLowerCase().includes('error')) { + return true; + } + + return false; +} + +const errorAwareFormat = { + transform: (einfo: any, {stack = true}: any = {}) => { + + // because winston logger.child() re-assigns its input to an object ALWAYS the object we recieve here will never actually be of type Error + const includeStack = stack && (!isProbablyError(einfo, 'simpleerror') && !isProbablyError(einfo.message, 'simpleerror')); + + if (!isProbablyError(einfo.message) && !isProbablyError(einfo)) { + return einfo; + } + + let info: any = {}; + + if (isProbablyError(einfo)) { + const tinfo = transformError(einfo); + info = Object.assign({}, tinfo, { + // @ts-ignore + level: einfo.level, + // @ts-ignore + [LEVEL]: einfo[LEVEL] || einfo.level, + message: tinfo.message, + // @ts-ignore + [MESSAGE]: tinfo[MESSAGE] || tinfo.message + }); + if(includeStack) { + // so we have to create a dummy error and re-assign all error properties from our info object to it so we can get a proper stack trace + const dummyErr = new ErrorWithCause(''); + const names = Object.getOwnPropertyNames(tinfo); + for(const k of names) { + if(dummyErr.hasOwnProperty(k) || k === 'cause') { + // @ts-ignore + dummyErr[k] = tinfo[k]; + } + } + // @ts-ignore + info.stack = stackWithCauses(dummyErr); + } + } else { + const err = transformError(einfo.message); + info = Object.assign({}, einfo, err); + // @ts-ignore + info.message = err.message; + // @ts-ignore + info[MESSAGE] = err.message; + + if(includeStack) { + const dummyErr = new ErrorWithCause(''); + // Error properties are not enumerable + // https://stackoverflow.com/a/18278145/1469797 + const names = Object.getOwnPropertyNames(err); + for(const k of names) { + if(dummyErr.hasOwnProperty(k) || k === 'cause') { + // @ts-ignore + dummyErr[k] = err[k]; + } + } + // @ts-ignore + info.stack = stackWithCauses(dummyErr); + } + } + + // remove redundant message from stack and make stack causes easier to read + if(info.stack !== undefined) { + let cleanedStack = info.stack.replace(info.message, ''); + cleanedStack = `${cleanedStack}`; + cleanedStack = cleanedStack.replaceAll('caused by:', '\ncaused by:'); + info.stack = cleanedStack; + } + + return info; + } +} + +export const transformError = (err: Error): any => _transformError(err, new Set()); + +const _transformError = (err: Error, seen: Set) => { + if (!err || !isProbablyError(err)) { + return ''; + } + if (seen.has(err)) { + return err; + } + + try { + + // @ts-ignore + let mOpts = err.matchOptions ?? matchOptions; + + // @ts-ignore + const cause = err.cause as unknown; + + if (cause !== undefined && cause instanceof Error) { + // @ts-ignore + err.cause = _transformError(cause, seen, mOpts); + } + + return err; + } catch (e: any) { + // oops :( + // we're gonna swallow silently instead of reporting to avoid any infinite nesting and hopefully the original error looks funny enough to provide clues as to what to fix here + return err; + } +} -- 2.51.2 From 0dc828fa85e6a414ac68496109eb06160d327e02 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 14:08:48 -0500 Subject: [PATCH 36/41] fix: time frame should be valid if there were no recent scrobbles retrieved --- src/clients/AbstractScrobbleClient.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index 40442c8d..e02bc438 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -36,7 +36,7 @@ export default abstract class AbstractScrobbleClient { recentScrobbles: PlayObject[] = []; scrobbledPlayObjs: FixedSizeList; newestScrobbleTime?: Dayjs - oldestScrobbleTime: Dayjs = dayjs(); + oldestScrobbleTime?: Dayjs tracksScrobbled: number = 0; lastScrobbleCheck: Dayjs = dayjs(0) @@ -147,10 +147,14 @@ export default abstract class AbstractScrobbleClient { // time frame is valid as long as the play date for the source track is newer than the oldest play time from the scrobble client // ...this is assuming the scrobble client is returning "most recent" scrobbles - timeFrameIsValid = (playObj: any) => { + timeFrameIsValid = (playObj: PlayObject) => { + + if(this.oldestScrobbleTime === undefined) { + return [true, '']; + } + const { data: { - // @ts-expect-error TS(2525): Initializer provides no value for this binding ele... Remove this comment to see the full error message playDate, } = {}, } = playObj; -- 2.51.2 From d77d547c15b67f5a52c731554d13c0c68ab639fb Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 14:25:05 -0500 Subject: [PATCH 37/41] fix: fix always adding plays to newly discovered --- src/sources/AbstractSource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sources/AbstractSource.ts b/src/sources/AbstractSource.ts index 991959d6..6a2f3ccd 100644 --- a/src/sources/AbstractSource.ts +++ b/src/sources/AbstractSource.ts @@ -134,8 +134,8 @@ export default abstract class AbstractSource { for(const play of plays) { if(!this.alreadyDiscovered(play)) { this.addPlayToDiscovered(play); + newDiscoveredPlays.push(play); } - newDiscoveredPlays.push(play); } if(newDiscoveredPlays.length > 0) { -- 2.51.2 From d6b682a23a4416bc3b63a680a032c9819d24a47a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 14:26:42 -0500 Subject: [PATCH 38/41] fix: Do not prematurely return no existing scrobble when no recent scrobbles exist don't want to assume we won't find an existing just because there are no scrobbles from the client itself -- we may have found it from recent recorded scrobbles sent --- src/clients/AbstractScrobbleClient.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/clients/AbstractScrobbleClient.ts b/src/clients/AbstractScrobbleClient.ts index e02bc438..d5fc2a91 100644 --- a/src/clients/AbstractScrobbleClient.ts +++ b/src/clients/AbstractScrobbleClient.ts @@ -300,17 +300,17 @@ export default abstract class AbstractScrobbleClient { // if not though then we need to check recent scrobbles from scrobble api. // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start - // if no recent scrobbles found then assume we haven't submitted it - // (either user doesnt want to check history or there is no history to check!) - if (this.recentScrobbles.length === 0) { - if (this.verboseOptions.match.onNoMatch) { - this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`); - } - return undefined; - } - if (existingScrobble === undefined) { + // if no recent scrobbles found then assume we haven't submitted it + // (either user doesnt want to check history or there is no history to check!) + if (this.recentScrobbles.length === 0) { + if (this.verboseOptions.match.onNoMatch) { + this.logger.debug(`(Existing Check) ${buildTrackString(playObj, scoreTrackOpts)} => No Match because no recent scrobbles returned from API`); + } + return undefined; + } + // we have have found an existing submission but without an exact date // in which case we can check the scrobble api response against recent scrobbles (also from api) for a more accurate comparison const referenceApiScrobbleResponse = existingDataSubmitted.length > 0 ? existingDataSubmitted[0].scrobble : undefined; -- 2.51.2 From f792feeb6c1879b59ce7fed98b9c823aa5fdaae7 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 14:28:50 -0500 Subject: [PATCH 39/41] fix: re-add accidentally delete lastfm docs --- docs/configuration.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index db26db58..7a53293e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -470,6 +470,19 @@ http://localhost:9078/lastfm/callback ``` or replace `localhost:9078` with your own base URL +### ENV-Based + +| Environmental Variable | Required? | Default | Description | +|----------------------------|-----------|-----------------------------------------|-------------------------------| +| `LASTFM_API_KEY` | Yes | | Api Key from your API Account | +| `LASTFM_SECRET` | Yes | | Shared secret from your API Account | +| `LASTFM_REDIRECT_URI` | No | `http://localhost:9078/lastfm/callback` | Url to use for authentication. Must include `lastfm/callback` somewhere in it | +| `LASTFM_SESSION` | No | | Session id. Will be generated by authentication flow if not provided. | + +### File-Based + +See [`lastfm.json.example`](/config/lastfm.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) + ## [Listenbrainz](https://listenbrainz.org) You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/) -- 2.51.2 From 48a0ecad416fbb71b8d360c9ace99df878fff68c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 14:37:42 -0500 Subject: [PATCH 40/41] feat: Implement Listenbrainz as a client #16 --- README.md | 1 + config/listenbrainz.json.example | 1 + docs/configuration.md | 17 ++- src/clients/ListenbrainzScrobbler.ts | 7 +- src/common/infrastructure/Atomic.ts | 4 +- .../config/client/listenbrainz.ts | 11 +- .../config/source/listenbrainz.ts | 20 +++ .../infrastructure/config/source/sources.ts | 5 +- src/common/schema/aio-client.json | 16 ++- src/common/schema/aio-source.json | 104 +++++++++++++++ src/common/schema/aio.json | 120 +++++++++++++++++- src/common/schema/client.json | 16 ++- src/common/schema/source.json | 97 ++++++++++++++ src/sources/ListenbrainzSource.ts | 57 +++++++++ src/sources/ScrobbleSources.ts | 34 ++++- 15 files changed, 485 insertions(+), 25 deletions(-) create mode 100644 src/common/infrastructure/config/source/listenbrainz.ts create mode 100644 src/sources/ListenbrainzSource.ts diff --git a/README.md b/README.md index 6e43c265..55e24465 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A javascript app to scrobble plays from multiple sources to [Maloja](https://git * [Jellyfin](/docs/configuration.md#jellyfin) * [Youtube Music](/docs/configuration.md#youtube-music) * [Last.fm](/docs/configuration.md#lastfm-source) + * [Listenbrainz](/docs/configuration.md#listenbrainz--source-) * [Deezer](/docs/configuration.md#deezer) * [MPRIS (Linux Desktop)](/docs/configuration.md#mpris) * [Mopidy](/docs/configuration.md#mopidy) diff --git a/config/listenbrainz.json.example b/config/listenbrainz.json.example index 50204a33..be3ce43c 100644 --- a/config/listenbrainz.json.example +++ b/config/listenbrainz.json.example @@ -1,6 +1,7 @@ [ { "name": "brainz", + "configureAs": "client" "data": { "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b", "username": "FoxxMD" diff --git a/docs/configuration.md b/docs/configuration.md index 7a53293e..42c90a4c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,6 +10,7 @@ * [Subsonic](#subsonic) * [Jellyfin](#jellyfin) * [Last.fm (Source)](#lastfm--source-) + * [Listenbrainz (Source)](#listenbrainz--source-) * [Deezer](#deezer) * [Youtube Music](#youtube-music) * [MPRIS (Linux Desktop)](#mpris) @@ -269,6 +270,20 @@ No support for ENV based for Last.fm as a client (only source) See [`lastfm.json.example`](/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json) +# [Listenbrainz (Source)](https://listenbrainz.org) + +You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/) + +On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration. + +**NOTE:** You cannot use ENV variables shown in the [Listenbrainz Client config](#listenbrainz) -- multi-scrobbler assumes Listenbrainz ENVs are always used for the **client** configuration. You must use the file-based config from below to setup Listenbrainz as a Source. + +### File-Based + +See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FListenbrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) + +**Change `configureAs` to `source`** + ## [Deezer](https://deezer.com/) Create a new application at [Deezer Developers](https://developers.deezer.com/myapps) @@ -347,7 +362,7 @@ multi-scrobbler can listen to this interface and scrobble tracks played by **any ### File-Based -See [`mpris.json.example`](/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) +See [`mpris.json.example`](/config/mpris.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FMPRISSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json) ## [Mopidy](https://mopidy.com/) diff --git a/src/clients/ListenbrainzScrobbler.ts b/src/clients/ListenbrainzScrobbler.ts index caa588a4..2fd92cc9 100644 --- a/src/clients/ListenbrainzScrobbler.ts +++ b/src/clients/ListenbrainzScrobbler.ts @@ -30,11 +30,6 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { constructor(name: any, config: ListenBrainzClientConfig, options = {}, notifier: Notifiers, logger: Logger) { super('listenbrainz', name, config, notifier, logger); - const { - data: { - url - } = {} - } = config; this.api = new ListenbrainzApiClient(name, config.data); } @@ -63,7 +58,7 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient { try { this.authed = await this.api.testAuth(); } catch (e) { - this.logger.error('Could not successfully communicate with Last.fm API'); + this.logger.error('Could not successfully communicate with Listenbrainz API'); this.logger.error(e); this.authed = false; } diff --git a/src/common/infrastructure/Atomic.ts b/src/common/infrastructure/Atomic.ts index 769cf4a1..72ceab8b 100644 --- a/src/common/infrastructure/Atomic.ts +++ b/src/common/infrastructure/Atomic.ts @@ -3,8 +3,8 @@ import {FixedSizeList} from 'fixed-size-list'; import {MESSAGE} from 'triple-beam'; import {Logger} from "winston"; -export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy'; -export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy']; +export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz'; +export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz']; export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic']; diff --git a/src/common/infrastructure/config/client/listenbrainz.ts b/src/common/infrastructure/config/client/listenbrainz.ts index 2335c7a3..25bf1dfc 100644 --- a/src/common/infrastructure/config/client/listenbrainz.ts +++ b/src/common/infrastructure/config/client/listenbrainz.ts @@ -1,7 +1,7 @@ import {CommonClientConfig, CommonClientData} from "./index.js"; import {RequestRetryOptions} from "../common.js"; -export interface ListenBrainzClientData extends RequestRetryOptions, CommonClientData { +export interface ListenBrainzData extends RequestRetryOptions{ /** * URL for the ListenBrainz server, if not using the default * @@ -22,7 +22,16 @@ export interface ListenBrainzClientData extends RequestRetryOptions, CommonClien username: string } +export interface ListenBrainzClientData extends ListenBrainzData, CommonClientData {} + export interface ListenBrainzClientConfig extends CommonClientConfig { + /** + * Should always be `client` when using Listenbrainz as a client + * + * @default client + * @examples ["client"] + * */ + configureAs?: 'client' | 'source' data: ListenBrainzClientData } diff --git a/src/common/infrastructure/config/source/listenbrainz.ts b/src/common/infrastructure/config/source/listenbrainz.ts new file mode 100644 index 00000000..800ace9c --- /dev/null +++ b/src/common/infrastructure/config/source/listenbrainz.ts @@ -0,0 +1,20 @@ +import {CommonSourceConfig, CommonSourceData} from "./index.js"; +import {ListenBrainzData} from "../client/listenbrainz.js"; + +export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData { +} + +export interface ListenBrainzSourceConfig extends CommonSourceConfig { + /** + * When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client. + * + * @default source + * @examples ["source"] + * */ + configureAs?: 'source' + data: ListenBrainzSourceData +} + +export interface ListenBrainzSourceAIOConfig extends ListenBrainzSourceConfig { + type: 'listenbrainz' +} diff --git a/src/common/infrastructure/config/source/sources.ts b/src/common/infrastructure/config/source/sources.ts index 8322b68e..0b3d9f05 100644 --- a/src/common/infrastructure/config/source/sources.ts +++ b/src/common/infrastructure/config/source/sources.ts @@ -8,7 +8,8 @@ import {LastFmSouceAIOConfig, LastfmSourceConfig} from "./lastfm.js"; import {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.js"; import {MPRISSourceAIOConfig, MPRISSourceConfig} from "./mpris.js"; import {MopidySourceAIOConfig, MopidySourceConfig} from "./mopidy.js"; +import {ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig} from "./listenbrainz.js"; -export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig; +export type SourceConfig = SpotifySourceConfig | PlexSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig | JellySourceConfig | LastfmSourceConfig | YTMusicSourceConfig | MPRISSourceConfig | MopidySourceConfig | ListenBrainzSourceConfig; -export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig; +export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig | JellySourceAIOConfig | LastFmSouceAIOConfig | YTMusicSourceAIOConfig | MPRISSourceAIOConfig | MopidySourceAIOConfig | ListenBrainzSourceAIOConfig; diff --git a/src/common/schema/aio-client.json b/src/common/schema/aio-client.json index c04d12c4..452ba749 100644 --- a/src/common/schema/aio-client.json +++ b/src/common/schema/aio-client.json @@ -7,10 +7,10 @@ "$ref": "#/definitions/LastfmClientAIOConfig" }, { - "$ref": "#/definitions/MalojaClientAIOConfig" + "$ref": "#/definitions/ListenBrainzClientAIOConfig" }, { - "$ref": "#/definitions/ListenBrainzClientAIOConfig" + "$ref": "#/definitions/MalojaClientAIOConfig" } ] }, @@ -164,6 +164,18 @@ }, "ListenBrainzClientAIOConfig": { "properties": { + "configureAs": { + "default": "client", + "description": "Should always be `client` when using Listenbrainz as a client", + "enum": [ + "client", + "source" + ], + "examples": [ + "client" + ], + "type": "string" + }, "data": { "$ref": "#/definitions/ListenBrainzClientData", "description": "Specific data required to configure this client" diff --git a/src/common/schema/aio-source.json b/src/common/schema/aio-source.json index daa59da3..2b2f214b 100644 --- a/src/common/schema/aio-source.json +++ b/src/common/schema/aio-source.json @@ -328,6 +328,107 @@ ], "type": "object" }, + "ListenBrainzSourceAIOConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "configureAs": { + "default": "source", + "description": "When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + "enum": [ + "source" + ], + "examples": [ + "source" + ], + "type": "string" + }, + "data": { + "$ref": "#/definitions/ListenBrainzSourceData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + }, + "type": { + "enum": [ + "listenbrainz" + ], + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "type": "object" + }, + "ListenBrainzSourceData": { + "properties": { + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "token": { + "description": "User token for the user to scrobble for", + "examples": [ + "6794186bf-1157-4de6-80e5-uvb411f3ea2b" + ], + "type": "string" + }, + "url": { + "default": "https://api.listenbrainz.org/", + "description": "URL for the ListenBrainz server, if not using the default", + "examples": [ + "https://api.listenbrainz.org/" + ], + "type": "string" + }, + "username": { + "description": "Username of the user to scrobble for", + "type": "string" + } + }, + "required": [ + "token", + "username" + ], + "type": "object" + }, "MPRISData": { "properties": { "blacklist": { @@ -716,6 +817,9 @@ }, { "$ref": "#/definitions/MopidySourceAIOConfig" + }, + { + "$ref": "#/definitions/ListenBrainzSourceAIOConfig" } ] }, diff --git a/src/common/schema/aio.json b/src/common/schema/aio.json index 3ab31e01..ba208272 100644 --- a/src/common/schema/aio.json +++ b/src/common/schema/aio.json @@ -7,10 +7,10 @@ "$ref": "#/definitions/LastfmClientAIOConfig" }, { - "$ref": "#/definitions/MalojaClientAIOConfig" + "$ref": "#/definitions/ListenBrainzClientAIOConfig" }, { - "$ref": "#/definitions/ListenBrainzClientAIOConfig" + "$ref": "#/definitions/MalojaClientAIOConfig" } ] }, @@ -534,6 +534,18 @@ }, "ListenBrainzClientAIOConfig": { "properties": { + "configureAs": { + "default": "client", + "description": "Should always be `client` when using Listenbrainz as a client", + "enum": [ + "client", + "source" + ], + "examples": [ + "client" + ], + "type": "string" + }, "data": { "$ref": "#/definitions/ListenBrainzClientData", "description": "Specific data required to configure this client" @@ -633,6 +645,107 @@ ], "type": "object" }, + "ListenBrainzSourceAIOConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "configureAs": { + "default": "source", + "description": "When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + "enum": [ + "source" + ], + "examples": [ + "source" + ], + "type": "string" + }, + "data": { + "$ref": "#/definitions/ListenBrainzSourceData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + }, + "type": { + "enum": [ + "listenbrainz" + ], + "type": "string" + } + }, + "required": [ + "data", + "type" + ], + "type": "object" + }, + "ListenBrainzSourceData": { + "properties": { + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "token": { + "description": "User token for the user to scrobble for", + "examples": [ + "6794186bf-1157-4de6-80e5-uvb411f3ea2b" + ], + "type": "string" + }, + "url": { + "default": "https://api.listenbrainz.org/", + "description": "URL for the ListenBrainz server, if not using the default", + "examples": [ + "https://api.listenbrainz.org/" + ], + "type": "string" + }, + "username": { + "description": "Username of the user to scrobble for", + "type": "string" + } + }, + "required": [ + "token", + "username" + ], + "type": "object" + }, "LogLevel": { "enum": [ "debug", @@ -1283,6 +1396,9 @@ }, { "$ref": "#/definitions/MopidySourceAIOConfig" + }, + { + "$ref": "#/definitions/ListenBrainzSourceAIOConfig" } ] }, diff --git a/src/common/schema/client.json b/src/common/schema/client.json index d3c07cdc..e81a1a0e 100644 --- a/src/common/schema/client.json +++ b/src/common/schema/client.json @@ -5,10 +5,10 @@ "$ref": "#/definitions/LastfmClientConfig" }, { - "$ref": "#/definitions/MalojaClientConfig" + "$ref": "#/definitions/ListenBrainzClientConfig" }, { - "$ref": "#/definitions/ListenBrainzClientConfig" + "$ref": "#/definitions/MalojaClientConfig" } ], "definitions": { @@ -155,6 +155,18 @@ }, "ListenBrainzClientConfig": { "properties": { + "configureAs": { + "default": "client", + "description": "Should always be `client` when using Listenbrainz as a client", + "enum": [ + "client", + "source" + ], + "examples": [ + "client" + ], + "type": "string" + }, "data": { "$ref": "#/definitions/ListenBrainzClientData", "description": "Specific data required to configure this client" diff --git a/src/common/schema/source.json b/src/common/schema/source.json index 04ded56f..a3107114 100644 --- a/src/common/schema/source.json +++ b/src/common/schema/source.json @@ -30,6 +30,9 @@ }, { "$ref": "#/definitions/MopidySourceConfig" + }, + { + "$ref": "#/definitions/ListenBrainzSourceConfig" } ], "definitions": { @@ -339,6 +342,100 @@ ], "type": "object" }, + "ListenBrainzSourceConfig": { + "properties": { + "clients": { + "description": "Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients.", + "examples": [ + [ + "MyMalojaConfigName", + "MyLastFMConfigName" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "configureAs": { + "default": "source", + "description": "When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + "enum": [ + "source" + ], + "examples": [ + "source" + ], + "type": "string" + }, + "data": { + "$ref": "#/definitions/ListenBrainzSourceData" + }, + "name": { + "description": "Unique identifier for this source.", + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "ListenBrainzSourceData": { + "properties": { + "maxPollRetries": { + "default": 0, + "description": "default # of automatic polling restarts on error", + "examples": [ + 1 + ], + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source can make before error is thrown", + "examples": [ + 1 + ], + "type": "number" + }, + "options": { + "$ref": "#/definitions/Record" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "type": "number" + }, + "token": { + "description": "User token for the user to scrobble for", + "examples": [ + "6794186bf-1157-4de6-80e5-uvb411f3ea2b" + ], + "type": "string" + }, + "url": { + "default": "https://api.listenbrainz.org/", + "description": "URL for the ListenBrainz server, if not using the default", + "examples": [ + "https://api.listenbrainz.org/" + ], + "type": "string" + }, + "username": { + "description": "Username of the user to scrobble for", + "type": "string" + } + }, + "required": [ + "token", + "username" + ], + "type": "object" + }, "MPRISData": { "properties": { "blacklist": { diff --git a/src/sources/ListenbrainzSource.ts b/src/sources/ListenbrainzSource.ts new file mode 100644 index 00000000..092ae581 --- /dev/null +++ b/src/sources/ListenbrainzSource.ts @@ -0,0 +1,57 @@ +import AbstractSource, {RecentlyPlayedOptions} from "./AbstractSource.js"; +import {FormatPlayObjectOptions, INITIALIZING, InternalConfig} from "../common/infrastructure/Atomic.js"; +import EventEmitter from "events"; +import {ListenBrainzSourceConfig} from "../common/infrastructure/config/source/listenbrainz.js"; +import {ListenbrainzApiClient} from "../apis/ListenbrainzApiClient.js"; + +export default class ListenbrainzSource extends AbstractSource { + + api: ListenbrainzApiClient; + requiresAuth = true; + requiresAuthInteraction = false; + + declare config: ListenBrainzSourceConfig; + + constructor(name: any, config: ListenBrainzSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + super('listenbrainz', name, config, internal, emitter); + this.canPoll = true; + this.api = new ListenbrainzApiClient(name, config.data); + } + + static formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options); + + initialize = async () => { + // @ts-expect-error TS(2322): Type 'number' is not assignable to type 'boolean'. + this.initialized = INITIALIZING; + if(this.config.data.token === undefined) { + this.logger.error('Must provide a User Token'); + this.initialized = false; + } else { + try { + await this.api.testConnection(); + this.initialized = true; + } catch (e) { + this.logger.error(e); + this.initialized = false; + } + } + return this.initialized; + } + + testAuth = async () => { + try { + this.authed = await this.api.testAuth(); + } catch (e) { + this.logger.error('Could not successfully communicate with Listenbrainz API'); + this.logger.error(e); + this.authed = false; + } + return this.authed; + } + + + getRecentlyPlayed = async(options: RecentlyPlayedOptions = {}) => { + const {limit = 20} = options; + return await this.api.getRecentlyPlayed(limit); + } +} diff --git a/src/sources/ScrobbleSources.ts b/src/sources/ScrobbleSources.ts index 0c842dd4..453f6dab 100644 --- a/src/sources/ScrobbleSources.ts +++ b/src/sources/ScrobbleSources.ts @@ -29,6 +29,8 @@ import {MPRISSource} from "./MPRISSource.js"; import EventEmitter from "events"; import {MopidySource} from "./MopidySource.js"; import {MopidySourceConfig} from "../common/infrastructure/config/source/mopidy.js"; +import ListenbrainzSource from "./ListenbrainzSource.js"; +import {ListenBrainzSourceConfig} from "../common/infrastructure/config/source/listenbrainz.js"; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -253,6 +255,10 @@ export default class ScrobbleSources { }); } break; + case 'listenbrainz': + // sane default for lastfm is that user want to scrobble TO it, not FROM it -- this is also existing behavior + defaultConfigureAs = 'client'; + break; default: break; } @@ -280,14 +286,25 @@ export default class ScrobbleSources { try { const validConfig = validateJson(rawConf, sourceSchema, this.logger); - if(sourceType !== 'lastfm' || ((validConfig as LastfmSourceConfig).configureAs === 'source')) { - // @ts-ignore - const parsedConfig: ParsedConfig = { - ...rawConf, - source: `${sourceType}.json`, - type: sourceType - } + // @ts-ignore + const parsedConfig: ParsedConfig = { + ...rawConf, + source: `${sourceType}.json`, + type: sourceType + } + + if(!['lastfm','listenbrainz'].includes(sourceType) || ((validConfig as LastfmSourceConfig | ListenBrainzSourceConfig).configureAs === 'source')) { configs.push(parsedConfig); + } else { + if('configureAs' in validConfig) { + if(validConfig.configureAs === 'source') { + configs.push(parsedConfig); + } else { + this.logger.debug(`${sourceType} has 'configureAs: client' so will skip adding as a source`); + } + } else { + this.logger.debug(`${sourceType} did not have 'configureAs' specified! Assuming 'client' so will skip adding as a source`); + } } } catch (e: any) { this.logger.error(`The config entry at index ${i} from ${sourceType}.json was not valid`); @@ -402,6 +419,9 @@ export default class ScrobbleSources { case 'mopidy': newSource = await new MopidySource(name, compositeConfig as MopidySourceConfig, internal, this.emitter); break; + case 'listenbrainz': + newSource = await new ListenbrainzSource(name, compositeConfig as ListenBrainzSourceConfig, internal, this.emitter); + break; default: break; } -- 2.51.2 From 1207a5eec1eef6e714a1022de9c80cb3a22cc32b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 10 Mar 2023 14:58:27 -0500 Subject: [PATCH 41/41] docs: Update wording --- README.md | 22 +++++++++++----------- docs/configuration.md | 4 ++-- docs/installation.md | 2 ++ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 55e24465..2f7f9aaf 100644 --- a/README.md +++ b/README.md @@ -4,40 +4,40 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/multi-scrobbler)](https://hub.docker.com/r/foxxmd/multi-scrobbler) -A javascript app to scrobble plays from multiple sources to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and other clients +A javascript app to scrobble music you listened to, to [Maloja](https://github.com/krateng/maloja), [Last.fm](https://www.last.fm), and [ListenBrainz](https://listenbrainz.org) -* Supports scrobbling for many sources +* Supports scrobbling from many **Sources** * [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) * [Youtube Music](/docs/configuration.md#youtube-music) * [Last.fm](/docs/configuration.md#lastfm-source) - * [Listenbrainz](/docs/configuration.md#listenbrainz--source-) + * [ListenBrainz](/docs/configuration.md#listenbrainz--source-) * [Deezer](/docs/configuration.md#deezer) * [MPRIS (Linux Desktop)](/docs/configuration.md#mpris) * [Mopidy](/docs/configuration.md#mopidy) -* Supports scrobbling to many clients +* Supports scrobbling to many **Clients** * [Maloja](/docs/configuration.md#maloja) * [Last.fm](/docs/configuration.md#lastfm) - * [Listenbrainz](/docs/configuration.md#listenbrainz) -* Monitor status of sources and clients using [webhooks (Gotify or Ntfy)](/docs/configuration.md#webhook-configurations) or [healthcheck endpoint](/docs/configuration.md#health-endpoint) + * [ListenBrainz](/docs/configuration.md#listenbrainz) +* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docs/configuration.md#webhook-configurations) or [healthcheck endpoint](/docs/configuration.md#health-endpoint) * Supports configuring for single or multiple users (scrobbling for your friends and family!) * Web server interface for stats, basic control, and detailed logs * Smart handling of credentials (persistent, authorization through app) * Easy configuration through ENVs or JSON -* Built for Docker and unattended use! +* Docker images for x86/ARM **Why should I use this over a browser extension and/or mobile app scrobbler?** * **Platform independent** -- Because multi-scrobbler communicates directly with service APIs it will scrobble everything you play regardless of where you play it. No more need for apps on every platform you use! * **Open-source** -- Get peace of mind knowing exactly how your personal data is being handled. -* **Consolidate play sources** -- Scrobble from many sources to one client with ease and without duplicating tracks. +* **Track your activity regardless of where you listen** -- Scrobble from many Sources to one Client with ease and without duplicating tracks. * **Manage scrobbling for others** -- Scrobble for your friends and family without any setup on their part. Easily silo sources to specific clients to keep plays separate. -**But I already scrobble my music to Last.fm, is multi-scrobbler for me?** +**But I already scrobble my music to Last.fm/ListenBrainz, is multi-scrobbler for me?** -Yes! You can use [Last.fm as a **Source**](/docs/configuration.md#lastfm--source-) to mirror scrobbles from your Last.fm profile to Maloja. That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location. +Yes! You can use [Last.fm as a **Source**](/docs/configuration.md#lastfm--source-) or [Listenbrainz as a **Source**](/docs/configuration.md#listenbrainz--source-) to forward scrobbles from your profile to any other Client! That way you can keep your current scrobble setup as-is but still get the benefit of capturing your data to a self-hosted location. @@ -47,7 +47,7 @@ You set up configurations for one or more **Sources** and one or more **Clients* ### Source -A **Source** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Airsonic**, etc... +A **Source** is a data source that contains information about tracks you are playing like a music player or platform. Examples are **Spotify, Jellyfin, Plex, Youtube Music, Airsonic**, etc... Source configurations consist of: diff --git a/docs/configuration.md b/docs/configuration.md index 42c90a4c..55552a49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -280,7 +280,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token ### File-Based -See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FListenbrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) +See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json) **Change `configureAs` to `source`** @@ -515,7 +515,7 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token ### File-Based -See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FListenbrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) +See [`listenbrainz.json.example`](/config/listenbrainz.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FListenBrainzClientConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fclient.json) # Monitoring diff --git a/docs/installation.md b/docs/installation.md index e12a7153..5b37edbc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -31,6 +31,8 @@ SPOTIFY_CLIENT_ID=yourId SPOTIFY_CLIENT_SECRET=yourSecret MALOJA_URL="http://dom ## [Docker](https://hub.docker.com/r/foxxmd/multi-scrobbler) +Cross-platform images are built for x86 (Intel/AMD) and ARM (IE Raspberry Pi) + ``` foxxmd/multi-scrobbler:latest ``` -- 2.51.2