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.
12 kB · 306 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306import { stripIndents } from "common-tags";import * as z from "zod";import { type PlayTransformHooks, type ExternalMetadataTerm } from "../../../../core/Transform.ts";import type { CommonClientOptions } from "./client/index.ts";import type { MarkRequired } from "ts-essentials";import type { ClientType, SourceType } from "../../../../core/Atomic.ts";import { capitalize } from "../../../../core/StringUtils.ts";import type { CommonSourceOptions } from "./source/index.ts";import type { Logger } from "@foxxmd/logging";import { loggerNoop } from "../../MaybeLogger.ts";
export const commonConfigPrimitivesSchema = z.object({ name: z.string().optional(), id: z.string().optional(), enable: z.boolean().optional()});
export type CommonConfigPrimitives = MarkRequired<z.infer<typeof commonConfigPrimitivesSchema>, 'id'>;
export const commonDataSchema = z.record(z.string(), z.any()); // keyOmit<{ [key: string]: any }, "options">
export type CommonData = z.infer<typeof commonDataSchema>;
export const commonConfigSchema = z.object({ /** * Vanity name for this Source/Client * * @examples ["My Cool Component"] * */ name: z.string().optional().meta({ description: "Vanity name for this Source/Client", examples: ["Foxx's Cool Client"] }), /** A UNIQUE identifier for this Source/Client * * It should be unique for the given Source/Client type. No other Source/Client of the same type should have this ID. This ID will be used to register this Source/Client in the database so that it can be identified even if you change the name of the component. * * If no id is given the name of this component will be used. */ id: z.string().meta({ description: "A UNIQUE identifier for this Source/Client", examples: ["fooGlobalA"] }), data: commonDataSchema.optional(), /** * Should MS parse this config and create a client/source from it? * * @default true * @examples [true] * */ enable: z.boolean().optional().meta({ description: "Should MS parse this config and create a client/source from it?", default: true, examples: [true] })});
export type CommonConfig = z.infer<typeof commonConfigSchema>;
export const requestRetryOptionsSchema = z.object({ /** * default # of http request retries a source/client can make before error is thrown * * @default 1 * @examples [1] * */ maxRequestRetries: z.number().optional().meta({ description: stripIndents`default # of http **request** retries a source/client can make before error is thrown a test`, default: 1, examples: [1] }), /** * default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying) * * @default 1.5 * @examples [1.5] * */ retryMultiplier: z.number().optional().meta({ description: "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", default: 1.5, examples: [1.5] })}).meta({title: 'RequestRetryOptions'});
export type RequestRetryOptions = z.infer<typeof requestRetryOptionsSchema>;
export const pollingOptionsSchema = z.object({
/** * How long to wait before polling the source API for new tracks (in seconds) * * @default 10 * @examples [10] * */ interval: z.number().optional().meta({ description: "How long to wait before polling the source API for new tracks (in seconds)", default: 10, examples: [10] }),
/** * When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds) * * @default 30 * @examples [30] * */ maxInterval: z.number().optional().meta({ 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)", default: 30, examples: [30] }),
/** * Number of seconds after which A Player is considered Stale * * When Polling the source does not recieve data about a specific Player after X seconds it becomes Stale. When the Player becomes Stale: * * * The current listening session is ended. If the Player becomes active again a new listening session is started (Player will miss `interval` seconds of listening) * * If the player has an existing session w/ track then MS attempts to scrobble it * * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 3) when not defined. */ staleAfter: z.number().optional().meta({ description: "Number of seconds after which A Player is considered Stale" }),
/** * Number of seconds after which A Player is considered Orphaned * * When Polling the source does not recieve data about a specific Player after X seconds it becomes Orphaned. When the Player becomes Orphaned: * * * The current Player session is ended and the Player is removed from MS * * MS attempts to scrobble, if the Player has an existing session w/ track * * A Player should become Orphaned EQUAL TO OR AFTER it becomes Stale. * * * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 5) when not defined. * * If it is set it must be equal to or larger than `staleAfter` or (`interval * 3`) */ orphanedAfter: z.number().optional().meta({ description: "Number of seconds after which A Player is considered Orphaned" })});
export type PollingOptions = z.infer<typeof pollingOptionsSchema>;
export const monitorOptionsSchema = z.object({ /** * Set the default behavior for wether this component should automatically monitor any activity, or scrobble, it encounters * * @default true */ autoMonitor: z.boolean().optional().meta({ default: true, description: 'Set the default behavior for wether this component should automatically monitor any activity, or scrobble, it encounters' })})export type MonitorOptions = z.infer<typeof monitorOptionsSchema>;
export const deadLetterOptionsSchema = z.object({ /** * Number of times MS should automatically retry Plays in dead letter queue * * @default 3 * @examples [3] * */ deadLetterRetries: z.number().optional().meta({ description: "Number of times MS should automatically retry Plays in dead letter queue", default: 3, examples: [3] })})export type DeadLetterOptions = z.infer<typeof deadLetterOptionsSchema>;
export type UnparsedConfig<T extends (SourceType | ClientType)> = {config: object, type: T, source?: 'file' | 'aio' | 'env', pos: string};
export const generateConfigLocation = (configType: string, config: UnparsedConfig<any>): string => { const identifiers: string[] = []; if(config.config !== undefined) { if('id' in config.config) { identifiers.push(`ID ${config.config.id}`); } if('name' in config.config) { identifiers.push(`Name ${config.config.name}`); } }
if(config.source === 'file') { return `${capitalize(configType)} #${config.pos}${identifiers.length > 0 ? ` (${identifiers.join(',')})` : ''} in ${config.type}.json`; } if(config.source === 'aio') { return `${capitalize(configType)} ${config.type} #${config.pos}${identifiers.length > 0 ? ` (${identifiers.join(',')})` : ''} in config.json`; } return `${capitalize(configType)} ${config.type}${identifiers.length > 0 ? ` (${identifiers.join(',')})` : ''} from ENV`;}
export const transformPresetEnv = <T extends CommonClientOptions | CommonSourceOptions>(prefix: string, existing: T | undefined = undefined, logger: Logger = loggerNoop): undefined | T => {
const env = process.env[`${prefix}_TRANSFORMS`]; if (env === undefined || env.trim() === '') { return existing; }
const preCompare: NonNullable<PlayTransformHooks<ExternalMetadataTerm>['preCompare']> = []; const popts: PlayTransformHooks<ExternalMetadataTerm> = { preCompare }; const transformTypes = env.split(',').map(x => x.trim().toLocaleLowerCase()); for (const p of transformTypes) { switch (p) { case 'native': preCompare.push({ type: 'native'}); break; case 'musicbrainz': preCompare.push({ type: 'musicbrainz' }); break; case 'rocksky': preCompare.push({ type: 'rocksky' }); break; case 'spotify': preCompare.push({ type: 'spotify'}); break; case 'coverartarchive': preCompare.push({type: 'coverartarchive'}); break; default: logger.warn(`Unrecognized transformer type '${p} in env ${env}'`); break; } }
// @ts-expect-error T is fine return { ...(existing || {}), playTransform: popts };};
export type CommonComponentEnvShape<T extends string> = { [K in `${T}_ID`]: z.ZodString} & { [K in `${T}_NAME`]: z.ZodOptional<z.ZodString>} & { [K in `${T}_ENABLE`]: z.ZodOptional<ReturnType<typeof z.stringbool>>};
export const generateCommonComponentEnvConfigSchema = <T extends string>(prefix: T) => z.object({ [`${prefix}_ID`]: z.string().meta({description: 'A globally unique ID EX `myComponentId`', examples: ['myComponentId']}), [`${prefix}_NAME`]: z.string().optional().meta({description: 'A vanity name EX `My Cool Component`', default: `Value of \`${prefix}_ID\``, examples: ['My Cool Component']}), [`${prefix}_ENABLE`]: z.stringbool().optional().meta({description: 'Should this component be used?', default: true}), } as CommonComponentEnvShape<T>);
export type CommonComponentEnvConfigParsed<T extends string> = { [K in `${T}_ID`]: string} & { [K in `${T}_NAME`]?: string} & { [K in `${T}_ENABLE`]?: boolean};
export const commonComponentEnvConfigToConfigPrimitives = <T extends string>(prefix: T, envConfig: CommonComponentEnvConfigParsed<T>): CommonConfigPrimitives => { const raw = envConfig as Record<string, string | boolean | undefined>; return { id: raw[`${prefix}_ID`] as string, name: raw[`${prefix}_NAME`] as string | undefined, enable: raw[`${prefix}_ENABLE`] as boolean | undefined };};
/** * Only devices explicitly enumerated here are reported as `media_player` in each listen's additional_info. * * Keys match case-insensitively as substrings of the source's device id (longest match wins). What is submitted * is the key's label value, or the key itself when the label is empty * * @examples [{"iphone": "", "9f3ec2-iphone": "kitchen ipad"}] * */export const allowDeviceListSchema = z.record(z.string(), z.string()).optional().meta({ description: "A dictionary of allowed identifiers to use as `media_player` in scrobble submit data, when none is present.", examples: [{"iphone": "", "9f3ec2-iphone": "kitchen ipad"}]});
export const allowDeviceListEnvSchema = z.string().optional().transform((val) => { if (val === undefined || val.trim() === '') { return undefined; } // comma-separated entries; 'match:label' gives a custom label, bare 'match' submits the match itself const rec: Record<string, string> = {}; for (const part of val.split(',').map(x => x.trim()).filter(x => x !== '')) { const sep = part.indexOf(':'); if (sep === -1) { rec[part] = ''; } else { rec[part.slice(0, sep).trim()] = part.slice(sep + 1).trim(); } } return rec; }).meta({ description: 'A comma-delimited list of match:label entries to use as `media_play` in scrobble submit data, when none is present.', examples: ['iphone,1234-smith-roku:familyRoku']});export type AllowDeviceListEnv = z.infer<typeof allowDeviceListEnvSchema>;