import { childLogger, type Logger } from '@foxxmd/logging'; import dayjs, { type Dayjs } from "dayjs"; import type {PlayObject, SourcePlayerObj} from "../../core/Atomic.ts"; import type {ConfigMeta, InternalConfig, InternalConfigOptional, SourceIdentifier} from "../common/infrastructure/Atomic.ts"; import { isClientType } from '../../core/Atomic.ts'; import { clientTypes } from "../../core/Atomic.ts"; import type {ClientType} from "../../core/Atomic.ts"; import {aioClientRelaxedConfigSchema, type AIOClientRelaxedConfig} from "../common/infrastructure/config/aioConfig.ts"; import {validateClientAIOJson, validateClientJson, type ClientAIOConfig} from "../common/infrastructure/config/client/clients.ts"; import type {LastfmClientConfig, LastfmData} from "../common/infrastructure/config/client/lastfm.ts"; import type {ListenBrainzClientConfig, ListenBrainzData} from "../common/infrastructure/config/client/listenbrainz.ts"; import type {MalojaClientConfig, MalojaData} from "../common/infrastructure/config/client/maloja.ts"; import type { WildcardEmitter } from "../common/WildcardEmitter.ts"; import type { Notifiers } from "../notifier/Notifiers.ts"; import { nonEmptyObj } from "../utils.ts"; import { removeUndefinedKeys } from '../../core/DataUtils.ts'; import { getCommonComponentEnvConfig, readJson } from '../utils/DataUtils.ts'; import type AbstractScrobbleClient from "./AbstractScrobbleClient.ts"; import type {KoitoClientConfig, KoitoData} from '../common/infrastructure/config/client/koito.ts'; import type {TealClientConfig, TealData} from '../common/infrastructure/config/client/tealfm.ts'; import type {RockSkyClientConfig, RockSkyData} from '../common/infrastructure/config/client/rocksky.ts'; import type {CommonClientOptions} from '../common/infrastructure/config/client/index.ts'; import type {ExternalMetadataTerm, PlayTransformHooks} from '../../core/Transform.ts'; import type {LibrefmClientConfig, LibrefmData} from '../common/infrastructure/config/client/librefm.ts'; import clone from 'clone'; import type {DiscordClientConfig, DiscordData} from '../common/infrastructure/config/client/discord.ts'; import { stripIndents } from 'common-tags'; import { normalizeStr, type StringNormalizationOptions } from '../utils/StringUtils.ts'; import { prettifyError, ZodError } from 'zod'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; type ParsedConfig = ClientAIOConfig & ConfigMeta; const clientScrobbleToNormalization: StringNormalizationOptions = { removeWhitespace: true, removeSymbols: false, normalizeUnicode: false, removeDiacritics: false } export default class ScrobbleClients { clients: AbstractScrobbleClient[] = []; logger: Logger; internalConfig: InternalConfig; emitter: WildcardEmitter; sourceEmitter: WildcardEmitter; scrobbleToNamesWarnings: string[] = []; constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) { this.emitter = emitter; this.sourceEmitter = sourceEmitter; this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr); this.internalConfig = { ...internal, logger: this.logger } this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => { // agressively update Now Playing so scrobblers that display based on duration are mostly synced // but aggressively *stop* updating if state becomes stale/orphaned this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}}); }); this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => { await this.scrobble(payload.data, payload.options); }); } getByName = (name: any, safe: boolean = false) => this.clients.find(x => (safe ? x.getSafeExternalName() : x.name) === name) getByType = (type: any) => this.clients.filter(x => x.type === type) getByNameAndType = (name: string, type: ClientType, safe: boolean = false) => this.clients.find(x => (safe ? x.getSafeExternalName() : x.name) === name && x.type === type) async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> { let clients: AbstractScrobbleClient[] = []; const messages: string[] = []; let clientsReady = true; if(type !== undefined) { clients = this.getByType(type); } else if(name !== undefined) { const clientByName = this.getByName(name); if(clientByName !== undefined) { clients = [clientByName]; } } else { clients = this.clients; } for(const client of clients) { if(!(await client.isReady())) { clientsReady = false; messages.push(`Client ${client.type} - ${client.name} is not ready.`); } } return [clientsReady, messages]; } buildClientsFromConfig = async (notifier: Notifiers) => { const configs: ParsedConfig[] = []; let configFile; try { configFile = await readJson(`${this.internalConfig.configDir}/config.json`, {throwOnNotFound: false, logger: childLogger(this.logger, `Secrets`)}); } catch (e) { // think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore throw new Error('config.json could not be parsed', {cause: e}); } let clientDefaults = {}; if (configFile !== undefined) { let aioConfig: AIOClientRelaxedConfig; try { aioConfig = aioClientRelaxedConfigSchema.parse(configFile); } catch (e) { const msg = `Validation error occurred while trying to parse 'config.json' for Client data/options`; if(e instanceof ZodError) { this.logger.error(`${msg}:\n${prettifyError(e)}`); } else { this.logger.error(new Error(msg, {cause: e})); } return; } const { clients: mainConfigClientConfigs = [], clientDefaults: cd = {}, database: { retention } = {}, } = aioConfig; clientDefaults = {retention, ...cd}; for (const [index, c] of mainConfigClientConfigs.entries()) { const {name = 'unnamed'} = c; if(c.type === undefined) { const invalidMsgType = `Client config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[clientType]" must be one of ${clientTypes.join(' | ')}`; this.logger.error(invalidMsgType); continue; } if(c.configureAs === 'source') { this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`); continue; } let validatedConfig: ClientAIOConfig; try { validatedConfig = await validateClientAIOJson(c.type.toLocaleLowerCase() as ClientType, c); } catch (e) { const msg = `Client config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`; const err = new Error(msg, {cause: e}); this.emitter.emit('error', err); // pretty print error if its a zod error if(e instanceof ZodError) { this.logger.error(`${msg}:\n${prettifyError(e)}`); } else { this.logger.error(err); } continue; } configs.push({...validatedConfig, name: validatedConfig.name ?? 'unnamed', source: 'config.json', configureAs: 'client', //override user value }); } } for (const clientType of clientTypes) { const defaultConfigureAs = 'client'; switch (clientType) { case 'maloja': { // env builder for single user mode const data = removeUndefinedKeys({ url: process.env.MALOJA_URL, apiKey: process.env.MALOJA_API_KEY }, false); const p = getCommonComponentEnvConfig('MALOJA'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'maloja', name: 'unnamed-mlj', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('MALOJA') }) } } break; case 'lastfm': { const data = removeUndefinedKeys({ apiKey: process.env.LASTFM_API_KEY, secret: process.env.LASTFM_SECRET, redirectUri: process.env.LASTFM_REDIRECT_URI, session: process.env.LASTFM_SESSION }, false); const p = getCommonComponentEnvConfig('LASTFM'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'lastfm', name: 'unnamed-lfm', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('LASTFM') }) } } break; case 'librefm': { const data = removeUndefinedKeys({ apiKey: process.env.LIBREFM_API_KEY, secret: process.env.LIBREFM_SECRET, redirectUri: process.env.LIBREFM_REDIRECT_URI, session: process.env.LIBREFM_SESSION, urlBase: process.env.LIBREFM_URLBASE, }, false); const p = getCommonComponentEnvConfig('LIBREFM'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'librefm', name: 'unnamed-librefm', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('LIBREFM') }) } } break; case 'listenbrainz': { const data = removeUndefinedKeys({ url: process.env.LZ_URL, token: process.env.LZ_TOKEN, username: process.env.LZ_USER }, false); const p = getCommonComponentEnvConfig('LZ'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'listenbrainz', name: 'unnamed-lz', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('LZ') }) } } break; case 'koito': { const data = removeUndefinedKeys({ url: process.env.KOITO_URL, token: process.env.KOITO_TOKEN, username: process.env.KOITO_USER }, false); const p = getCommonComponentEnvConfig('KOITO'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'koito', name: 'unnamed-koito', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('KOITO') }) } } break; case 'tealfm': { const data: TealData = removeUndefinedKeys({ identifier: process.env.TEALFM_IDENTIFIER, appPassword: process.env.TEALFM_APP_PW, }, false); const p = getCommonComponentEnvConfig('TEALFM'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'tealfm', name: 'unnamed-tealfm', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('TEALFM') }) } } break; case 'rocksky': { const data: RockSkyData = removeUndefinedKeys({ key: process.env.ROCKSKY_KEY, token: process.env.ROCKSKY_TOKEN, handle: process.env.ROCKSKY_HANDLE }, false); const p = getCommonComponentEnvConfig('ROCKSKY'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'rocksky', name: 'unnamed-rocksky', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('ROCKSKY') }) } } break; case 'discord': { const data: DiscordData = removeUndefinedKeys({ token: process.env.DISCORD_TOKEN, artwork: process.env.DISCORD_ARTWORK, applicationId: process.env.DISCORD_APPLICATION_ID, ipcLocations: process.env.DISCORD_IPC_LOCATIONS, artworkDefaultUrl: process.env.DISCORD_ARTWORK_DEFAULT_URL, statusOverrideAllow: process.env.DISCORD_STATUS_OVERRIDE_ALLOW, listeningActivityAllow: process.env.DISCORD_LISTENING_ACTIVITY_ALLOW }, false); const p = getCommonComponentEnvConfig('DISCORD'); if (nonEmptyObj(data) || nonEmptyObj(p)) { configs.push({ type: 'discord', name: 'unnamed-discord', source: 'ENV', mode: 'single', configureAs: 'client', data: data, ...p, options: transformPresetEnv('DISCORD') }) } } break; default: break; } let rawClientConfigs; try { rawClientConfigs = await readJson(`${this.internalConfig.configDir}/${clientType}.json`, {throwOnNotFound: false, logger: childLogger(this.logger, `${clientType} Secrets`)}); } catch (e) { const errMsg = `${clientType}.json config file could not be parsed`; this.emitter.emit('error', errMsg); this.logger.error(errMsg); continue; } if (rawClientConfigs !== undefined) { let clientConfigs: ParsedConfig[] = []; if (Array.isArray(rawClientConfigs)) { clientConfigs = rawClientConfigs; } else if(rawClientConfigs === null) { this.logger.error(`${clientType}.json contained no data`); continue; } else if(typeof rawClientConfigs === 'object') { clientConfigs = [rawClientConfigs]; } else { this.logger.error(`All top level data from ${clientType}.json must be an object or an array of objects, will not parse configs from file`); continue; } for(const [i,rawConf] of rawClientConfigs.entries()) { if(rawConf.configureAs === 'source') { this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`); continue; } try { const validConfig = await validateClientJson(clientType, rawConf); // await validateJson('client', rawConf, this.getSchemaByType(clientType), this.logger); const {configureAs = defaultConfigureAs} = validConfig; if (configureAs === 'client') { const parsedConfig: ParsedConfig = { ...rawConf, source: `${clientType}.json`, type: clientType } configs.push(parsedConfig); } } catch (e: any) { const msg = `The config entry at index ${i} from ${clientType}.json was not valid`; const configErr = new Error(msg, {cause: e}); this.emitter.emit('error', configErr); // pretty print error if its a zod error if(e instanceof ZodError) { this.logger.error(`${msg}:\n${prettifyError(e)}`); } else { this.logger.error(configErr); } } } } } // all client configs are minimally valid // now check that names are unique const nameGroupedConfigs = configs.reduce((acc: groupedNamedConfigs, curr: ParsedConfig) => { const {name = 'unnamed'} = curr; const {[name]: n = []} = acc; return {...acc, [name]: [...n, curr]}; }, {}); let noConflictConfigs: ParsedConfig[] = []; for (const [name, configs] of Object.entries(nameGroupedConfigs)) { if (configs.length > 1) { const sources = configs.map((c: any) => `Config object from ${c.source} of type [${c.type}]`); this.logger.error(`The following clients will not be built because of config naming conflicts (they have the same name of "${name}"): ${sources.join('\n')}`); if (name === 'unnamed') { this.logger.info('HINT: "unnamed" configs occur when using ENVs, if a multi-user mode config does not have a "name" property, or if a config is built in single-user mode'); } } else { noConflictConfigs = [...noConflictConfigs, ...configs]; } } // finally! all configs are valid, structurally, and can now be passed to addClient // just need to re-map unnnamed to default const finalConfigs: ParsedConfig[] = noConflictConfigs.map(({name = 'unnamed', ...x}) => ({ ...x, name })); for (const c of finalConfigs) { try { await this.addClient(c, clientDefaults, notifier); } catch(e) { const addError = new Error(`Client ${c.name} from ${c.source} was not added because it had unrecoverable errors`, {cause: e}); this.emitter.emit('error', addError); this.logger.error(addError); } } } addClient = async (clientConfig: ParsedConfig, defaults = {}, notifier: Notifiers) => { /* const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true}); if (isValidConfig !== true) { throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`) }*/ const {type, name, enable = true, source, data: d = {}, options = {}} = clientConfig; if(enable === false) { this.logger.warn({labels: [`${type} - ${name}`]}, `Client from ${source} was disabled by config`); return; } // add defaults const compositeOptions = {...defaults, ...options}; let newClient; this.logger.debug({labels: [`${type} - ${name}`]}, `Constructing Client from ${source}`); switch (type) { case 'maloja': { const MalojaScrobbler = (await import('./MalojaScrobbler.ts')).default; newClient = new MalojaScrobbler(name, ({...clientConfig, data: d, options: compositeOptions} as unknown as MalojaClientConfig), this.emitter, this.logger); break; } case 'lastfm': { const LastfmScrobbler = (await import('./LastfmScrobbler.ts')).default; newClient = new LastfmScrobbler(name, {...clientConfig, data: d, options: compositeOptions } as unknown as LastfmClientConfig, this.internalConfig, this.emitter, this.logger); break; } case 'librefm': { const LibrefmScrobbler = (await import('./LibrefmScrobbler.ts')).default; newClient = new LibrefmScrobbler(name, {...clientConfig, data: d, options: compositeOptions } as unknown as LibrefmClientConfig, this.internalConfig, this.emitter, this.logger); break; } case 'listenbrainz': { const ListenbrainzScrobbler = (await import('./ListenbrainzScrobbler.ts')).default; newClient = new ListenbrainzScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as ListenBrainzClientConfig, {}, this.emitter, this.logger); break; } case 'koito': { const KoitoScrobbler = (await import('./KoitoScrobbler.ts')).default; newClient = new KoitoScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as KoitoClientConfig, {}, this.emitter, this.logger); break; } case 'tealfm': { const TealScrobbler = (await import('./TealfmScrobbler.ts')).default; newClient = new TealScrobbler(name, {...clientConfig, data: d, options: compositeOptions} as unknown as TealClientConfig, this.internalConfig, this.emitter, this.logger); break; } case 'rocksky': { const RockskyScrobbler = (await import('./RockskyScrobbler.ts')).default; newClient = new RockskyScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as RockSkyClientConfig, this.internalConfig, this.emitter, this.logger); break; } case 'discord': { const DiscordScrobbler = (await import('./DiscordScrobbler.ts')).default; newClient = new DiscordScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as DiscordClientConfig, {}, this.emitter, this.logger); break; } default: break; } if(newClient === undefined) { // really shouldn't get here! throw new Error(`Client of type ${type} from ${source} was not recognized??`); } newClient.logger.info(`Client Added from ${source}`); this.clients.push(newClient); } playingNow = async (data: SourcePlayerObj, options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => { const playObjs = Array.isArray(data) ? data : [data]; const { scrobbleTo = [], scrobbleFrom, } = options; if (this.clients.length === 0) { this.logger.trace('Cannot update Now Playing! No clients are configured.'); } const excluded: string[] = []; for (const client of this.clients) { if(!client.supportsNowPlaying || !client.nowPlayingEnabled) { continue; } if (scrobbleTo.length > 0) { // removing whitespace, case-insensitive, and trimming const cNameNormal = normalizeStr(client.name, clientScrobbleToNormalization); const cUidNormal = normalizeStr(client.getUid(), clientScrobbleToNormalization); const name = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cNameNormal) const id = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cUidNormal); if(name === undefined && id === undefined) { excluded.push(client.getUid()); continue; } else if(name !== undefined && id === undefined && !this.scrobbleToNamesWarnings.includes(`${name}-${scrobbleFrom.type}-${scrobbleFrom.name}`)) { client.logger.warn(stripIndents`Using Client *name* '${name}' in the \`clients\` fields for a Source (${scrobbleFrom}) is DEPRECATED and will be removed in a future release. Replace the *name* with the *id* '${client.getUid()}' of this Client.`); this.scrobbleToNamesWarnings.push(`${name}-${scrobbleFrom}`); } } for (const playObj of playObjs) { await client.queuePlayingNow(playObj, scrobbleFrom); } } if(excluded.length > 0) { this.logger.trace(`These Now Playing clients were filtered from Source '${scrobbleFrom.type} - ${scrobbleFrom.name}' => ${excluded.join(' | ')}`); } } getPlayingNow = (source: string, scrobbleTo: string[]): PlayObject[] => { const playingNow = []; for (const client of this.clients) { if(!client.supportsNowPlaying || !client.nowPlayingEnabled) { continue; } if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) { continue; } if(client.nowPlayingSourceAllowed(source) && client.nowPlayingLastPlay !== undefined) { playingNow.push(client.nowPlayingLastPlay.play); } } return playingNow.filter(x => x !== undefined); } scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => { const playObjs = Array.isArray(data) ? data : [data]; const { forceRefresh = false, checkTime = dayjs(), scrobbleTo = [], scrobbleFrom = 'source', } = options; if (this.clients.length === 0) { this.logger.warn('Cannot scrobble! No clients are configured.'); } const excluded: string[] = []; for (const client of this.clients) { if (scrobbleTo.length > 0) { // removing whitespace, case-insensitive, and trimming const cNameNormal = normalizeStr(client.name, clientScrobbleToNormalization); const cUidNormal = normalizeStr(client.getUid(), clientScrobbleToNormalization); const name = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cNameNormal) const id = scrobbleTo.find(x => normalizeStr(x, clientScrobbleToNormalization) === cUidNormal); if(name === undefined && id === undefined) { excluded.push(client.getUid()); continue; } else if(name !== undefined && id === undefined && !this.scrobbleToNamesWarnings.includes(`${name}-${scrobbleFrom}`)) { client.logger.warn(stripIndents`Using Client *name* '${name}' in the \`clients\` fields for a Source (${scrobbleFrom}) is DEPRECATED and will be removed in a future release. Replace the *name* with the *id* '${client.getUid()}' of this Client.`); this.scrobbleToNamesWarnings.push(`${name}-${scrobbleFrom}`); } } for (const playObj of playObjs) { await client.queueScrobble(clone(playObj), scrobbleFrom); } } if(excluded.length > 0) { this.logger.trace(`These clients were filtered from scrobbling from Source '${scrobbleFrom}' => ${excluded.join(' | ')}`); } } } const transformPresetEnv = (prefix: string, existing: T = undefined): undefined | T => { const env = process.env[`${prefix}_TRANSFORMS`]; if(env === undefined || env.trim() === '') { return existing; } const popts: PlayTransformHooks = { preCompare: [ ] } for(const p of env.split(',').map(x => x.trim().toLocaleLowerCase())) { switch(p) { case 'native': popts.preCompare.push({type: 'native'}); break; case 'musicbrainz': popts.preCompare.push({type: 'musicbrainz'}); break; } } // @ts-expect-error T is fine return { ...(existing || {}), playTransform: popts }; }