Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
Something went wrong. Try again.
25 kB · 495 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495 import { childLogger, type Logger } from '@foxxmd/logging';import type {InternalConfig, InternalConfigOptional} from "../common/infrastructure/Atomic.ts";import { clientTypes, isSourceType } from "../../core/Atomic.ts";import { sourceTypes } from "../../core/Atomic.ts";import type {SourceType} from "../../core/Atomic.ts";import {aioSourceRelaxedConfigSchema, type AIOSourceRelaxedConfig, type SourceDefaults} from "../common/infrastructure/config/aioConfig.ts";import type { WildcardEmitter } from "../common/WildcardEmitter.ts";import { pick } from '../../core/DataUtils.ts';import { readJson } from '../utils/DataUtils.ts';import type AbstractSource from "./AbstractSource.ts";import { nonEmptyStringOrDefault } from '../../core/StringUtils.ts';import type {CommonSourceConfig, CommonSourceOptions} from '../common/infrastructure/config/source/index.ts';import type {ExternalMetadataTerm, PlayTransformHooks} from '../../core/Transform.ts';import { prettifyError, ZodError } from 'zod';import { commonComponentEnvConfigToConfigPrimitives, generateCommonComponentEnvConfigSchema, generateConfigLocation, type CommonConfigPrimitives, type UnparsedConfig } from '../common/infrastructure/config/common.ts';import { getSourceEnvSchema, validateSourceAIOJson, validateSourceJson } from '../common/infrastructure/config/source/sourcesMap.ts';import type { SourceTypeConfigMap } from "../common/infrastructure/config/source/sourcesMap.ts";import { stripIndents } from 'common-tags';import type { MSBackendEventMap } from '../common/infrastructure/MSBackendEventMap.ts';
type UnparsedSourceConfig = UnparsedConfig<SourceType>;
type CommonParsedConfig = (CommonSourceConfig & {source: string});
export default class ScrobbleSources {
sources: AbstractSource[] = []; logger: Logger; internalConfig: InternalConfig; configErrors: (string | Error)[] = []; instantiateErrors: Error[] = [];
emitter: WildcardEmitter<MSBackendEventMap>;
constructor(emitter: WildcardEmitter<MSBackendEventMap>, internal: InternalConfigOptional, parentLogger: Logger) { this.emitter = emitter; this.logger = childLogger(parentLogger, 'Sources'); this.internalConfig = { ...internal, logger: this.logger } }
getByName = (name: any, safe: boolean = false) => this.sources.find(x => (safe ? x.getSafeExternalName() : x.name) === name)
getByType = (type: any) => this.sources.filter(x => x.type === type)
getByNameAndType = (name: string, type: SourceType, safe: boolean = false) => this.sources.find(x => (safe ? x.getSafeExternalName() : x.name) === name && x.type === type)
async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> { let sources: AbstractSource[] = []; let sourcesReady = true; const messages: string[] = [];
if(type !== undefined) { sources = this.getByType(type); } else if(name !== undefined) { const sourceByName = this.getByName(name); if(sourceByName !== undefined) { sources = [sourceByName]; } } else { sources = this.sources; }
for(const source of sources) { if(source.requiresAuth && !source.authed) { sourcesReady = false; messages.push(`Source ${source.type} - ${source.name} requires authentication.`); } if(source.canPoll && !source.polling) { sourcesReady = false; messages.push(`Source ${source.type} - ${source.name} is not polling.`); } }
return [sourcesReady, messages]; }
buildSourceDefaults = (fileDefaults: SourceDefaults = {}): SourceDefaults => { const scrobbleDurationEnv = process.env.SOURCE_SCROBBLE_DURATION; const scrobblePercentEnv = process.env.SOURCE_SCROBBLE_PERCENT;
const buildDefaults = {...fileDefaults};
if(nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined || nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) { const { scrobbleThresholds: { duration, percent } = {}, scrobbleThresholds = {} } = fileDefaults; buildDefaults.scrobbleThresholds = {...scrobbleThresholds};
if(duration === undefined && nonEmptyStringOrDefault(scrobbleDurationEnv) !== undefined) { const envDur = Number.parseInt(scrobbleDurationEnv); if(Number.isNaN(envDur)) { this.logger.warn(`Ignoring value '${scrobbleDurationEnv}' for env SOURCE_SCROBBLE_DURATION because it is not a number`); } else { buildDefaults.scrobbleThresholds.duration = envDur; this.logger.verbose(`Set default scrobble threshold duration to '${scrobbleDurationEnv}' based on env SOURCE_SCROBBLE_DURATION`); } } if(percent === undefined && nonEmptyStringOrDefault(scrobblePercentEnv) !== undefined) { const envPercent = Number.parseInt(scrobblePercentEnv); if(Number.isNaN(envPercent)) { this.logger.warn(`Ignoring value '${scrobblePercentEnv}' for env SOURCE_SCROBBLE_PERCENT because it is not a number`); } else { buildDefaults.scrobbleThresholds.percent = envPercent; this.logger.verbose(`Set default scrobble threshold percent to '${scrobblePercentEnv}' based on env SOURCE_SCROBBLE_PERCENT`); } } }
return buildDefaults; }
buildSourcesFromConfig = async () => { const unparsedConfigs: UnparsedSourceConfig[] = []; this.configErrors = []; this.instantiateErrors = [];
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 sourceDefaults: SourceDefaults; if (configFile !== undefined) { let aioConfig: AIOSourceRelaxedConfig; try { aioConfig = aioSourceRelaxedConfigSchema.parse(configFile); } catch (e) { const msg = `Validation error occurred while trying to parse 'config.json' for Source data/options`; if (e instanceof ZodError) { this.logger.error(`${msg}:\n${prettifyError(e)}`); } else { this.logger.error(new Error(msg, { cause: e })); } return; } const { sources: mainConfigs = [], sourceDefaults: cd = {}, database: { retention } = {}, } = aioConfig; sourceDefaults = this.buildSourceDefaults({ retention, ...cd }); for (const [index, c] of mainConfigs.entries()) { const { name = 'unnamed' } = c; if (c.type === undefined) { const invalidMsgType = `Source config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[sourceType]" must be one of ${sourceTypes.join(' | ')}`; this.logger.error(invalidMsgType); continue; } if (isSourceType(c.type)) { unparsedConfigs.push({ config: c, source: 'aio', type: c.type, pos: `${index + 1} (${name})` }); } else { const invalidMsgType = `Source config ${index + 1} (${name}) in config.json has an invalid "type" property. Must be one of must be one of ${sourceTypes.join(' | ')}`; this.logger.error(invalidMsgType); continue; } } } else { sourceDefaults = this.buildSourceDefaults(); }
const envKeys = Object.keys(process.env).map(x => x.toUpperCase());
for (const configType of sourceTypes) {
let sourceUnparsedConfigs = unparsedConfigs.filter(x => x.type === configType);
let rawConfigs; try { rawConfigs = await readJson(`${this.internalConfig.configDir}/${configType}.json`, { throwOnNotFound: false, logger: childLogger(this.logger, `${configType} Secrets`) }); } catch (e) { const errMsg = `${configType}.json config file could not be parsed`; this.emitter.emit('error', errMsg); this.logger.error(errMsg); }
if (rawConfigs !== undefined) { this.logger.debug(`Found config file ${configType}.json`); if (Array.isArray(rawConfigs)) { sourceUnparsedConfigs = sourceUnparsedConfigs.concat(rawConfigs.map((x, i) => ({ config: x, type: configType, source: 'file', pos: `${i + 1}` }))); } else if (rawConfigs === null) { this.logger.warn(`${configType}.json contained no data`); } else if (typeof rawConfigs === 'object') { sourceUnparsedConfigs.push({ config: rawConfigs, type: configType, source: 'file', pos: `1` }) } else { this.logger.error(`All top level data from ${configType}.json must be an object or an array of objects, will not parse configs from file`); } }
const envSchema = await getSourceEnvSchema(configType); const configTypeUpper = envSchema.prefix.toUpperCase();
const configKeys = envKeys.filter(x => x.startsWith(`${configTypeUpper}_`)); if (configKeys.length > 0) { sourceUnparsedConfigs.push({ config: pick(process.env, ...configKeys), type: configType, source: 'env', pos: '' }) }
let strongConfigs: CommonParsedConfig[] = []; for (const entry of sourceUnparsedConfigs) { let parsedConfig: CommonParsedConfig; try { switch (entry.source) { case 'env': { const primitiveSchema = generateCommonComponentEnvConfigSchema(configTypeUpper); const parsed = primitiveSchema.parse(entry.config); const primitives: CommonConfigPrimitives = commonComponentEnvConfigToConfigPrimitives(configTypeUpper, parsed); const parsedEnvConfigValues = envSchema.env.parse(entry.config); const { data = {}, options = {}, ...rest } = envSchema.toConfig(parsedEnvConfigValues); const transformOptions = transformPresetEnv(configTypeUpper); parsedConfig = { name: `${configType} - ${entry.source}${entry.pos !== '' ? ` - ${entry.pos}` : ''} `, ...primitives, data, ...rest, source: generateConfigLocation('source', entry), options: { ...options, ...(transformOptions ?? {}) } }; } break; case 'file': { // only file config has a combined array of both source and client configs // // for source configs, it is required that "configureAs": "source" // // if `configureAs` is missing OR its value is not `source` then we skip it // // @ts-expect-error could be a client type if(clientTypes.includes(entry.type)) { const confAs = 'configureAs' in entry.config ? entry.config.configureAs : undefined; if(confAs === undefined || confAs !== 'source') { const reason = confAs === undefined ? `configureAs was not defined` : `configureAs was not set to 'source'`; this.logger.debug(`Skipping ${generateConfigLocation('source', entry)} because it is configured as a Client (${reason})`); continue; } } const parsed = await validateSourceJson(entry.type, entry.config); parsedConfig = { ...parsed, name: parsed.name ?? parsed.id, source: generateConfigLocation('source', entry) } } break; case 'aio': { // aio entries can also optionally have `configureAs` but it must always be `source` // and we are only including entries from the `sources` array at this point // so there's no need to manually check if `configureAs` is present const parsed = await validateSourceAIOJson(entry.type, entry.config) parsedConfig = { ...parsed, name: parsed.name ?? parsed.id, source: generateConfigLocation('source', entry) } } break; } } catch (e) { const msg = `Failed to validate ${generateConfigLocation('source', entry)}`; if (e instanceof ZodError) { const prettyError = `${msg}:\n${prettifyError(e)}` this.logger.error(prettyError); this.emitter.emit('configError', prettyError); this.configErrors.push(prettyError); } else { const err = new Error(msg, { cause: e }); this.logger.error(err); this.emitter.emit('configError', err); this.configErrors.push(err); } continue; }
const existingById = strongConfigs.find(x => x.id === parsedConfig.id); if(undefined !== existingById) { this.logger.error(stripIndents`There are two ${configType} Sources that have the same ID: ${existingById.source} ${parsedConfig.source} BOTH of these Sources will be disabled to prevent tainting database history. Correct this issue by using a different ID for at least one of them.`); strongConfigs = strongConfigs.filter(x => x.id !== parsedConfig.id); continue; } if (parsedConfig.enable === false) { this.logger.debug(`Not using Config ${parsedConfig.source} because it was marked as not enabled.`); continue; } strongConfigs.push(parsedConfig); }
if(strongConfigs.length > 0) { await this.addSource(configType, strongConfigs, sourceDefaults); } } }
private instantiateSources = async <T extends SourceType>( sourceType: T, strongConfigs: CommonParsedConfig[], defaults: SourceDefaults, Ctor: new (name: string, config: SourceTypeConfigMap[T][0], internalConfig: InternalConfig, emitter: WildcardEmitter<MSBackendEventMap>) => AbstractSource, ) => { for (const s of strongConfigs) { try { const config = await validateSourceJson(sourceType, s); const compositeOptions = { ...defaults, ...config.options }; const newComponent = new Ctor(config.name ?? config.id, { ...config, options: compositeOptions }, this.internalConfig, this.emitter); newComponent.logger.info(`Source added from ${s.source}`); this.sources.push(newComponent); } catch (e) { const err = new Error(`${s.source} was not added due to unrecoverable errors`, { cause: e }) this.logger.error(err); this.emitter.emit('instantiateError', err); this.instantiateErrors.push(err); } } }
addSource = async (sourceType: SourceType, strongConfigs: CommonParsedConfig[], defaults: SourceDefaults = {}) => { switch (sourceType) { case 'spotify': { const SpotifySource = (await import('./SpotifySource.ts')).default; await this.instantiateSources('spotify', strongConfigs, defaults, SpotifySource); } break; case 'plex': { const PlexApiSource = (await import('./PlexApiSource.ts')).default; await this.instantiateSources('plex', strongConfigs, defaults, PlexApiSource); } break; case 'subsonic': { const {SubsonicSource} = (await import('./SubsonicSource.ts')); await this.instantiateSources('subsonic', strongConfigs, defaults, SubsonicSource); } break; case 'jellyfin': { const JellyfinApiSource = (await import('./JellyfinApiSource.ts')).default; await this.instantiateSources('jellyfin', strongConfigs, defaults, JellyfinApiSource); } break; case 'lastfm': { const LastfmSource = (await import('./LastfmSource.ts')).default; await this.instantiateSources('lastfm', strongConfigs, defaults, LastfmSource); } break; case 'librefm': { const LibrefmSource = (await import('./LibrefmSource.ts')).default; await this.instantiateSources('librefm', strongConfigs, defaults, LibrefmSource); } break; case 'deezer': { const DeezerInternalSource = (await import('./DeezerInternalSource.ts')).default; await this.instantiateSources('deezer', strongConfigs, defaults, DeezerInternalSource); } break; case 'ytmusic': { const YTMusicSource = (await import('./YTMusicSource.ts')).default; await this.instantiateSources('ytmusic', strongConfigs, defaults, YTMusicSource); } break; case 'ymbridge': { const YandexMusicBridgeSource = (await import('./YandexMusicBridgeSource.ts')).default; await this.instantiateSources('ymbridge', strongConfigs, defaults, YandexMusicBridgeSource); } break; case 'mpris': { const {MPRISSource} = (await import('./MPRISSource.ts')); await this.instantiateSources('mpris', strongConfigs, defaults, MPRISSource); } break; case 'mopidy': { const {MopidySource} = (await import('./MopidySource.ts')); await this.instantiateSources('mopidy', strongConfigs, defaults, MopidySource); } break; case 'listenbrainz': { const ListenbrainzSource = (await import('./ListenbrainzSource.ts')).default; await this.instantiateSources('listenbrainz', strongConfigs, defaults, ListenbrainzSource); } break; case 'endpointlz': { const {EndpointListenbrainzSource} = (await import('./EndpointListenbrainzSource.ts')); await this.instantiateSources('endpointlz', strongConfigs, defaults, EndpointListenbrainzSource); } break; case 'endpointlfm': { const {EndpointLastfmSource} = (await import('./EndpointLastfmSource.ts')); await this.instantiateSources('endpointlfm', strongConfigs, defaults, EndpointLastfmSource); } break; case 'icecast': { const {IcecastSource} = (await import('./IcecastSource.ts')); await this.instantiateSources('icecast', strongConfigs, defaults, IcecastSource); } break; case 'jriver': { const {JRiverSource} = (await import('./JRiverSource.ts')); await this.instantiateSources('jriver', strongConfigs, defaults, JRiverSource); } break; case 'kodi': { const {KodiSource} = (await import('./KodiSource.ts')); await this.instantiateSources('kodi', strongConfigs, defaults, KodiSource); } break; case 'webscrobbler': { const {WebScrobblerSource} = (await import('./WebScrobblerSource.ts')); await this.instantiateSources('webscrobbler', strongConfigs, defaults, WebScrobblerSource); } break; case 'chromecast': { const {ChromecastSource} = (await import('./ChromecastSource.ts')); await this.instantiateSources('chromecast', strongConfigs, defaults, ChromecastSource); } break; case 'musikcube': { const {MusikcubeSource} = (await import('./MusikcubeSource.ts')); await this.instantiateSources('musikcube', strongConfigs, defaults, MusikcubeSource); } break; case 'musiccast': { const {MusicCastSource} = (await import('./MusicCastSource.ts')); await this.instantiateSources('musiccast', strongConfigs, defaults, MusicCastSource); } break; case 'mpd': { const {MPDSource} = (await import('./MPDSource.ts')); await this.instantiateSources('mpd', strongConfigs, defaults, MPDSource); } break; case 'vlc': { const {VLCSource} = (await import('./VLCSource.ts')); await this.instantiateSources('vlc', strongConfigs, defaults, VLCSource); } break; case 'azuracast': { const {AzuracastSource} = (await import('./AzuracastSource.ts')); await this.instantiateSources('azuracast', strongConfigs, defaults, AzuracastSource); } break; case 'koito': { const KoitoSource = (await import('./KoitoSource.ts')).default; await this.instantiateSources('koito', strongConfigs, defaults, KoitoSource); } break; case 'maloja': { const MalojaSource = (await import('./MalojaSource.ts')).default; await this.instantiateSources('maloja', strongConfigs, defaults, MalojaSource); } break; case 'tealfm': { const TealfmSource = (await import('./TealfmSource.ts')).default; await this.instantiateSources('tealfm', strongConfigs, defaults, TealfmSource); } break; case 'rocksky': { const RockskySource = (await import('./RockskySource.ts')).default; await this.instantiateSources('rocksky', strongConfigs, defaults, RockskySource); } break; case 'sonos': { const {SonosSource} = (await import('./SonosSource.ts')); await this.instantiateSources('sonos', strongConfigs, defaults, SonosSource); } break; case 'applemusic': { const AppleMusicSource = (await import('./AppleMusicSource.ts')).default; await this.instantiateSources('applemusic', strongConfigs, defaults, AppleMusicSource); } break; default: break; } }}
const transformPresetEnv = <T extends CommonSourceOptions = CommonSourceOptions>(prefix: string, existing: T = undefined): undefined | T => {
const env = process.env[`${prefix}_TRANSFORMS`]; if(env === undefined || env.trim() === '') { return existing; }
const popts: PlayTransformHooks<ExternalMetadataTerm> = { 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 };}