diff --git a/package-lock.json b/package-lock.json index 3f3b88f4..ae949689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "toad-scheduler": "^3.0.0", "tsx": "^4.7.0", "vite-express": "^0.16.0", + "vlc-client": "^1.1.1", "xml2js": "0.5.0", "youtube-music-ts-api": "^1.7.0" }, @@ -3748,6 +3749,14 @@ "protobufjs": "^6.8.8" } }, + "node_modules/centra": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", + "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", + "dependencies": { + "follow-redirects": "^1.15.6" + } + }, "node_modules/chai": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", @@ -8177,6 +8186,17 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/phin": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", + "integrity": "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==", + "dependencies": { + "centra": "^2.7.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", @@ -10588,6 +10608,14 @@ "picocolors": "^1.0.0" } }, + "node_modules/vlc-client": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vlc-client/-/vlc-client-1.1.1.tgz", + "integrity": "sha512-tnxEyNdRUe8mcQFgrwDV2uOmWKRCH2d+UVunYD7cUwItR68qSA2Q/Ih3eh6i3BaF/7p+AKy1RnwRUcfBwuUM+w==", + "dependencies": { + "phin": "^3.6.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0c321b65..22a69a3e 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "toad-scheduler": "^3.0.0", "tsx": "^4.7.0", "vite-express": "^0.16.0", + "vlc-client": "^1.1.1", "xml2js": "0.5.0", "youtube-music-ts-api": "^1.7.0" }, diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 1ae13a7c..73220e06 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -24,7 +24,8 @@ export type SourceType = | 'webscrobbler' | 'chromecast' | 'musikcube' - | 'mpd'; + | 'mpd' + | 'vlc'; export const sourceTypes: SourceType[] = [ 'spotify', @@ -43,7 +44,8 @@ export const sourceTypes: SourceType[] = [ 'webscrobbler', 'chromecast', 'musikcube', - 'mpd' + 'mpd', + 'vlc' ]; export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 48f95e08..d4a4417f 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -13,6 +13,7 @@ import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js"; import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js"; import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js"; import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js"; +import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js"; import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js"; import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js"; @@ -34,7 +35,8 @@ export type SourceConfig = | WebScrobblerSourceConfig | ChromecastSourceConfig | MusikcubeSourceConfig - | MPDSourceConfig; + | MPDSourceConfig + | VLCSourceConfig; export type SourceAIOConfig = SpotifySourceAIOConfig @@ -53,4 +55,5 @@ export type SourceAIOConfig = | WebScrobblerSourceAIOConfig | ChromecastSourceAIOConfig | MusikcubeSourceAIOConfig - | MPDSourceAIOConfig; + | MPDSourceAIOConfig + | VLCSourceAIOConfig; diff --git a/src/backend/common/infrastructure/config/source/vlc.ts b/src/backend/common/infrastructure/config/source/vlc.ts new file mode 100644 index 00000000..0221fb9a --- /dev/null +++ b/src/backend/common/infrastructure/config/source/vlc.ts @@ -0,0 +1,53 @@ +import { VlcMeta } from "vlc-client/dist/Types.js"; +import { PollingOptions } from "../common.js"; +import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"; + +export interface VLCData extends CommonSourceData, PollingOptions { + /** + * URL:PORT of the VLC server to connect to + * + * To use this you must have Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc + * + * @examples ["localhost:8080"] + * @default "localhost:8080" + * */ + url?: string + + /** + * Password for the server + * */ + password: string + +} + +export interface VLCSourceOptions extends CommonSourceOptions { +} + +export interface VLCSourceConfig extends CommonSourceConfig { + data: VLCData + options: VLCSourceOptions +} + +export interface VLCSourceAIOConfig extends VLCSourceConfig { + type: 'vlc' +} + +export type PlayerState = 'playing' | 'stopped' | 'paused'; + +// if not provided the value is an EMPTY STRING or undefined +export interface VlcAudioMeta extends VlcMeta { + track_id?: string + date?: string + description?: string + album?: string + genre?: string + title?: string + artist?: string + /** alt for artist */ + Writer?: string + ALBUMARTIST?: string + artwork_url?: string + StreamTitle?: string + StreamArtist?: string + StreamAlbum?: string +} diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 3cd6e46a..ecf4857f 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -20,6 +20,7 @@ import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/s import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js"; import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js"; import { TautulliSourceConfig } from "../common/infrastructure/config/source/tautulli.js"; +import { VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js"; import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; import * as aioSchema from "../common/schema/aio-source.json"; @@ -42,6 +43,7 @@ import PlexSource from "./PlexSource.js"; import SpotifySource from "./SpotifySource.js"; import { SubsonicSource } from "./SubsonicSource.js"; import TautulliSource from "./TautulliSource.js"; +import { VLCSource } from "./VLCSource.js"; import { WebScrobblerSource } from "./WebScrobblerSource.js"; import YTMusicSource from "./YTMusicSource.js"; @@ -548,6 +550,9 @@ export default class ScrobbleSources { case 'mpd': newSource = await new MPDSource(name, compositeConfig as MPDSourceConfig, internal, this.emitter); break; + case 'vlc': + newSource = await new VLCSource(name, compositeConfig as VLCSourceConfig, internal, this.emitter); + break; default: break; } diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts new file mode 100644 index 00000000..4f61bde0 --- /dev/null +++ b/src/backend/sources/VLCSource.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from "events"; +import * as VLC from "vlc-client" +import { VlcMeta, VlcStatus } from "vlc-client/dist/Types.js"; +import { PlayObject } from "../../core/Atomic.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + PlayerStateData, + REPORTED_PLAYER_STATUSES, + ReportedPlayerStatus, + SINGLE_USER_PLATFORM_ID, +} from "../common/infrastructure/Atomic.js"; +import { VlcAudioMeta, VLCSourceConfig, PlayerState } from "../common/infrastructure/config/source/vlc.js"; +import { isPortReachable } from "../utils/NetworkUtils.js"; +import { RecentlyPlayedOptions } from "./AbstractSource.js"; +import MemorySource from "./MemorySource.js"; + +const CLIENT_PLAYER_STATE: Record = { + 'playing': REPORTED_PLAYER_STATUSES.playing, + 'paused': REPORTED_PLAYER_STATUSES.paused, + 'stopped': REPORTED_PLAYER_STATUSES.stopped, +} + +export class VLCSource extends MemorySource { + declare config: VLCSourceConfig; + + host?: string + port?: number + client!: VLC.Client; + deviceId: string + + constructor(name: any, config: VLCSourceConfig, internal: InternalConfig, emitter: EventEmitter) { + const { + data, + } = config; + const { + interval = 5, // reduced polling interval because its likely we are on the same network + ...rest + } = data || {}; + super('vlc', name, {...config, data: {...rest, interval}}, internal, emitter); + + this.requiresAuth = true; + this.canPoll = true; + } + + static parseConnectionUrl(valRaw: string): [string, string] { + if(valRaw.trim() === '') { + throw new Error(`'url' cannot be an empty string`); + } + + const [host, port] = valRaw.trim().split(':'); + return [host, port ?? '8080']; + } + + protected async doBuildInitData(): Promise { + const { + data: { + url, + password, + } = {} + } = this.config; + + const [host, port] = VLCSource.parseConnectionUrl(url ?? 'localhost:8080'); + this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`); + this.host = host; + this.port = Number.parseInt(port); + this.client = new VLC.Client({ + ip: host, + port: this.port, + password: password + }); + return true; + } + + protected async doCheckConnection(): Promise { + if(this.host !== undefined) { + try { + await isPortReachable(this.port, {host: this.host}); + return `${this.host}:${this.port} is reachable.`; + } catch (e) { + throw e; + } + } + return null; + } + + doAuthentication = async () => { + try { + const status = await this.client.status(); + this.logger.info(`Connected successfully, found VLC ${status.version}`); + return true; + } catch (e) { + let friendlyError: string | undefined; + throw new Error(`Could not connect to VLC server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e}); + } + } + + formatPlayObj(obj: VlcAudioMeta, options: FormatPlayObjectOptions = {}): PlayObject { + + let vlcState: VlcStatus; + const { + vlcStatus, + } = options; + + if(vlcStatus !== undefined) { + vlcState = vlcStatus as VlcStatus; + } + + const { + filename, + title, + album, + ALBUMARTIST, + artist + } = obj; + + let artists = []; + let albumArtists = []; + if(artist !== undefined && artist.trim() !== '') { + artists.push(artist); + } + if(ALBUMARTIST !== undefined && ALBUMARTIST.trim() !== '' && ALBUMARTIST !== artist) { + albumArtists.push(ALBUMARTIST); + } + if(artists.length === 0 && albumArtists.length !== 0) { + // switch these, tags are probably improper + artists = albumArtists; + albumArtists = []; + } + + let trackName = title; + if(trackName === undefined && filename !== undefined) { + trackName = filename; + } + + const { + /** time position within the current track */ + time, + length, + /** percent as decimal within current track*/ + position, + state + } = vlcState || {}; + + return { + data: { + artists: artists, + albumArtists, + album, + track: trackName, + duration: length + }, + meta: { + trackProgressPosition: time, + } + } + } + + getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { + + let state: VlcStatus; + let meta: VlcMeta; + try { + state = await this.client.status(); + meta = await this.client.meta(); + } catch (e) { + this.connectionOK = false; + this.authed = false; + throw e; + } + + let play: PlayObject | undefined; + if(meta !== undefined) { + play = this.formatPlayObj(meta, {vlcStatus: state}); + } + + const playerState: PlayerStateData = { + platformId: SINGLE_USER_PLATFORM_ID, + status: CLIENT_PLAYER_STATE[state.state], + play, + position: state.time + } + + return this.processRecentPlays([playerState]); + } + +} -- 2.51.2 From 4206696f0d418ca907f4c8c3674dbafc31c48231 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 22 Aug 2024 15:07:47 -0400 Subject: [PATCH 2/8] feat(vlc): Better metadata parsing --- src/backend/sources/VLCSource.ts | 19 +++++++++++-------- src/backend/utils/StringUtils.ts | 11 +++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 4f61bde0..138e7477 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -12,6 +12,7 @@ import { } from "../common/infrastructure/Atomic.js"; import { VlcAudioMeta, VLCSourceConfig, PlayerState } from "../common/infrastructure/config/source/vlc.js"; import { isPortReachable } from "../utils/NetworkUtils.js"; +import { firstNonEmptyStr } from "../utils/StringUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import MemorySource from "./MemorySource.js"; @@ -111,16 +112,21 @@ export class VLCSource extends MemorySource { title, album, ALBUMARTIST, + Writer, + StreamArtist, + StreamTitle, artist } = obj; let artists = []; let albumArtists = []; - if(artist !== undefined && artist.trim() !== '') { - artists.push(artist); + const validArtist = firstNonEmptyStr([artist, StreamArtist, ALBUMARTIST, Writer]); + if(artist !== undefined) { + artists.push(validArtist); } - if(ALBUMARTIST !== undefined && ALBUMARTIST.trim() !== '' && ALBUMARTIST !== artist) { - albumArtists.push(ALBUMARTIST); + const aa = firstNonEmptyStr([ALBUMARTIST]); + if(aa !== undefined) { + albumArtists.push(aa); } if(artists.length === 0 && albumArtists.length !== 0) { // switch these, tags are probably improper @@ -128,10 +134,7 @@ export class VLCSource extends MemorySource { albumArtists = []; } - let trackName = title; - if(trackName === undefined && filename !== undefined) { - trackName = filename; - } + const trackName = firstNonEmptyStr([title, StreamTitle, filename]); const { /** time position within the current track */ diff --git a/src/backend/utils/StringUtils.ts b/src/backend/utils/StringUtils.ts index 278f79dc..62410c1b 100644 --- a/src/backend/utils/StringUtils.ts +++ b/src/backend/utils/StringUtils.ts @@ -325,3 +325,14 @@ export const compareNormalizedStrings = (existing: string, candidate: string): S strategies: [levenStrategy, diceStrategy] }) } + +export const firstNonEmptyStr = (vals: unknown[]): string | undefined => { + for(const val of vals) { + if(val !== undefined && val !== null && typeof val !== 'object') { + const strVal = val.toString(); + if(strVal.trim() !== '') { + return strVal; + } + } + } +} -- 2.51.2 From bf71f8cf86bd0bc4cefa934ec3b8bd56d24dae94 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 22 Aug 2024 16:44:08 -0400 Subject: [PATCH 3/8] feat(vlc): Use regex from configuration to extract metadata from filenames --- .../infrastructure/config/source/vlc.ts | 21 +++++- src/backend/sources/VLCSource.ts | 73 ++++++++++++++++++- src/backend/utils.ts | 2 +- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/backend/common/infrastructure/config/source/vlc.ts b/src/backend/common/infrastructure/config/source/vlc.ts index 0221fb9a..dab26cd4 100644 --- a/src/backend/common/infrastructure/config/source/vlc.ts +++ b/src/backend/common/infrastructure/config/source/vlc.ts @@ -6,7 +6,7 @@ export interface VLCData extends CommonSourceData, PollingOptions { /** * URL:PORT of the VLC server to connect to * - * To use this you must have Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc + * To use this you must have the Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc * * @examples ["localhost:8080"] * @default "localhost:8080" @@ -21,6 +21,25 @@ export interface VLCData extends CommonSourceData, PollingOptions { } export interface VLCSourceOptions extends CommonSourceOptions { + /** A list of regular expressions to use to extract metadata (title, album, artist) from a filename + * + * Used when VLC reports only the filename for the current audio track + * */ + filenamePatterns?: string[] + /** + * Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns + * + * @default false + * */ + logFilenamePatterns?: boolean + /** + * Dump all the metadata VLC reports for an audio track to DEBUG. + * + * Use this if reporting an issue with VLC not correctly capturing metadata for a track. + * + * @default false + * */ + dumpVlcMetadata?: boolean } export interface VLCSourceConfig extends CommonSourceConfig { diff --git a/src/backend/sources/VLCSource.ts b/src/backend/sources/VLCSource.ts index 138e7477..6d394039 100644 --- a/src/backend/sources/VLCSource.ts +++ b/src/backend/sources/VLCSource.ts @@ -1,3 +1,4 @@ +import { parseRegexSingle, parseToRegex } from "@foxxmd/regex-buddy-core"; import { EventEmitter } from "events"; import * as VLC from "vlc-client" import { VlcMeta, VlcStatus } from "vlc-client/dist/Types.js"; @@ -29,6 +30,7 @@ export class VLCSource extends MemorySource { port?: number client!: VLC.Client; deviceId: string + filenamePatterns: RegExp[] = []; constructor(name: any, config: VLCSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { @@ -58,6 +60,9 @@ export class VLCSource extends MemorySource { data: { url, password, + } = {}, + options: { + filenamePatterns = [] } = {} } = this.config; @@ -70,6 +75,21 @@ export class VLCSource extends MemorySource { port: this.port, password: password }); + + let fp = filenamePatterns; + if(typeof filenamePatterns === 'string') { + fp = [filenamePatterns]; + } + if(fp.length > 0) { + for(const p of fp) { + const reg = parseToRegex(p); + if(reg === undefined) { + throw new Error(`filenamePattern could not be parsed as a valid Regex => ${p}`); + } + this.filenamePatterns.push(reg); + } + } + return true; } @@ -110,7 +130,7 @@ export class VLCSource extends MemorySource { const { filename, title, - album, + album: albumVal, ALBUMARTIST, Writer, StreamArtist, @@ -134,7 +154,53 @@ export class VLCSource extends MemorySource { albumArtists = []; } - const trackName = firstNonEmptyStr([title, StreamTitle, filename]); + let album = albumVal; + + let trackName = firstNonEmptyStr([title, StreamTitle]); + if (trackName === undefined && album === undefined && artists.length === 0 && albumArtists.length === 0 && this.filenamePatterns.length > 0) { + const { + options: { + logFilenamePatterns = false + } = {} + } = this.config; + + let anyMatched = false; + for (const reg of this.filenamePatterns) { + const result = parseRegexSingle(reg, filename); + const matchedPatternDebug: Record = {}; + if (result !== undefined) { + anyMatched = true; + if (result.named.title !== undefined) { + trackName = result.named.title; + matchedPatternDebug.title = trackName; + } + if (result.named.artist !== undefined) { + artists.push(result.named.artist); + matchedPatternDebug.artist = result.named.artist; + } + if (result.named.album !== undefined) { + album = result.named.album; + matchedPatternDebug.album = album; + } + + if (logFilenamePatterns) { + if (Object.keys(matchedPatternDebug).length > 0) { + matchedPatternDebug.filenamePattern = reg.source; + this.logger.debug(matchedPatternDebug, `No metadata found for file '${filename}' but it was matched by filenamePattern`); + } else { + this.logger.debug(`filenamePattern matched but no named groups? Should have groups named any of: title artist album | filenamePattern => ${reg.source}`); + } + } + } + } + if (!anyMatched && logFilenamePatterns) { + this.logger.debug(`no filenamePatterns matched for filename => ${filename}`); + } + } + + if(trackName === undefined) { + trackName = filename; + } const { /** time position within the current track */ @@ -175,6 +241,9 @@ export class VLCSource extends MemorySource { let play: PlayObject | undefined; if(meta !== undefined) { play = this.formatPlayObj(meta, {vlcStatus: state}); + if(this.config?.options?.dumpVlcMetadata) { + this.logger.debug(meta, 'VLC Metadata'); + } } const playerState: PlayerStateData = { diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 49945a7e..f623aeb2 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -808,7 +808,7 @@ export const configValToSearchReplace = (val: string | undefined | object): Sear throw new Error(`Value must be a string or an object containing 'search: string' and 'replace: 'string'. Given: ${val}`); } -export const isSearchAndReplace = (val: object): val is SearchAndReplaceRegExp => { +export const isSearchAndReplace = (val: unknown): val is SearchAndReplaceRegExp => { return typeof val === 'object' && ('search' in val && typeof val.search === 'string') && ('replace' in val && typeof val.replace === 'string'); -- 2.51.2 From ae935798f35d811065712cafcb796c9798d53ba0 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 22 Aug 2024 16:44:15 -0400 Subject: [PATCH 4/8] chore: Update schema --- package-lock.json | 32 +- package.json | 12 +- patches/typescript-json-schema+0.55.0.patch | 26 -- patches/typescript-json-schema+0.61.0.patch | 13 + src/backend/common/schema/aio-client.json | 194 ++++++++- src/backend/common/schema/aio-source.json | 386 +++++++++++++++++ src/backend/common/schema/aio.json | 433 +++++++++++++++++++- src/backend/common/schema/client.json | 193 ++++++++- src/backend/common/schema/source.json | 378 +++++++++++++++++ 9 files changed, 1583 insertions(+), 84 deletions(-) delete mode 100644 patches/typescript-json-schema+0.55.0.patch create mode 100644 patches/typescript-json-schema+0.61.0.patch diff --git a/package-lock.json b/package-lock.json index ae949689..c58a9344 100644 --- a/package-lock.json +++ b/package-lock.json @@ -119,7 +119,7 @@ "ts-essentials": "^9.1.2", "typescript": "^5.3.3", "typescript-eslint": "^7.0.1", - "typescript-json-schema": "~0.55", + "typescript-json-schema": "^0.61.0", "vite": "^5.2.12" }, "engines": { @@ -10243,9 +10243,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "devOptional": true, "bin": { "tsc": "bin/tsc", @@ -10287,18 +10287,18 @@ "integrity": "sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==" }, "node_modules/typescript-json-schema": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.55.0.tgz", - "integrity": "sha512-BXaivYecUdiXWWNiUqXgY6A9cMWerwmhtO+lQE7tDZGs7Mf38sORDeQZugfYOZOHPZ9ulsD+w0LWjFDOQoXcwg==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.61.0.tgz", + "integrity": "sha512-sYKnLJjvh9ljdSQkh91LM9PEzgzrgnYfvU226IauISFd+379VCti/VtAFXd00Z9euLkVffh5LRTAO8eamsxUlw==", "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", "@types/node": "^16.9.2", "glob": "^7.1.7", - "path-equal": "^1.1.2", + "path-equal": "^1.2.5", "safe-stable-stringify": "^2.2.0", "ts-node": "^10.9.1", - "typescript": "~4.8.2", + "typescript": "~5.1.0", "yargs": "^17.1.1" }, "bin": { @@ -10306,9 +10306,9 @@ } }, "node_modules/typescript-json-schema/node_modules/@types/node": { - "version": "16.18.98", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.98.tgz", - "integrity": "sha512-fpiC20NvLpTLAzo3oVBKIqBGR6Fx/8oAK/SSf7G+fydnXMY1x4x9RZ6sBXhqKlCU21g2QapUsbLlhv3+a7wS+Q==", + "version": "16.18.105", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.105.tgz", + "integrity": "sha512-w2d0Z9yMk07uH3+Cx0N8lqFyi3yjXZxlbYappPj+AsOlT02OyxyiuNoNHdGt6EuiSm8Wtgp2YV7vWg+GMFrvFA==", "dev": true }, "node_modules/typescript-json-schema/node_modules/cliui": { @@ -10347,16 +10347,16 @@ } }, "node_modules/typescript-json-schema/node_modules/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/typescript-json-schema/node_modules/wrap-ansi": { diff --git a/package.json b/package.json index 22a69a3e..0f127f87 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,11 @@ "description": "scrobble plays from multiple sources to multiple clients", "scripts": { "schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient", - "schema-client": "typescript-json-schema src/backend/tsconfig.json ClientConfig --out src/backend/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage", - "schema-source": "typescript-json-schema src/backend/tsconfig.json SourceConfig --out src/backend/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage", - "schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage", - "schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage", - "schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage", + "schema-client": "typescript-json-schema src/backend/tsconfig.json ClientConfig --out src/backend/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum", + "schema-source": "typescript-json-schema src/backend/tsconfig.json SourceConfig --out src/backend/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum", + "schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum", + "schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum", + "schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum", "circular": "madge --circular --extensions ts src/index.ts", "test": "npm run -s test:backend", "test:backend": "mocha --reporter spec --recursive src/backend/tests/**/*.test.ts", @@ -154,7 +154,7 @@ "ts-essentials": "^9.1.2", "typescript": "^5.3.3", "typescript-eslint": "^7.0.1", - "typescript-json-schema": "~0.55", + "typescript-json-schema": "^0.61.0", "vite": "^5.2.12" }, "browserslist": { diff --git a/patches/typescript-json-schema+0.55.0.patch b/patches/typescript-json-schema+0.55.0.patch deleted file mode 100644 index a4839f56..00000000 --- a/patches/typescript-json-schema+0.55.0.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --git a/node_modules/typescript-json-schema/dist/typescript-json-schema.js b/node_modules/typescript-json-schema/dist/typescript-json-schema.js -index 23cc6d1..221adc2 100644 ---- a/node_modules/typescript-json-schema/dist/typescript-json-schema.js -+++ b/node_modules/typescript-json-schema/dist/typescript-json-schema.js -@@ -55,7 +55,7 @@ var crypto_1 = require("crypto"); - var ts = require("typescript"); - var path_equal_1 = require("path-equal"); - var vm = require("vm"); --var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g; -+var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g; - var REGEX_TSCONFIG_NAME = /^.*\.json$/; - var REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g; - var REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g; -diff --git a/node_modules/typescript-json-schema/typescript-json-schema.ts b/node_modules/typescript-json-schema/typescript-json-schema.ts -index 5908567..c188383 100644 ---- a/node_modules/typescript-json-schema/typescript-json-schema.ts -+++ b/node_modules/typescript-json-schema/typescript-json-schema.ts -@@ -9,7 +9,7 @@ export { Program, CompilerOptions, Symbol } from "typescript"; - - const vm = require("vm"); - --const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g; -+const REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g; - const REGEX_TSCONFIG_NAME = /^.*\.json$/; - const REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g; - const REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g; diff --git a/patches/typescript-json-schema+0.61.0.patch b/patches/typescript-json-schema+0.61.0.patch new file mode 100644 index 00000000..c3ae9454 --- /dev/null +++ b/patches/typescript-json-schema+0.61.0.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/typescript-json-schema/dist/typescript-json-schema.js b/node_modules/typescript-json-schema/dist/typescript-json-schema.js +index 5b5ecec..1e48e77 100644 +--- a/node_modules/typescript-json-schema/dist/typescript-json-schema.js ++++ b/node_modules/typescript-json-schema/dist/typescript-json-schema.js +@@ -55,7 +55,7 @@ var crypto_1 = require("crypto"); + var ts = require("typescript"); + var path_equal_1 = require("path-equal"); + var vm = require("vm"); +-var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g; ++var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g; + var REGEX_TSCONFIG_NAME = /^.*\.json$/; + var REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g; + var REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g; diff --git a/src/backend/common/schema/aio-client.json b/src/backend/common/schema/aio-client.json index 0c8570d6..befd264e 100644 --- a/src/backend/common/schema/aio-client.json +++ b/src/backend/common/schema/aio-client.json @@ -48,6 +48,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "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", @@ -57,20 +61,19 @@ "title": "refreshEnabled", "type": "boolean" }, - "refreshForce": { - "default": false, - "description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.", - "examples": [ - false - ], - "title": "refreshForce", - "type": "boolean" - }, "refreshInitialCount": { "description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.", "title": "refreshInitialCount", "type": "number" }, + "refreshStaleAfter": { + "description": "Force client to refresh scrobbled plays from upstream service if last refresh was at least X seconds ago\n\n**In most case this setting should NOT be used.** MS intelligently refreshes based on activity so using this setting may increase upstream service load and slow down scrobbles.\n\nThis setting should only be used in specific scenarios where MS is handling multiple \"relaying\" client-services (IE lfm -> lz -> lfm) and there is the potential for a client to be out of sync after more than a few seconds.", + "examples": [ + 3 + ], + "title": "refreshStaleAfter", + "type": "number" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -353,6 +356,7 @@ "title": "options" }, "type": { + "default": "maloja", "enum": [ "maloja" ], @@ -446,6 +450,136 @@ "title": "MatchLoggingOptions", "type": "object" }, + "PlayTransformConfig": { + "properties": { + "compare": { + "properties": { + "candidate": { + "$ref": "#/definitions/PlayTransformParts", + "title": "candidate" + }, + "existing": { + "$ref": "#/definitions/PlayTransformParts", + "title": "existing" + } + }, + "title": "compare", + "type": "object" + }, + "postCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "postCompare" + }, + "preCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "preCompare" + } + }, + "title": "PlayTransformConfig", + "type": "object" + }, + "PlayTransformOptions": { + "allOf": [ + { + "$ref": "#/definitions/PlayTransformConfig" + }, + { + "properties": { + "log": { + "title": "log", + "type": "boolean" + } + }, + "type": "object" + } + ], + "title": "PlayTransformOptions" + }, + "PlayTransformParts": { + "properties": { + "album": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "album", + "type": "array" + }, + "artists": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "artists", + "type": "array" + }, + "title": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "title", + "type": "array" + } + }, + "title": "PlayTransformParts", + "type": "object" + }, + "RegExp": { + "properties": { + "dotAll": { + "title": "dotAll", + "type": "boolean" + }, + "flags": { + "title": "flags", + "type": "string" + }, + "global": { + "title": "global", + "type": "boolean" + }, + "hasIndices": { + "title": "hasIndices", + "type": "boolean" + }, + "ignoreCase": { + "title": "ignoreCase", + "type": "boolean" + }, + "lastIndex": { + "title": "lastIndex", + "type": "number" + }, + "multiline": { + "title": "multiline", + "type": "boolean" + }, + "source": { + "title": "source", + "type": "string" + }, + "sticky": { + "title": "sticky", + "type": "boolean" + }, + "unicode": { + "title": "unicode", + "type": "boolean" + } + }, + "required": [ + "dotAll", + "flags", + "global", + "hasIndices", + "ignoreCase", + "lastIndex", + "multiline", + "source", + "sticky", + "unicode" + ], + "title": "RegExp", + "type": "object" + }, "RequestRetryOptions": { "properties": { "maxRequestRetries": { @@ -469,6 +603,48 @@ }, "title": "RequestRetryOptions", "type": "object" + }, + "SearchAndReplaceRegExp": { + "properties": { + "replace": { + "description": "The replacement string/value to use when search is found\n\nThis can be a literal string like `'replace with this`, an empty string to remove the search value (`''`), or a special regex value\n\nSee replacement here for more information: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace", + "title": "replace", + "type": "string" + }, + "search": { + "anyOf": [ + { + "$ref": "#/definitions/RegExp" + }, + { + "type": "string" + } + ], + "description": "The search value to test for\n\nCan be a normal string (converted to a case-sensitive literal) or a valid regular expression as a string, or an actual RegExp object\n\nEX `[\"find this string\", \"/some string*\\/ig\"]`", + "examples": [ + "find this string", + "/some string*/ig" + ], + "title": "search" + } + }, + "required": [ + "replace", + "search" + ], + "title": "SearchAndReplaceRegExp", + "type": "object" + }, + "SearchAndReplaceTerm": { + "anyOf": [ + { + "$ref": "#/definitions/SearchAndReplaceRegExp" + }, + { + "type": "string" + } + ], + "title": "SearchAndReplaceTerm" } }, "properties": { diff --git a/src/backend/common/schema/aio-source.json b/src/backend/common/schema/aio-source.json index 4ec9ab3c..424a2f52 100644 --- a/src/backend/common/schema/aio-source.json +++ b/src/backend/common/schema/aio-source.json @@ -264,6 +264,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -1099,6 +1103,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -1432,6 +1440,78 @@ "title": "MusikcubeSourceAIOConfig", "type": "object" }, + "PlayTransformConfig": { + "properties": { + "compare": { + "properties": { + "candidate": { + "$ref": "#/definitions/PlayTransformParts", + "title": "candidate" + }, + "existing": { + "$ref": "#/definitions/PlayTransformParts", + "title": "existing" + } + }, + "title": "compare", + "type": "object" + }, + "postCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "postCompare" + }, + "preCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "preCompare" + } + }, + "title": "PlayTransformConfig", + "type": "object" + }, + "PlayTransformOptions": { + "allOf": [ + { + "$ref": "#/definitions/PlayTransformConfig" + }, + { + "properties": { + "log": { + "title": "log", + "type": "boolean" + } + }, + "type": "object" + } + ], + "title": "PlayTransformOptions" + }, + "PlayTransformParts": { + "properties": { + "album": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "album", + "type": "array" + }, + "artists": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "artists", + "type": "array" + }, + "title": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "title", + "type": "array" + } + }, + "title": "PlayTransformParts", + "type": "object" + }, "PlexSourceAIOConfig": { "properties": { "clients": { @@ -1553,6 +1633,64 @@ "title": "PlexSourceData", "type": "object" }, + "RegExp": { + "properties": { + "dotAll": { + "title": "dotAll", + "type": "boolean" + }, + "flags": { + "title": "flags", + "type": "string" + }, + "global": { + "title": "global", + "type": "boolean" + }, + "hasIndices": { + "title": "hasIndices", + "type": "boolean" + }, + "ignoreCase": { + "title": "ignoreCase", + "type": "boolean" + }, + "lastIndex": { + "title": "lastIndex", + "type": "number" + }, + "multiline": { + "title": "multiline", + "type": "boolean" + }, + "source": { + "title": "source", + "type": "string" + }, + "sticky": { + "title": "sticky", + "type": "boolean" + }, + "unicode": { + "title": "unicode", + "type": "boolean" + } + }, + "required": [ + "dotAll", + "flags", + "global", + "hasIndices", + "ignoreCase", + "lastIndex", + "multiline", + "source", + "sticky", + "unicode" + ], + "title": "RegExp", + "type": "object" + }, "ScrobbleThresholds": { "properties": { "duration": { @@ -1577,6 +1715,48 @@ "title": "ScrobbleThresholds", "type": "object" }, + "SearchAndReplaceRegExp": { + "properties": { + "replace": { + "description": "The replacement string/value to use when search is found\n\nThis can be a literal string like `'replace with this`, an empty string to remove the search value (`''`), or a special regex value\n\nSee replacement here for more information: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace", + "title": "replace", + "type": "string" + }, + "search": { + "anyOf": [ + { + "$ref": "#/definitions/RegExp" + }, + { + "type": "string" + } + ], + "description": "The search value to test for\n\nCan be a normal string (converted to a case-sensitive literal) or a valid regular expression as a string, or an actual RegExp object\n\nEX `[\"find this string\", \"/some string*\\/ig\"]`", + "examples": [ + "find this string", + "/some string*/ig" + ], + "title": "search" + } + }, + "required": [ + "replace", + "search" + ], + "title": "SearchAndReplaceRegExp", + "type": "object" + }, + "SearchAndReplaceTerm": { + "anyOf": [ + { + "$ref": "#/definitions/SearchAndReplaceRegExp" + }, + { + "type": "string" + } + ], + "title": "SearchAndReplaceTerm" + }, "SourceAIOConfig": { "anyOf": [ { @@ -1624,6 +1804,9 @@ { "$ref": "#/definitions/TautulliSourceAIOConfig" }, + { + "$ref": "#/definitions/VLCSourceAIOConfig" + }, { "$ref": "#/definitions/WebScrobblerSourceAIOConfig" }, @@ -1941,6 +2124,209 @@ "title": "TautulliSourceAIOConfig", "type": "object" }, + "VLCData": { + "properties": { + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "title": "interval", + "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 + ], + "title": "maxInterval", + "type": "number" + }, + "password": { + "description": "Password for the server", + "title": "password", + "type": "string" + }, + "url": { + "default": "localhost:8080", + "description": "URL:PORT of the VLC server to connect to\n\nTo use this you must have the Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc", + "examples": [ + "localhost:8080" + ], + "title": "url", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "VLCData", + "type": "object" + }, + "VLCSourceAIOConfig": { + "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" + }, + "title": "clients", + "type": "array" + }, + "data": { + "$ref": "#/definitions/VLCData", + "title": "data" + }, + "enable": { + "default": true, + "description": "Should MS use this client/source? Defaults to true", + "examples": [ + true + ], + "title": "enable", + "type": "boolean" + }, + "name": { + "description": "Unique identifier for this source.", + "title": "name", + "type": "string" + }, + "options": { + "$ref": "#/definitions/VLCSourceOptions", + "title": "options" + }, + "type": { + "enum": [ + "vlc" + ], + "title": "type", + "type": "string" + } + }, + "required": [ + "data", + "options", + "type" + ], + "title": "VLCSourceAIOConfig", + "type": "object" + }, + "VLCSourceOptions": { + "properties": { + "dumpVlcMetadata": { + "default": false, + "description": "Dump all the metadata VLC reports for an audio track to DEBUG.\n\nUse this if reporting an issue with VLC not correctly capturing metadata for a track.", + "title": "dumpVlcMetadata", + "type": "boolean" + }, + "filenamePatterns": { + "description": "A list of regular expressions to use to extract metadata (title, album, artist) from a filename\n\nUsed when VLC reports only the filename for the current audio track", + "items": { + "type": "string" + }, + "title": "filenamePatterns", + "type": "array" + }, + "logFilenamePatterns": { + "default": false, + "description": "Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns", + "title": "logFilenamePatterns", + "type": "boolean" + }, + "logFilterFailure": { + "default": "warn", + "description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.", + "enum": [ + "debug", + false, + "warn" + ], + "examples": [ + "warn" + ], + "title": "logFilterFailure" + }, + "logPayload": { + "default": false, + "description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output", + "examples": [ + false + ], + "title": "logPayload", + "type": "boolean" + }, + "logPlayerState": { + "default": false, + "description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output", + "examples": [ + false + ], + "title": "logPlayerState", + "type": "boolean" + }, + "maxPollRetries": { + "default": 5, + "description": "default # of automatic polling restarts on error", + "examples": [ + 5 + ], + "title": "maxPollRetries", + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source/client can make before error is thrown", + "examples": [ + 1 + ], + "title": "maxRequestRetries", + "type": "number" + }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "title": "retryMultiplier", + "type": "number" + }, + "scrobbleBacklog": { + "default": true, + "description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history", + "examples": [ + true, + false + ], + "title": "scrobbleBacklog", + "type": "boolean" + }, + "scrobbleBacklogCount": { + "description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports", + "title": "scrobbleBacklogCount", + "type": "number" + }, + "scrobbleThresholds": { + "$ref": "#/definitions/ScrobbleThresholds", + "description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.", + "title": "scrobbleThresholds" + } + }, + "title": "VLCSourceOptions", + "type": "object" + }, "WebScrobblerData": { "properties": { "blacklist": { diff --git a/src/backend/common/schema/aio.json b/src/backend/common/schema/aio.json index a7be3b79..7afeb24a 100644 --- a/src/backend/common/schema/aio.json +++ b/src/backend/common/schema/aio.json @@ -337,6 +337,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "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", @@ -346,20 +350,19 @@ "title": "refreshEnabled", "type": "boolean" }, - "refreshForce": { - "default": false, - "description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.", - "examples": [ - false - ], - "title": "refreshForce", - "type": "boolean" - }, "refreshInitialCount": { "description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.", "title": "refreshInitialCount", "type": "number" }, + "refreshStaleAfter": { + "description": "Force client to refresh scrobbled plays from upstream service if last refresh was at least X seconds ago\n\n**In most case this setting should NOT be used.** MS intelligently refreshes based on activity so using this setting may increase upstream service load and slow down scrobbles.\n\nThis setting should only be used in specific scenarios where MS is handling multiple \"relaying\" client-services (IE lfm -> lz -> lfm) and there is the potential for a client to be out of sync after more than a few seconds.", + "examples": [ + 3 + ], + "title": "refreshStaleAfter", + "type": "number" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -417,6 +420,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "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", @@ -426,20 +433,19 @@ "title": "refreshEnabled", "type": "boolean" }, - "refreshForce": { - "default": false, - "description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.", - "examples": [ - false - ], - "title": "refreshForce", - "type": "boolean" - }, "refreshInitialCount": { "description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.", "title": "refreshInitialCount", "type": "number" }, + "refreshStaleAfter": { + "description": "Force client to refresh scrobbled plays from upstream service if last refresh was at least X seconds ago\n\n**In most case this setting should NOT be used.** MS intelligently refreshes based on activity so using this setting may increase upstream service load and slow down scrobbles.\n\nThis setting should only be used in specific scenarios where MS is handling multiple \"relaying\" client-services (IE lfm -> lz -> lfm) and there is the potential for a client to be out of sync after more than a few seconds.", + "examples": [ + 3 + ], + "title": "refreshStaleAfter", + "type": "number" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -515,6 +521,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -1751,6 +1761,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -1914,6 +1928,7 @@ "title": "options" }, "type": { + "default": "maloja", "enum": [ "maloja" ], @@ -2263,6 +2278,78 @@ "title": "NtfyConfig", "type": "object" }, + "PlayTransformConfig": { + "properties": { + "compare": { + "properties": { + "candidate": { + "$ref": "#/definitions/PlayTransformParts", + "title": "candidate" + }, + "existing": { + "$ref": "#/definitions/PlayTransformParts", + "title": "existing" + } + }, + "title": "compare", + "type": "object" + }, + "postCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "postCompare" + }, + "preCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "preCompare" + } + }, + "title": "PlayTransformConfig", + "type": "object" + }, + "PlayTransformOptions": { + "allOf": [ + { + "$ref": "#/definitions/PlayTransformConfig" + }, + { + "properties": { + "log": { + "title": "log", + "type": "boolean" + } + }, + "type": "object" + } + ], + "title": "PlayTransformOptions" + }, + "PlayTransformParts": { + "properties": { + "album": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "album", + "type": "array" + }, + "artists": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "artists", + "type": "array" + }, + "title": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "title", + "type": "array" + } + }, + "title": "PlayTransformParts", + "type": "object" + }, "PlexSourceAIOConfig": { "properties": { "clients": { @@ -2416,6 +2503,64 @@ "title": "PrioritiesConfig", "type": "object" }, + "RegExp": { + "properties": { + "dotAll": { + "title": "dotAll", + "type": "boolean" + }, + "flags": { + "title": "flags", + "type": "string" + }, + "global": { + "title": "global", + "type": "boolean" + }, + "hasIndices": { + "title": "hasIndices", + "type": "boolean" + }, + "ignoreCase": { + "title": "ignoreCase", + "type": "boolean" + }, + "lastIndex": { + "title": "lastIndex", + "type": "number" + }, + "multiline": { + "title": "multiline", + "type": "boolean" + }, + "source": { + "title": "source", + "type": "string" + }, + "sticky": { + "title": "sticky", + "type": "boolean" + }, + "unicode": { + "title": "unicode", + "type": "boolean" + } + }, + "required": [ + "dotAll", + "flags", + "global", + "hasIndices", + "ignoreCase", + "lastIndex", + "multiline", + "source", + "sticky", + "unicode" + ], + "title": "RegExp", + "type": "object" + }, "ScrobbleThresholds": { "properties": { "duration": { @@ -2440,6 +2585,48 @@ "title": "ScrobbleThresholds", "type": "object" }, + "SearchAndReplaceRegExp": { + "properties": { + "replace": { + "description": "The replacement string/value to use when search is found\n\nThis can be a literal string like `'replace with this`, an empty string to remove the search value (`''`), or a special regex value\n\nSee replacement here for more information: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace", + "title": "replace", + "type": "string" + }, + "search": { + "anyOf": [ + { + "$ref": "#/definitions/RegExp" + }, + { + "type": "string" + } + ], + "description": "The search value to test for\n\nCan be a normal string (converted to a case-sensitive literal) or a valid regular expression as a string, or an actual RegExp object\n\nEX `[\"find this string\", \"/some string*\\/ig\"]`", + "examples": [ + "find this string", + "/some string*/ig" + ], + "title": "search" + } + }, + "required": [ + "replace", + "search" + ], + "title": "SearchAndReplaceRegExp", + "type": "object" + }, + "SearchAndReplaceTerm": { + "anyOf": [ + { + "$ref": "#/definitions/SearchAndReplaceRegExp" + }, + { + "type": "string" + } + ], + "title": "SearchAndReplaceTerm" + }, "SourceAIOConfig": { "anyOf": [ { @@ -2487,6 +2674,9 @@ { "$ref": "#/definitions/TautulliSourceAIOConfig" }, + { + "$ref": "#/definitions/VLCSourceAIOConfig" + }, { "$ref": "#/definitions/WebScrobblerSourceAIOConfig" }, @@ -2547,6 +2737,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -2855,6 +3049,209 @@ "title": "TautulliSourceAIOConfig", "type": "object" }, + "VLCData": { + "properties": { + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "title": "interval", + "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 + ], + "title": "maxInterval", + "type": "number" + }, + "password": { + "description": "Password for the server", + "title": "password", + "type": "string" + }, + "url": { + "default": "localhost:8080", + "description": "URL:PORT of the VLC server to connect to\n\nTo use this you must have the Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc", + "examples": [ + "localhost:8080" + ], + "title": "url", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "VLCData", + "type": "object" + }, + "VLCSourceAIOConfig": { + "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" + }, + "title": "clients", + "type": "array" + }, + "data": { + "$ref": "#/definitions/VLCData", + "title": "data" + }, + "enable": { + "default": true, + "description": "Should MS use this client/source? Defaults to true", + "examples": [ + true + ], + "title": "enable", + "type": "boolean" + }, + "name": { + "description": "Unique identifier for this source.", + "title": "name", + "type": "string" + }, + "options": { + "$ref": "#/definitions/VLCSourceOptions", + "title": "options" + }, + "type": { + "enum": [ + "vlc" + ], + "title": "type", + "type": "string" + } + }, + "required": [ + "data", + "options", + "type" + ], + "title": "VLCSourceAIOConfig", + "type": "object" + }, + "VLCSourceOptions": { + "properties": { + "dumpVlcMetadata": { + "default": false, + "description": "Dump all the metadata VLC reports for an audio track to DEBUG.\n\nUse this if reporting an issue with VLC not correctly capturing metadata for a track.", + "title": "dumpVlcMetadata", + "type": "boolean" + }, + "filenamePatterns": { + "description": "A list of regular expressions to use to extract metadata (title, album, artist) from a filename\n\nUsed when VLC reports only the filename for the current audio track", + "items": { + "type": "string" + }, + "title": "filenamePatterns", + "type": "array" + }, + "logFilenamePatterns": { + "default": false, + "description": "Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns", + "title": "logFilenamePatterns", + "type": "boolean" + }, + "logFilterFailure": { + "default": "warn", + "description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.", + "enum": [ + "debug", + false, + "warn" + ], + "examples": [ + "warn" + ], + "title": "logFilterFailure" + }, + "logPayload": { + "default": false, + "description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output", + "examples": [ + false + ], + "title": "logPayload", + "type": "boolean" + }, + "logPlayerState": { + "default": false, + "description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output", + "examples": [ + false + ], + "title": "logPlayerState", + "type": "boolean" + }, + "maxPollRetries": { + "default": 5, + "description": "default # of automatic polling restarts on error", + "examples": [ + 5 + ], + "title": "maxPollRetries", + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source/client can make before error is thrown", + "examples": [ + 1 + ], + "title": "maxRequestRetries", + "type": "number" + }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "title": "retryMultiplier", + "type": "number" + }, + "scrobbleBacklog": { + "default": true, + "description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history", + "examples": [ + true, + false + ], + "title": "scrobbleBacklog", + "type": "boolean" + }, + "scrobbleBacklogCount": { + "description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports", + "title": "scrobbleBacklogCount", + "type": "number" + }, + "scrobbleThresholds": { + "$ref": "#/definitions/ScrobbleThresholds", + "description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.", + "title": "scrobbleThresholds" + } + }, + "title": "VLCSourceOptions", + "type": "object" + }, "WebScrobblerData": { "properties": { "blacklist": { diff --git a/src/backend/common/schema/client.json b/src/backend/common/schema/client.json index 32f402bc..a7de9b7e 100644 --- a/src/backend/common/schema/client.json +++ b/src/backend/common/schema/client.json @@ -45,6 +45,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "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", @@ -54,20 +58,19 @@ "title": "refreshEnabled", "type": "boolean" }, - "refreshForce": { - "default": false, - "description": "Force client to always refresh scrobbled plays from service before scrobbling new play\n\nWARNING: This will cause increased load on the scrobble service and potentially slow down scrobble speed as well. This should be used as a debugging tool and not be always-on.", - "examples": [ - false - ], - "title": "refreshForce", - "type": "boolean" - }, "refreshInitialCount": { "description": "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported for the client.", "title": "refreshInitialCount", "type": "number" }, + "refreshStaleAfter": { + "description": "Force client to refresh scrobbled plays from upstream service if last refresh was at least X seconds ago\n\n**In most case this setting should NOT be used.** MS intelligently refreshes based on activity so using this setting may increase upstream service load and slow down scrobbles.\n\nThis setting should only be used in specific scenarios where MS is handling multiple \"relaying\" client-services (IE lfm -> lz -> lfm) and there is the potential for a client to be out of sync after more than a few seconds.", + "examples": [ + 3 + ], + "title": "refreshStaleAfter", + "type": "number" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -418,6 +421,178 @@ }, "title": "MatchLoggingOptions", "type": "object" + }, + "PlayTransformConfig": { + "properties": { + "compare": { + "properties": { + "candidate": { + "$ref": "#/definitions/PlayTransformParts", + "title": "candidate" + }, + "existing": { + "$ref": "#/definitions/PlayTransformParts", + "title": "existing" + } + }, + "title": "compare", + "type": "object" + }, + "postCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "postCompare" + }, + "preCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "preCompare" + } + }, + "title": "PlayTransformConfig", + "type": "object" + }, + "PlayTransformOptions": { + "allOf": [ + { + "$ref": "#/definitions/PlayTransformConfig" + }, + { + "properties": { + "log": { + "title": "log", + "type": "boolean" + } + }, + "type": "object" + } + ], + "title": "PlayTransformOptions" + }, + "PlayTransformParts": { + "properties": { + "album": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "album", + "type": "array" + }, + "artists": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "artists", + "type": "array" + }, + "title": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "title", + "type": "array" + } + }, + "title": "PlayTransformParts", + "type": "object" + }, + "RegExp": { + "properties": { + "dotAll": { + "title": "dotAll", + "type": "boolean" + }, + "flags": { + "title": "flags", + "type": "string" + }, + "global": { + "title": "global", + "type": "boolean" + }, + "hasIndices": { + "title": "hasIndices", + "type": "boolean" + }, + "ignoreCase": { + "title": "ignoreCase", + "type": "boolean" + }, + "lastIndex": { + "title": "lastIndex", + "type": "number" + }, + "multiline": { + "title": "multiline", + "type": "boolean" + }, + "source": { + "title": "source", + "type": "string" + }, + "sticky": { + "title": "sticky", + "type": "boolean" + }, + "unicode": { + "title": "unicode", + "type": "boolean" + } + }, + "required": [ + "dotAll", + "flags", + "global", + "hasIndices", + "ignoreCase", + "lastIndex", + "multiline", + "source", + "sticky", + "unicode" + ], + "title": "RegExp", + "type": "object" + }, + "SearchAndReplaceRegExp": { + "properties": { + "replace": { + "description": "The replacement string/value to use when search is found\n\nThis can be a literal string like `'replace with this`, an empty string to remove the search value (`''`), or a special regex value\n\nSee replacement here for more information: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace", + "title": "replace", + "type": "string" + }, + "search": { + "anyOf": [ + { + "$ref": "#/definitions/RegExp" + }, + { + "type": "string" + } + ], + "description": "The search value to test for\n\nCan be a normal string (converted to a case-sensitive literal) or a valid regular expression as a string, or an actual RegExp object\n\nEX `[\"find this string\", \"/some string*\\/ig\"]`", + "examples": [ + "find this string", + "/some string*/ig" + ], + "title": "search" + } + }, + "required": [ + "replace", + "search" + ], + "title": "SearchAndReplaceRegExp", + "type": "object" + }, + "SearchAndReplaceTerm": { + "anyOf": [ + { + "$ref": "#/definitions/SearchAndReplaceRegExp" + }, + { + "type": "string" + } + ], + "title": "SearchAndReplaceTerm" } } } diff --git a/src/backend/common/schema/source.json b/src/backend/common/schema/source.json index 0e28b0ff..af2ba41a 100644 --- a/src/backend/common/schema/source.json +++ b/src/backend/common/schema/source.json @@ -46,6 +46,9 @@ { "$ref": "#/definitions/TautulliSourceConfig" }, + { + "$ref": "#/definitions/VLCSourceConfig" + }, { "$ref": "#/definitions/WebScrobblerSourceConfig" }, @@ -309,6 +312,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -1088,6 +1095,10 @@ "title": "maxRequestRetries", "type": "number" }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, "retryMultiplier": { "default": 1.5, "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", @@ -1397,6 +1408,78 @@ "title": "MusikcubeSourceConfig", "type": "object" }, + "PlayTransformConfig": { + "properties": { + "compare": { + "properties": { + "candidate": { + "$ref": "#/definitions/PlayTransformParts", + "title": "candidate" + }, + "existing": { + "$ref": "#/definitions/PlayTransformParts", + "title": "existing" + } + }, + "title": "compare", + "type": "object" + }, + "postCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "postCompare" + }, + "preCompare": { + "$ref": "#/definitions/PlayTransformParts", + "title": "preCompare" + } + }, + "title": "PlayTransformConfig", + "type": "object" + }, + "PlayTransformOptions": { + "allOf": [ + { + "$ref": "#/definitions/PlayTransformConfig" + }, + { + "properties": { + "log": { + "title": "log", + "type": "boolean" + } + }, + "type": "object" + } + ], + "title": "PlayTransformOptions" + }, + "PlayTransformParts": { + "properties": { + "album": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "album", + "type": "array" + }, + "artists": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "artists", + "type": "array" + }, + "title": { + "items": { + "$ref": "#/definitions/SearchAndReplaceTerm" + }, + "title": "title", + "type": "array" + } + }, + "title": "PlayTransformParts", + "type": "object" + }, "PlexSourceConfig": { "properties": { "clients": { @@ -1510,6 +1593,64 @@ "title": "PlexSourceData", "type": "object" }, + "RegExp": { + "properties": { + "dotAll": { + "title": "dotAll", + "type": "boolean" + }, + "flags": { + "title": "flags", + "type": "string" + }, + "global": { + "title": "global", + "type": "boolean" + }, + "hasIndices": { + "title": "hasIndices", + "type": "boolean" + }, + "ignoreCase": { + "title": "ignoreCase", + "type": "boolean" + }, + "lastIndex": { + "title": "lastIndex", + "type": "number" + }, + "multiline": { + "title": "multiline", + "type": "boolean" + }, + "source": { + "title": "source", + "type": "string" + }, + "sticky": { + "title": "sticky", + "type": "boolean" + }, + "unicode": { + "title": "unicode", + "type": "boolean" + } + }, + "required": [ + "dotAll", + "flags", + "global", + "hasIndices", + "ignoreCase", + "lastIndex", + "multiline", + "source", + "sticky", + "unicode" + ], + "title": "RegExp", + "type": "object" + }, "ScrobbleThresholds": { "properties": { "duration": { @@ -1534,6 +1675,48 @@ "title": "ScrobbleThresholds", "type": "object" }, + "SearchAndReplaceRegExp": { + "properties": { + "replace": { + "description": "The replacement string/value to use when search is found\n\nThis can be a literal string like `'replace with this`, an empty string to remove the search value (`''`), or a special regex value\n\nSee replacement here for more information: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace", + "title": "replace", + "type": "string" + }, + "search": { + "anyOf": [ + { + "$ref": "#/definitions/RegExp" + }, + { + "type": "string" + } + ], + "description": "The search value to test for\n\nCan be a normal string (converted to a case-sensitive literal) or a valid regular expression as a string, or an actual RegExp object\n\nEX `[\"find this string\", \"/some string*\\/ig\"]`", + "examples": [ + "find this string", + "/some string*/ig" + ], + "title": "search" + } + }, + "required": [ + "replace", + "search" + ], + "title": "SearchAndReplaceRegExp", + "type": "object" + }, + "SearchAndReplaceTerm": { + "anyOf": [ + { + "$ref": "#/definitions/SearchAndReplaceRegExp" + }, + { + "type": "string" + } + ], + "title": "SearchAndReplaceTerm" + }, "SpotifySourceConfig": { "properties": { "clients": { @@ -1785,6 +1968,201 @@ "title": "TautulliSourceConfig", "type": "object" }, + "VLCData": { + "properties": { + "interval": { + "default": 10, + "description": "How long to wait before polling the source API for new tracks (in seconds)", + "examples": [ + 10 + ], + "title": "interval", + "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 + ], + "title": "maxInterval", + "type": "number" + }, + "password": { + "description": "Password for the server", + "title": "password", + "type": "string" + }, + "url": { + "default": "localhost:8080", + "description": "URL:PORT of the VLC server to connect to\n\nTo use this you must have the Web (http) interface module enabled and a password set https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc", + "examples": [ + "localhost:8080" + ], + "title": "url", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "VLCData", + "type": "object" + }, + "VLCSourceConfig": { + "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" + }, + "title": "clients", + "type": "array" + }, + "data": { + "$ref": "#/definitions/VLCData", + "title": "data" + }, + "enable": { + "default": true, + "description": "Should MS use this client/source? Defaults to true", + "examples": [ + true + ], + "title": "enable", + "type": "boolean" + }, + "name": { + "description": "Unique identifier for this source.", + "title": "name", + "type": "string" + }, + "options": { + "$ref": "#/definitions/VLCSourceOptions", + "title": "options" + } + }, + "required": [ + "data", + "options" + ], + "title": "VLCSourceConfig", + "type": "object" + }, + "VLCSourceOptions": { + "properties": { + "dumpVlcMetadata": { + "default": false, + "description": "Dump all the metadata VLC reports for an audio track to DEBUG.\n\nUse this if reporting an issue with VLC not correctly capturing metadata for a track.", + "title": "dumpVlcMetadata", + "type": "boolean" + }, + "filenamePatterns": { + "description": "A list of regular expressions to use to extract metadata (title, album, artist) from a filename\n\nUsed when VLC reports only the filename for the current audio track", + "items": { + "type": "string" + }, + "title": "filenamePatterns", + "type": "array" + }, + "logFilenamePatterns": { + "default": false, + "description": "Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns", + "title": "logFilenamePatterns", + "type": "boolean" + }, + "logFilterFailure": { + "default": "warn", + "description": "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)\n\n* `false` => do not log\n* `debug` => log to DEBUG level\n* `warn` => log to WARN level (default)\n\nHint: This is useful if you are sure this source is setup correctly and you have multiple other sources. Set to `debug` or `false` to reduce log noise.", + "enum": [ + "debug", + false, + "warn" + ], + "examples": [ + "warn" + ], + "title": "logFilterFailure" + }, + "logPayload": { + "default": false, + "description": "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)\nthen setting this option to true will make MS log the payload JSON to DEBUG output", + "examples": [ + false + ], + "title": "logPayload", + "type": "boolean" + }, + "logPlayerState": { + "default": false, + "description": "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output", + "examples": [ + false + ], + "title": "logPlayerState", + "type": "boolean" + }, + "maxPollRetries": { + "default": 5, + "description": "default # of automatic polling restarts on error", + "examples": [ + 5 + ], + "title": "maxPollRetries", + "type": "number" + }, + "maxRequestRetries": { + "default": 1, + "description": "default # of http request retries a source/client can make before error is thrown", + "examples": [ + 1 + ], + "title": "maxRequestRetries", + "type": "number" + }, + "playTransform": { + "$ref": "#/definitions/PlayTransformOptions", + "title": "playTransform" + }, + "retryMultiplier": { + "default": 1.5, + "description": "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + "examples": [ + 1.5 + ], + "title": "retryMultiplier", + "type": "number" + }, + "scrobbleBacklog": { + "default": true, + "description": "If this source\n\n* supports fetching a listen history\n* and this option is enabled\n\nthen on startup MS will attempt to scrobble the recent listens from that history", + "examples": [ + true, + false + ], + "title": "scrobbleBacklog", + "type": "boolean" + }, + "scrobbleBacklogCount": { + "description": "The number of listens to fetch when scrobbling from backlog\n\n* Only applies if this source supports fetching a listen history\n* If not specified it defaults to the maximum number of listens the source API supports", + "title": "scrobbleBacklogCount", + "type": "number" + }, + "scrobbleThresholds": { + "$ref": "#/definitions/ScrobbleThresholds", + "description": "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\". If both duration and percent are defined then if either condition is met the track is scrobbled.", + "title": "scrobbleThresholds" + } + }, + "title": "VLCSourceOptions", + "type": "object" + }, "WebScrobblerData": { "properties": { "blacklist": { -- 2.51.2 From 10bf48df40ab7ac51a273d789064344f1afa0cf7 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 22 Aug 2024 17:30:37 -0400 Subject: [PATCH 5/8] docs(vlc): Add VLC docs --- README.md | 1 + config/vlc.json.example | 10 ++ docsite/docs/configuration/configuration.mdx | 117 ++++++++++++++++++ docsite/src/pages/index.mdx | 1 + .../infrastructure/config/source/vlc.ts | 2 +- src/backend/common/schema/aio-client.json | 1 - src/backend/common/schema/aio-source.json | 1 - src/backend/common/schema/aio.json | 2 - src/backend/common/schema/source.json | 3 +- src/backend/sources/ScrobbleSources.ts | 18 ++- 10 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 config/vlc.json.example diff --git a/README.md b/README.md index a7fe5eb3..fa758ad9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Google Cast (Chromecast)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#google-cast-chromecast) * [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube) * [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon) + * [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc) * Supports scrobbling to many **Clients** * [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja) * [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm) diff --git a/config/vlc.json.example b/config/vlc.json.example new file mode 100644 index 00000000..988040c5 --- /dev/null +++ b/config/vlc.json.example @@ -0,0 +1,10 @@ +[ + { + "enable": true, + "name": "MyVlc", + "data": { + "url": "localhost:8080", + "password": "changeme" + } + } +] diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index d0f1f6ed..d6b40b72 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -28,6 +28,7 @@ import SubsonicConfig from '!!raw-loader!../../../config/subsonic.json.example'; import TautulliConfig from '!!raw-loader!../../../config/tautulli.json.example'; import WebscrobblerConfig from '!!raw-loader!../../../config/webscrobbler.json.example'; import YTMusicConfig from '!!raw-loader!../../../config/ytmusic.json.example'; +import VLCConfig from '!!raw-loader!../../../config/vlc.json.example'; # Configuration @@ -1317,6 +1318,122 @@ port "6600" +### [VLC](https://www.videolan.org/vlc/) + +MS communicates with VLC using the [Web (`http`) interface module](https://wiki.videolan.org/VLC_HTTP_requests/) + +#### Enable HTTP Interface + +Open VLC, then + +* **Tools** Menu -> Preferences + * In the bottom left change **Show Settings** from `Simple` to `All` + * In the updated window select Interface -> Main interfaces + * In the **Extra interface modules** section enable **Web** and verify that `http` is shown in the textbox + * Select Interface -> Main interfaces -> Lua + * In Lua HTTP -> Password -> set a password in this box + * Click **Save** at the bottom to persist your changes +* Restart VLC +* Verify the HTTP interface is accessible by navigating to `http://localhost:8080` in your browser + +If the interface is accessible, after typing in your password, VLC is now ready to be used by MS. + +##### Setting VLC Interface and Port + +These can be set by starting VLC from command line with specific options: + +* `vlc --http-host yourHostIp:yourPort` or +* `vlc --http-port yourPort` or +* modify the configuration file for VLC by editing keys with the same values as from the command line (`http-host` and `http-port`) + + +#### Configuration + + + + | Environmental Variable | Required? | Default | Description | + |------------------------|------------|------------------|-------------| + | `VLC_URL` | No | `localhost:8080` | | + | `VLC_PASSWORD` | Yes | | | + + +
+ + Example + + {VLCConfig} + +
+ + or +
+ +
+ + Example + + + +
+ + or +
+
+ +#### Filename Parsing + +If the file being played in VLC does not have ID3 tags/metadata information multi-scrobbler can attempt to extract artist, title, and album information from the raw filename using regular expressions. Mutli-scrobbler will only do this if there is **no other information** reported by VLC about these fields. + +To extract this information use [named capture groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) in your expression matching the field that should be extracted: `artist` `title` and/or `album`. MS accepts multiple expressions, the first one that matches a filename will be used. + +
+ + Example + + Example usage in [a file-based config:](./configuration?configType=file#webscrobbler-config) + + ```json5 title="config/vlc.json" {10-16} + [ + { + "name": "MyVlc", + "enable": true, + "data": { + // ... + }, + "options": { + "filenamePatterns": [ + // Extracts artist and title from filenames that look like: + // My Artist - My Cool title.mp4 + "/^\\s*(?.+?) - (?.+?)\\.\\w+$/i" + ], + // logs to DEBUG when MS tries to extract data from a filename + "logFilenamePatterns": true, + } + } + ] + ``` + +</details> + +#### Vlc Information Reporting + +If you find that VLC is correctly reporting track information (in its interface) but multi-scrobbler is not parsing it correctly it may be due to the _name_ of the fields VLC is reporting. mutli-scrobbler uses the most common field name but does not cover all use cases. Before reporting an issue please turn on metadata logging and include output from your logs while it is turned on: + +```json5 title="config/vlc.json" {10-10} + [ + { + "name": "MyVlc", + "enable": true, + "data": { + // ... + }, + "options": { + "dumpVlcMetadata": true + } + } + ] + ``` + ## Client Configurations ### [Maloja](https://github.com/krateng/maloja) diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx index 21d04d43..c28b0abc 100644 --- a/docsite/src/pages/index.mdx +++ b/docsite/src/pages/index.mdx @@ -29,6 +29,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Google Cast (Chromecast)](docs/configuration#google-cast-chromecast) * [Musikcube](docs/configuration#musikcube) * [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon) + * [VLC](docs/configuration#vlc) * Supports scrobbling to many **Clients** * [Maloja](docs/configuration#maloja) * [Last.fm](docs/configuration#lastfm) diff --git a/src/backend/common/infrastructure/config/source/vlc.ts b/src/backend/common/infrastructure/config/source/vlc.ts index dab26cd4..43261cfa 100644 --- a/src/backend/common/infrastructure/config/source/vlc.ts +++ b/src/backend/common/infrastructure/config/source/vlc.ts @@ -44,7 +44,7 @@ export interface VLCSourceOptions extends CommonSourceOptions { export interface VLCSourceConfig extends CommonSourceConfig { data: VLCData - options: VLCSourceOptions + options?: VLCSourceOptions } export interface VLCSourceAIOConfig extends VLCSourceConfig { diff --git a/src/backend/common/schema/aio-client.json b/src/backend/common/schema/aio-client.json index befd264e..540f004d 100644 --- a/src/backend/common/schema/aio-client.json +++ b/src/backend/common/schema/aio-client.json @@ -356,7 +356,6 @@ "title": "options" }, "type": { - "default": "maloja", "enum": [ "maloja" ], diff --git a/src/backend/common/schema/aio-source.json b/src/backend/common/schema/aio-source.json index 424a2f52..fc226ad8 100644 --- a/src/backend/common/schema/aio-source.json +++ b/src/backend/common/schema/aio-source.json @@ -2213,7 +2213,6 @@ }, "required": [ "data", - "options", "type" ], "title": "VLCSourceAIOConfig", diff --git a/src/backend/common/schema/aio.json b/src/backend/common/schema/aio.json index 7afeb24a..1a1db46e 100644 --- a/src/backend/common/schema/aio.json +++ b/src/backend/common/schema/aio.json @@ -1928,7 +1928,6 @@ "title": "options" }, "type": { - "default": "maloja", "enum": [ "maloja" ], @@ -3138,7 +3137,6 @@ }, "required": [ "data", - "options", "type" ], "title": "VLCSourceAIOConfig", diff --git a/src/backend/common/schema/source.json b/src/backend/common/schema/source.json index af2ba41a..7ac0896e 100644 --- a/src/backend/common/schema/source.json +++ b/src/backend/common/schema/source.json @@ -2049,8 +2049,7 @@ } }, "required": [ - "data", - "options" + "data" ], "title": "VLCSourceConfig", "type": "object" diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index ecf4857f..4e2a0c18 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -20,7 +20,7 @@ import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/s import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js"; import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js"; import { TautulliSourceConfig } from "../common/infrastructure/config/source/tautulli.js"; -import { VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; +import { VLCData, VLCSourceConfig } from "../common/infrastructure/config/source/vlc.js"; import { WebScrobblerSourceConfig } from "../common/infrastructure/config/source/webscrobbler.js"; import { YTMusicSourceConfig } from "../common/infrastructure/config/source/ytmusic.js"; import * as aioSchema from "../common/schema/aio-source.json"; @@ -365,6 +365,22 @@ export default class ScrobbleSources { }); } break; + case 'vlc': + const vlc = { + url: process.env.VLC_URL, + password: process.env.VLC_PASSWORD + } + if (!Object.values(vlc).every(x => x === undefined)) { + configs.push({ + type: 'vlc', + name: 'unnamed', + source: 'ENV', + mode: 'single', + configureAs: defaultConfigureAs, + data: vlc as VLCData + }); + } + break; default: break; } -- 2.51.2 From 5e0c4e3e77779ecc75a2547e3f2b4e85350867f3 Mon Sep 17 00:00:00 2001 From: FoxxMD <FoxxMD@users.noreply.github.com> Date: Fri, 23 Aug 2024 09:00:14 -0400 Subject: [PATCH 6/8] docs(vlc): Some fixes for VLC docs tags <> in code block in details causes parsing error --- docsite/docs/FAQ.md | 4 ++ docsite/docs/configuration/configuration.mdx | 64 +++++++++----------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docsite/docs/FAQ.md b/docsite/docs/FAQ.md index 897f51ac..40f9f31a 100644 --- a/docsite/docs/FAQ.md +++ b/docsite/docs/FAQ.md @@ -177,3 +177,7 @@ My Artist - Example Track has 'movie' media type so will not track ``` Refer to [Force Media Tracking](configuration/configuration.mdx#forcing-media-tracking) section to fix this + +### VLC is not scrobbling fields correctly + +Before reporting an issue turn on metadata logging in the MS VLC configuration, [see the VLC documentation.](configuration/configuration.mdx#vlc-information-reporting) diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index d6b40b72..674f88eb 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -1324,13 +1324,13 @@ MS communicates with VLC using the [Web (`http`) interface module](https://wiki. #### Enable HTTP Interface -Open VLC, then +Open VLC: * **Tools** Menu -> Preferences * In the bottom left change **Show Settings** from `Simple` to `All` - * In the updated window select Interface -> Main interfaces + * In the updated window select `Interface -> Main interfaces` * In the **Extra interface modules** section enable **Web** and verify that `http` is shown in the textbox - * Select Interface -> Main interfaces -> Lua + * Select `Interface -> Main interfaces -> Lua` * In Lua HTTP -> Password -> set a password in this box * Click **Save** at the bottom to persist your changes * Restart VLC @@ -1386,40 +1386,36 @@ If the file being played in VLC does not have ID3 tags/metadata information mult To extract this information use [named capture groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) in your expression matching the field that should be extracted: `artist` `title` and/or `album`. MS accepts multiple expressions, the first one that matches a filename will be used. -<details> - - <summary>Example</summary> - - Example usage in [a file-based config:](./configuration?configType=file#webscrobbler-config) - - ```json5 title="config/vlc.json" {10-16} - [ - { - "name": "MyVlc", - "enable": true, - "data": { - // ... - }, - "options": { - "filenamePatterns": [ - // Extracts artist and title from filenames that look like: - // My Artist - My Cool title.mp4 - "/^\\s*(?<artist>.+?) - (?<title>.+?)\\.\\w+$/i" - ], - // logs to DEBUG when MS tries to extract data from a filename - "logFilenamePatterns": true, - } - } - ] - ``` - -</details> +<!-- Would like to put this in a <details> node but the <> in the named capture groups are causing the parser to incorrectly parse them as an actual tag --> + +Example usage in [a file-based config:](./configuration?configType=file#webscrobbler-config) + +```json5 title="config/vlc.json" {10-15} +[ + { + "name": "MyVlc", + "enable": true, + "data": { + // ... + }, + "options": { + "filenamePatterns": [ + // Extracts artist and title from filenames that look like: + // My Artist - My Cool title.mp4 + "/^\\s*(?<artist>.+?) - (?<title>.+?)\\.\\w+$/i" + ], + // logs to DEBUG when MS tries to extract data from a filename + "logFilenamePatterns": true, + } + } +] +``` #### Vlc Information Reporting -If you find that VLC is correctly reporting track information (in its interface) but multi-scrobbler is not parsing it correctly it may be due to the _name_ of the fields VLC is reporting. mutli-scrobbler uses the most common field name but does not cover all use cases. Before reporting an issue please turn on metadata logging and include output from your logs while it is turned on: +If you find that VLC is incorrectly reporting track information (in its interface) but multi-scrobbler is not parsing it correctly it may be due to the _name_ of the fields VLC is reporting. mutli-scrobbler uses the most common field name but does not cover all use cases. Before reporting an issue please turn on metadata logging and include output from your logs while it is turned on: -```json5 title="config/vlc.json" {10-10} +```json5 title="config/vlc.json" {9-9} [ { "name": "MyVlc", @@ -1432,7 +1428,7 @@ If you find that VLC is correctly reporting track information (in its interface) } } ] - ``` +``` ## Client Configurations -- 2.51.2 From 5b53b7490c68c079655627c57a647fd62dc1b3d2 Mon Sep 17 00:00:00 2001 From: FoxxMD <FoxxMD@users.noreply.github.com> Date: Mon, 26 Aug 2024 11:09:49 -0400 Subject: [PATCH 7/8] fix(maloja): Improve handling for Maloja warnings-as-errors * Fixes incorrect handling for duplicate scrobble error from Maloja 3.2.2 #180 * More defensive Maloja scrobble parsing --- .../common/vendor/maloja/interfaces.ts | 2 +- src/backend/scrobblers/MalojaScrobbler.ts | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/backend/common/vendor/maloja/interfaces.ts b/src/backend/common/vendor/maloja/interfaces.ts index 8efec702..5f53bf6a 100644 --- a/src/backend/common/vendor/maloja/interfaces.ts +++ b/src/backend/common/vendor/maloja/interfaces.ts @@ -74,7 +74,7 @@ export interface MalojaScrobbleV3RequestData extends MalojaScrobbleRequestData { nofix?: boolean } -interface MalojaScrobbleWarning { +export interface MalojaScrobbleWarning { type: string value: string[] | string desc: string diff --git a/src/backend/scrobblers/MalojaScrobbler.ts b/src/backend/scrobblers/MalojaScrobbler.ts index c66b4c95..a393d259 100644 --- a/src/backend/scrobblers/MalojaScrobbler.ts +++ b/src/backend/scrobblers/MalojaScrobbler.ts @@ -19,7 +19,7 @@ import { MalojaScrobbleRequestData, MalojaScrobbleV2RequestData, MalojaScrobbleV3RequestData, - MalojaScrobbleV3ResponseData, + MalojaScrobbleV3ResponseData, MalojaScrobbleWarning, MalojaV2ScrobbleData, MalojaV3ScrobbleData, } from "../common/vendor/maloja/interfaces.js"; @@ -59,7 +59,7 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { // when the track was scrobbled time: mTime, track: { - artists: mArtists, + artists: mArtists = [], title: mTitle, album: mAlbum, // length of the track @@ -77,14 +77,14 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { const { albumtitle, name: mAlbumName, - artists: albumArtists + artists: albumArtists = [] } = mAlbum || {}; album = albumtitle ?? mAlbumName; } } else { // scrobble data structure for v2 and below const { - artists: mArtists, + artists: mArtists = [], title: mTitle, album: mAlbum, duration: mDuration, @@ -438,7 +438,11 @@ export default class MalojaScrobbler extends AbstractScrobbleClient { } if(warnings.length > 0) { for(const w of warnings) { - this.logger.warn(`Maloja Warning: ${w.desc} => ${JSON.stringify(w.value)}`) + const warnStr = buildWarningString(w); + if(warnStr.includes('The submitted scrobble was not added')) { + throw new UpstreamError(`Maloja returned a warning but MS treating as error: ${warnStr}`, {showStopper: false}); + } + this.logger.warn(`Maloja Warning: ${warnStr}`); } } } else { @@ -517,3 +521,19 @@ const buildErrorString = (body: MalojaResponseV3CommonData) => { } return `Maloja API returned ${status} of type ${type} "${desc}"${valString !== undefined ? `: ${valString}` : ''}`; } + +const buildWarningString = (w: MalojaScrobbleWarning): string => { + const parts: string[] = [`${typeof w.type === 'string' ? `(${w.type}) ` : ''}${w.desc ?? ''}`]; + let vals: string[] = []; + if(w.value !== null && w.value !== undefined) { + if(Array.isArray(w.value)) { + vals = w.value; + } else { + vals.push(w.value); + } + } + if(vals.length > 0) { + parts.push(vals.join(' | ')); + } + return parts.join(' => '); +} -- 2.51.2 From 08056a650f26c00f30b1ed14944c3030264439fe Mon Sep 17 00:00:00 2001 From: FoxxMD <FoxxMD@users.noreply.github.com> Date: Tue, 27 Aug 2024 09:40:30 -0400 Subject: [PATCH 8/8] fix: Actually store recent scrobbles Refresh scrobbles refactor missed actually storing scrobbles on refresh --- .../scrobblers/AbstractScrobbleClient.ts | 1 + src/backend/tests/scrobbler/TestScrobbler.ts | 9 +- .../tests/scrobbler/scrobblers.test.ts | 96 +++++++++++-------- 3 files changed, 62 insertions(+), 44 deletions(-) diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 1696c0e3..795cf415 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -153,6 +153,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i this.logger.debug('Refreshing recent scrobbles'); const recent = await this.getScrobblesForRefresh(limit); this.logger.debug(`Found ${recent.length} recent scrobbles`); + this.recentScrobbles = recent; 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/backend/tests/scrobbler/TestScrobbler.ts b/src/backend/tests/scrobbler/TestScrobbler.ts index 7006063e..ff361093 100644 --- a/src/backend/tests/scrobbler/TestScrobbler.ts +++ b/src/backend/tests/scrobbler/TestScrobbler.ts @@ -6,9 +6,8 @@ import { Notifiers } from "../../notifier/Notifiers.js"; import AbstractScrobbleClient from "../../scrobblers/AbstractScrobbleClient.js"; export class TestScrobbler extends AbstractScrobbleClient { - protected async getScrobblesForRefresh(limit: number): Promise<PlayObject[]> { - return []; - } + + testRecentScrobbles: PlayObject[] = []; constructor() { const logger = loggerTest; @@ -16,6 +15,10 @@ export class TestScrobbler extends AbstractScrobbleClient { super('test', 'Test', {name: 'test'}, notifier, new EventEmitter(), logger); } + protected async getScrobblesForRefresh(limit: number): Promise<PlayObject[]> { + return this.testRecentScrobbles; + } + doScrobble(playObj: PlayObject): Promise<PlayObject> { return Promise.resolve(playObj); } diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index b2161921..4880197a 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -458,66 +458,80 @@ describe('Detects duplicate and unique scrobbles using actively tracked scrobble }); }); -describe('Detects when upstream scrobbles should be refreshed', function() { +describe('Upstream Scrobbles', function() { + + it('Stores upstream scrobbles on refresh', async function () { + const scrobbler = generateTestScrobbler(); + scrobbler.testRecentScrobbles = normalizedWithMixedDur; + assert.isEmpty(scrobbler.recentScrobbles); + await scrobbler.refreshScrobbles(); + assert.isNotEmpty(scrobbler.recentScrobbles); + }); - const normalizedClose = normalizePlays(withDurPlays, {initialDate: dayjs().subtract(100, 'seconds')}); + describe('Detects when upstream scrobbles should be refreshed', function() { - beforeEach(function () { - testScrobbler = generateTestScrobbler(); - testScrobbler.recentScrobbles = normalizedWithMixedDur; - testScrobbler.newestScrobbleTime = normalizedWithMixedDur[0].data.playDate; - testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds'); - testScrobbler.queuedScrobbles = []; - testScrobbler.config.options = {}; - }); + const normalizedClose = normalizePlays(withDurPlays, {initialDate: dayjs().subtract(100, 'seconds')}); - it('Detects queued scrobble date is newer than last scrobble refresh', async function() { - const newScrobble = generatePlay({ - playDate: dayjs() + beforeEach(function () { + testScrobbler = generateTestScrobbler(); + testScrobbler.recentScrobbles = normalizedWithMixedDur; + testScrobbler.newestScrobbleTime = normalizedWithMixedDur[0].data.playDate; + testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds'); + testScrobbler.queuedScrobbles = []; + testScrobbler.config.options = {}; }); - testScrobbler.queueScrobble(newScrobble, 'test'); - assert.isTrue(testScrobbler.shouldRefreshScrobble()); - }); - - it('Detects queued scrobble date is older than newest scrobble', async function() { - testScrobbler.recentScrobbles = normalizedClose; - testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate; + it('Detects queued scrobble date is newer than last scrobble refresh', async function() { + const newScrobble = generatePlay({ + playDate: dayjs() + }); - const newScrobble = generatePlay({ - playDate: dayjs().subtract(120, 'seconds') + testScrobbler.queueScrobble(newScrobble, 'test'); + assert.isTrue(testScrobbler.shouldRefreshScrobble()); }); - testScrobbler.queueScrobble(newScrobble, 'test'); - assert.isTrue(testScrobbler.shouldRefreshScrobble()); - }); + it('Detects queued scrobble date is older than newest scrobble', async function() { + testScrobbler.recentScrobbles = normalizedClose; + testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate; - it('Forces refresh if refreshStaleAfter is set', async function() { - testScrobbler.recentScrobbles = normalizedClose; - testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate; - testScrobbler.config.options = { refreshStaleAfter: 10 }; + const newScrobble = generatePlay({ + playDate: dayjs().subtract(120, 'seconds') + }); - const newScrobble = generatePlay({ - playDate: dayjs().subtract(80, 'seconds') + testScrobbler.queueScrobble(newScrobble, 'test'); + assert.isTrue(testScrobbler.shouldRefreshScrobble()); }); - testScrobbler.queueScrobble(newScrobble, 'test'); - assert.isTrue(testScrobbler.shouldRefreshScrobble()); - }); + it('Forces refresh if refreshStaleAfter is set', async function() { + testScrobbler.recentScrobbles = normalizedClose; + testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate; + testScrobbler.config.options = { refreshStaleAfter: 10 }; - it('Does not refresh if scrobble is older than last check but newer than newest upstream scrobble', async function() { - testScrobbler.recentScrobbles = normalizedClose; - testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate; + const newScrobble = generatePlay({ + playDate: dayjs().subtract(80, 'seconds') + }); - const newScrobble = generatePlay({ - playDate: dayjs().subtract(80, 'seconds') + testScrobbler.queueScrobble(newScrobble, 'test'); + assert.isTrue(testScrobbler.shouldRefreshScrobble()); }); - testScrobbler.queueScrobble(newScrobble, 'test'); - assert.isFalse(testScrobbler.shouldRefreshScrobble()); + it('Does not refresh if scrobble is older than last check but newer than newest upstream scrobble', async function() { + testScrobbler.recentScrobbles = normalizedClose; + testScrobbler.newestScrobbleTime = normalizedClose[0].data.playDate; + + const newScrobble = generatePlay({ + playDate: dayjs().subtract(80, 'seconds') + }); + + testScrobbler.queueScrobble(newScrobble, 'test'); + assert.isFalse(testScrobbler.shouldRefreshScrobble()); + }); }); + }); + + describe('Scrobble client uses transform plays correctly', function() { beforeEach(async function() {