import { 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, 'id'>; export const commonDataSchema = z.record(z.string(), z.any()); // keyOmit<{ [key: string]: any }, "options"> export type CommonData = z.infer; 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; 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; 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; 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; 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; export type UnparsedConfig = {config: object, type: T, source?: 'file' | 'aio' | 'env', pos: string}; export const generateConfigLocation = (configType: string, config: UnparsedConfig): 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 = (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['preCompare']> = []; const popts: PlayTransformHooks = { 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 = { [K in `${T}_ID`]: z.ZodString } & { [K in `${T}_NAME`]: z.ZodOptional } & { [K in `${T}_ENABLE`]: z.ZodOptional> }; export const generateCommonComponentEnvConfigSchema = (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); export type CommonComponentEnvConfigParsed = { [K in `${T}_ID`]: string } & { [K in `${T}_NAME`]?: string } & { [K in `${T}_ENABLE`]?: boolean }; export const commonComponentEnvConfigToConfigPrimitives = (prefix: T, envConfig: CommonComponentEnvConfigParsed): CommonConfigPrimitives => { const raw = envConfig as Record; 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 = {}; 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;