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.
16 kB · 470 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471import 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<InternalConfig, 'logger'>
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<string, ConfigureAs>
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<DeviceId,PlayUserId,ProgressAwarePlayObject[]>;
export type GroupedFixedPlays = TupleMap<DeviceId,PlayUserId,FixedSizeList<ProgressAwarePlayObject>>;
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<ParamsDictionary, any, any, Query, Record<string, any>>;export type ExpressResponse = Response<any, Record<string, any>>;export type ExpressHandler = (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>
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/1469797export 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<any>}
export interface ScrobbleRangeFetchable { getScrobblesForTimeRange: TimeRangeListensFetcher}
export interface MdnsDeviceInfo { name: string type: string addresses: string[]}
export type AbstractApiOptions = Record<any, any> & { logger: Logger }
export type keyOmit<T, U extends keyof any> = T & { [P in U]?: never }
export type WithRequiredProperty<Type, Key extends keyof Type> = Type & { [Property in Key]-?: Type[Property];};export type CacheProvider = 'memory' | 'valkey' | 'file' | false;export interface CacheConfig<T extends CacheProvider = CacheProvider> { 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<false> {}
export type CacheConfigType = CacheMemoryConfig | CacheValkeyConfig | CacheFileConfig | CacheNoopConfig;
export type CacheMetadataProvider = CacheProvider;//Exclude<CacheProvider, 'file'>;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<string, any>): val is (CacheMemoryConfig | CacheNoopConfig) => { return val.provider === false || val.provider === 'memory';}export const asCacheConnectableConfig = (val: Record<string, any>): val is (CacheValkeyConfig | CacheFileConfig) => { if(!['valkey', 'file'].includes(val.provider)) { return false; } return typeof val.connection === 'string';}export const asCacheConfig = (val: Record<string, any>): 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<typeof cacheConfigUserSchema>;
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<typeof musicbrainzApiConfigDataSchema>;
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<PaginatedTimeRangeOptions> & PaginatedLimit;
export type CursorType = number | string;
export interface PaginatedListensOptions<T extends CursorType> extends PaginatedLimit { cursor: T}
export interface PagelessListensTimeRangeOptions extends PaginatedTimeRangeCommonOptions {}
export interface PaginatedListensTimeRangeOptions<T extends CursorType = CursorType> extends Partial<PaginatedTimeRangeOptions>, PaginatedListensOptions<T> {}
export interface PaginatedResults<T extends CursorType = CursorType> { total?: number more?: boolean cursorNext?: T order?: 'desc' | 'asc'}
export interface PaginatedListens { getPaginatedListens(params: PaginatedListensOptions<CursorType>): Promise<{data: PlayObject[], meta: PaginatedListensOptions<CursorType> & PaginatedResults<CursorType>}>}
export const hasPaginagedListens = (obj: object): obj is PaginatedListens => { return 'getPaginatedListens' in obj;}
export interface PaginatedTimeRangeListensResult<T extends CursorType> { data: PlayObject[]; meta: PaginatedListensTimeRangeOptions<T> & PaginatedResults<T>;}export interface PaginatedTimeRangeListens<T extends CursorType = CursorType> { getPaginatedTimeRangeListens(params: PaginatedListensTimeRangeOptions<T>): Promise<PaginatedTimeRangeListensResult<T>> 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<PagelessTimeRangeListensResult> 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<T extends CursorType = CursorType> = (opts: PaginatedTimeRangeCommonOptions | PaginatedListensTimeRangeOptions<T>) => Promise<PlayObject[]>
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<typeof durationValueSchema>; // 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';