import type { Logger, LogDataPretty } from '@foxxmd/logging'; import type { Dayjs, ManipulateType } from "dayjs"; import type { Request, Response } from "express"; import type { NextFunction, ParamsDictionary, Query } from "express-serve-static-core"; import type { FixedSizeList } from 'fixed-size-list'; import { isPlayObject } from "../../../core/Atomic.ts"; import type {DeviceId, ErrorLike, PlayMeta, PlayObject, PlayObjectMinimal, PlayPlatformId, PlayUserId, UnixTimestamp} from "../../../core/Atomic.ts"; import type TupleMap from "../TupleMap.ts"; import type { ReportedPlayerStatus, SourceType } from "../../../core/Atomic.ts"; import type { ClientType } from "../../../core/Atomic.ts"; import assert from 'assert'; import * as path from 'path'; import * as z from 'zod'; export const __filename = import.meta.filename; export const projectRootDir = path.resolve(__filename, '../../../../../'); export interface LeveledLogData extends LogDataPretty { levelLabel: string } export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic', 'applemusic']; export interface ComponentIdentifier { type: SourceType | ClientType name: string } export interface SourceIdentifier extends ComponentIdentifier { type: SourceType } export interface ClientIdentifier extends ComponentIdentifier { type: ClientType } export type InitState = 0 | 1 | 2; export const NOT_INITIALIZED: InitState = 0; export const INITIALIZING: InitState = 1; export const INITIALIZED: InitState = 2; export const initStates: InitState[] = [NOT_INITIALIZED, INITIALIZING, INITIALIZED]; export type ReadyState = 0 | 1 | 2; export const NOT_READY: ReadyState = 0; export const GETTING_READY: ReadyState = 1; export const READY: ReadyState = 2; export const readyStates: ReadyState[] = [NOT_READY, GETTING_READY, READY]; export interface InternalConfig { localUrl: URL configDir: string version: string logger: Logger } export type InternalConfigOptional = Omit export type ConfigureAsSource = 'source'; export type ConfigureAsClient = 'client'; export type ConfigureAs = ConfigureAsSource | ConfigureAsClient; export const CONFIGURE_AS = { source: 'source', client: 'client' } as const satisfies Record export interface ConfigMeta { source: string mode?: string configureAs: ConfigureAs } export type SourceData = (PlayObject | PlayerStateData); export interface PlayerStateData extends PlayerStateDataMaybePlay { play: PlayObject } export interface PlayerStateDataMaybePlay { platformId: PlayPlatformId /** The ID/Key for individual sessions on a device/platform */ sessionId?: string play?: PlayObject status?: ReportedPlayerStatus position?: number stateUpdatedAt?: Dayjs playUpdatedAt?: Dayjs } export const asPlayerStateData = (obj: object): obj is PlayerStateData => asPlayerStateDataMaybePlay(obj) && 'play' in obj && isPlayObject(obj.play) export const asPlayerStateDataMaybePlay = (obj: object): obj is PlayerStateDataMaybePlay => 'platformId' in obj export interface FormatPlayObjectOptions { newFromSource?: boolean parsedFrom?: string [key: string]: any } export interface ProgressAwarePlayObject extends PlayObjectMinimal { meta: PlayMeta & { initialTrackProgressPosition?: number } } export type GroupedPlays = TupleMap; export type GroupedFixedPlays = TupleMap>; export interface ScrobbledPlayObject { play: PlayObject scrobble: PlayObjectMinimal } export interface RemoteIdentityParts { host: string, proxy: string | undefined, agent: string | undefined } /** * https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?) * https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline * */ export const DEFAULT_SCROBBLE_DURATION_THRESHOLD: number = 240; /** * https://www.last.fm/api/scrobbling (When is a scrobble a scrobble?) * https://github.com/krateng/maloja/blob/master/API.md#scrobbling-guideline * */ export const DEFAULT_SCROBBLE_PERCENT_THRESHOLD: number = 50; export const DEFAULT_POLLING_INTERVAL: number = 10; export const DEFAULT_POLLING_MAX_INTERVAL = 30; export const DEFAULT_RETRY_MULTIPLIER: number = 1.5; export const DEFAULT_CLOSE_POSITION_ABSOLUTE = 12; export const DEFAULT_CLOSE_POSITION_PERCENT = 0.15; export const DEFAULT_DURATION_REPEAT_ABSOLUTE = 120; export const DEFAULT_DURATION_REPEAT_PERCENT = 0.50; export interface ScrobbleThresholdResult { passes: boolean duration: { passes?: boolean value?: number threshold?: number } percent: { passes?: boolean value?: number threshold?: number } } export interface RegExResult { match: string, groups: string[], index: number named: NamedGroup } export interface NamedGroup { [name: string]: any } export type ExpressRequest = Request>; export type ExpressResponse = Response>; export type ExpressHandler = (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => Promise>> export const ARTIST_WEIGHT = 0.3; export const TITLE_WEIGHT = 0.4; export const TIME_WEIGHT = 0.5; export const REFERENCE_WEIGHT = 0.5; export const DUP_SCORE_THRESHOLD = 1; // https://stackoverflow.com/a/70887388/1469797 export type ArbitraryObject = { [key: string]: unknown; }; export function isArbitraryObject(potentialObject: unknown): potentialObject is ArbitraryObject { return typeof potentialObject === "object" && potentialObject !== null; } export interface Authenticatable { requiresAuth: boolean requiresAuthInteraction: boolean authed: boolean authFailure?: boolean testAuth: () => Promise } export interface ScrobbleRangeFetchable { getScrobblesForTimeRange: TimeRangeListensFetcher } export interface MdnsDeviceInfo { name: string type: string addresses: string[] } export type AbstractApiOptions = Record & { logger: Logger } export type keyOmit = T & { [P in U]?: never } export type WithRequiredProperty = Type & { [Property in Key]-?: Type[Property]; }; export type CacheProvider = 'memory' | 'valkey' | 'file' | false; export interface CacheConfig { provider: T; connection?: string; [key: string]: any } export interface CacheMemoryConfig extends CacheConfig<'memory'> { lruSize?: number ttl?: number } export interface CacheValkeyConfig extends CacheConfig<'valkey'> { } export interface CacheFileConfig extends CacheConfig<'file'> { } export interface CacheNoopConfig extends CacheConfig { } export type CacheConfigType = CacheMemoryConfig | CacheValkeyConfig | CacheFileConfig | CacheNoopConfig; export type CacheMetadataProvider = CacheProvider;//Exclude; export type CacheMetadataConfig = CacheConfigType; export const asCacheProvider = (val: boolean | string): val is CacheProvider => { if(typeof val === 'string') { return ['memory', 'valkey', 'file'].includes(val); } return val === false; } export const asCacheEphemeralConfig = (val: Record): val is (CacheMemoryConfig | CacheNoopConfig) => { return val.provider === false || val.provider === 'memory'; } export const asCacheConnectableConfig = (val: Record): val is (CacheValkeyConfig | CacheFileConfig) => { if(!['valkey', 'file'].includes(val.provider)) { return false; } return typeof val.connection === 'string'; } export const asCacheConfig = (val: Record): val is CacheConfigType => { if(!asCacheProvider(val.provider)) { assert(asCacheProvider(val.provider), `Cache provider must be one of: 'memory', 'valkey', 'file', or false`); } if(asCacheConnectableConfig(val) || asCacheEphemeralConfig(val)) { return true; } throw new Error(`${val.provider} must have a connection property that is a string`); } export const asCacheMetadataProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val); export type CacheScrobbleProvider = CacheProvider; export type CacheScrobbleConfig = CacheConfigType; export const asCacheScrobbleProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val); export type CacheAuthProvider = CacheProvider; export type CacheAuthConfig = CacheConfigType; export const asCacheAuthProvider = (val: any): val is CacheAuthProvider => asCacheProvider(val); export interface CacheConfigOptions { metadata?: CacheMetadataConfig; scrobble?: CacheScrobbleConfig; auth?: CacheAuthConfig; } export const cacheConfigUserAuthSchema = z.object({ provider: z.enum(['valkey', 'file']), }).meta({description: 'The cache type to use for Auth'}); export const cacheConfigUserSchema = z.object({ auth: cacheConfigUserAuthSchema.optional(), valkey: z.string().optional().meta({description: 'The connection string to a valkey server with the syntax `redis://HOST_IP:HOST_PORT`'}) }).meta({title: 'Cache Config', description: 'Configuration for Caching'}); export type CacheConfigUser = z.infer; const musicbrainzApiConfigDataSchema = z.object({ /** * Use this api configuration? * * @default true */ enable: z.boolean().optional().meta({description: 'Use this api configuration? Default is `true`.'}), /** * Base URL of a custom Musicbrainz server. Leave unset to use the official Musicbrainz instance. * */ url: z.string().optional().meta({ description: 'Base URL of a custom Musicbrainz server. Leave unset to use the official Musicbrainz instance.' }), /** * milliseconds to wait until throwing a timeout error when waiting for a response. * * @default 6000 */ requestTimeout: z.int().optional().meta({ description: 'milliseconds to wait until throwing a timeout error when waiting for a response. Default is `6000`ms.' }), contact: z.string().optional().meta({ description: '(If running a forked version of multi-scrobbler) A website or email Musicbrainz can contact you at in case of issues' }), rate: z.object({ requests: z.number().positive().optional().meta({ description: 'max number of requests allowed during perTime unit of time', default: 1 }), perTime: z.number().positive().optional().meta({ description: 'A span of time (in seconds) during which requests made me made, resets after perTime', default: 1 }) }).optional() // future use //apiKey: z.string() }); export type MusicbrainzApiConfigData = z.infer; export const MUSICBRAINZ_URL = 'https://musicbrainz.org'; export const MBID_VARIOUS_ARTISTS = "89ad4ac3-39f7-470e-963a-56509c546377"; /* https://websocket.org/reference/close-codes/ */ export const WEBSOCKET_CLOSE_CODE_REASONS = { 1000: 'Closed gracefully', 1001: 'Going Away', 1002: 'Protocol Error', 1003: 'Unsupported Data', 1005: 'No Status Received', 1006: 'Abnormal Closure (Connection lost unexpectedly)', 1008: 'Policy Violation', 1009: 'Message Too Big', 1011: 'Server-side exception', 1012: 'Service Restart', 1013: 'Try Again Later', 1014: 'Bad Gateway', 1015: 'TLD handshake fail' } export const WEBSOCKET_CLOSE_CODE = { AbnormalClosure: 1006, InternalError: 1011 } export const WEBSOCKET_CLOSE_CODES_RETRY = [1006,1011,1001];export interface PaginatedLimit { /** per page max number of results to return */ limit?: number } export interface PaginatedTimeRangeOptions { /** Unix timestamp */ from: UnixTimestamp /** Unix timestamp */ to: UnixTimestamp /** maximum number of results to fetch before returning early */ fetchMax?: number } export type PaginatedTimeRangeCommonOptions = Partial & PaginatedLimit; export type CursorType = number | string; export interface PaginatedListensOptions extends PaginatedLimit { cursor: T } export interface PagelessListensTimeRangeOptions extends PaginatedTimeRangeCommonOptions { } export interface PaginatedListensTimeRangeOptions extends Partial, PaginatedListensOptions { } export interface PaginatedResults { total?: number more?: boolean cursorNext?: T order?: 'desc' | 'asc' } export interface PaginatedListens { getPaginatedListens(params: PaginatedListensOptions): Promise<{data: PlayObject[], meta: PaginatedListensOptions & PaginatedResults}> } export const hasPaginagedListens = (obj: object): obj is PaginatedListens => { return 'getPaginatedListens' in obj; } export interface PaginatedTimeRangeListensResult { data: PlayObject[]; meta: PaginatedListensTimeRangeOptions & PaginatedResults; } export interface PaginatedTimeRangeListens { getPaginatedTimeRangeListens(params: PaginatedListensTimeRangeOptions): Promise> getPaginatedUnitOfTime(): ManipulateType; } export const hasPaginatedTimeRangeListens = (obj: object): obj is PaginatedTimeRangeListens => { return 'getPaginatedTimeRangeListens' in obj; } export interface PagelessTimeRangeListensResult { data: PlayObject[] meta: PagelessListensTimeRangeOptions & PaginatedResults } export interface PagelessTimeRangeListens { getPagelessTimeRangeListens(params: PagelessListensTimeRangeOptions): Promise getPaginatedUnitOfTime(): ManipulateType; } export const hasPagelessTimeRangeListens = (obj: object): obj is PagelessTimeRangeListens => { return 'getPagelessTimeRangeListens' in obj; } export type PaginatedTimeRangeSource = PaginatedTimeRangeListens | PagelessTimeRangeListens; export type PaginatedSource = PaginatedListens | PaginatedTimeRangeSource; export type TimeRangeListensFetcher = (opts: PaginatedTimeRangeCommonOptions | PaginatedListensTimeRangeOptions) => Promise export interface ScrobbleRangeResult { plays: PlayObject[] fetchedAt: Dayjs } export const REFRESH_STALE_DEFAULT = 60; /** A number of seconds */ export const secondsSchema = z.number().meta({ description: 'number of seconds', examples: [60, 3600], title: 'Seconds' }); /** A string representing a number and unit of time comptabile with dayjs */ export const durationStringSchema = z.string().meta({ description: 'a string containing a number and a unit of time compatible with dayjs', examples:["1 hour", "4 days"], title: 'Duration Value' }) export const durationValueSchema = z.xor([secondsSchema, durationStringSchema]).meta({title: 'Duration Value'}); // .meta({ // description: stripIndents`A duration of time as either // * a number of seconds // * or a string containing a number and a unit of time compatible with dayjs`, // example: [60, 3600, "1 hour", "4 days"]}); /** * A duration of time * * May be either: * * * a `number` of seconds * * a `string` containing a number and a unit of time compatible with dayjs * * @example [60, 3600, "1 hour", "4 days"] */ export type DurationValue = z.infer; // number | string; export type MigrationStatus = { backupRequired: boolean, pending: string[], reason?: string, log?: string, error?: ErrorLike }; export const DEVELOPER_CONTACT = process.env.DEVELOPER_CONTACT ?? 'contact@multi-scrobbler.app';