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.
14 kB · 418 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418import type { CompareOpKey, ComponentMinimalSelect } from "../backend/common/database/drizzle/drizzleTypes.ts"import { creditSchema, playTrackDataSchema, type ClientType, type ComponentAuthType, type MonitoringStatus, type QueueContext, type MusicServices, type Replace, creditBaseSchema, trackSearchHash, albumSearchHash, artistSearchHash } from "./Atomic.ts"import type { LogEvent, SourceType } from "./Atomic.ts"import type { ComponentType, DateLike, ErrorLike, JsonPlayObject, PlayState, QueueName, SOURCE_SOT_TYPES, SourcePlayerJson } from "./Atomic.ts"import type { Dayjs } from "dayjs"import type { ErrorIsh } from "./ErrorUtils.ts"import type { PlayEvent } from "./PlayEvent.ts"import * as z from "zod"import type { ElementOf, MarkOptional } from "ts-essentials";import type { ErrorObject } from "serialize-error";import { serializeError } from "serialize-error";
export interface PlayApiCommon { uid: string componentId: number state: PlayState play: JsonPlayObject compacted: boolean playedAt: string seenAt: string updatedAt: string parentUid?: string // TODO add parent source type/name?}
export interface PlayInputApi { id: number data?: object play?: JsonPlayObject createdAt: string}
export interface QueueStateApi { id: number queueName: string queueStatus: string retries: number error?: ErrorLike updatedAt: string createdAt: string context?: QueueContext}
export interface PlayApiCommonDetailed extends PlayApiCommon { error?: ErrorIsh input?: PlayInputApi queueStates: QueueStateApi[] events: PlayEvent<string>[]}
export type SerializablePlaySelect = Replace<MarkOptional<PlayApiCommonDetailed, 'queueStates'>, 'error', ErrorObject> & {queueStates?: Replace<ElementOf<PlayApiCommonDetailed['queueStates']>, 'error', ErrorObject | undefined>[]};export const asSerializablePlaySelect = (data: MarkOptional<PlayApiCommonDetailed, 'queueStates'>): SerializablePlaySelect => { const { error, queueStates = [], ...rest } = data;
const qMapped = queueStates.map((x) => { const { error: e, ...restQ } = x; return { ...restQ, error: e instanceof Error ? serializeError(e) : e } });
return { ...rest, // @ts-expect-error error: error instanceof Error ? serializeError(error) : error, queueStates: qMapped }}
export type ComponentState = 1 | 2 | 3 | 4 | 5 | 6 | 7;export const COMPONENT_STATE = { RUNNING: 1, IGNORED: 2, IDLE: 3, STOPPED: 4, INITIALIZING: 5, NOT_READY: 6, ERROR: 7,} as const satisfies Record<string, ComponentState>;
export const componentStateToFriendly = (state: ComponentState) => { switch(state) { case 1: return 'Running'; case 2: return 'Ignored'; case 3: return 'Idle'; case 4: return 'Stopped'; case 5: return 'Initializing'; case 6: return 'Not Ready'; case 7: return 'Error'; }}
export type ComponentCommonApi = { type: SourceType | ClientType name: string /** General state of the component like Idle, Stopped, Running, Error */ state: ComponentState /** More specific, live activity state like "sleeping", "hydrating historical scrobbles", "processing dead scrobbles", etc... */ status?: string players: Record<string, SourcePlayerJson> errors?: ErrorIsh[] warnings?: ErrorIsh[] monitoringStatus?: MonitoringStatus deadLetterPlays: number deadLetterPlaysTotal: number queued: number} & Omit<ComponentMinimalSelect, 'type'>
/** Maps Dayjs (including nullable/optional Dayjs) properties to string, preserving optionality */// nullable Dayjs fields are emitted as undefined (key omitted) when missing, so allow undefined as well as nulltype DayjsJsonVal<V> = Dayjs extends V ? Exclude<V, Dayjs> | string | (null extends V ? undefined : never) : V;type DayjsToJson<T> = { [K in keyof T]: DayjsJsonVal<T[K]> };
export type ComponentCommonApiJson = DayjsToJson<ComponentCommonApi>;
export type ComponentHistoricalApi = { synced: boolean syncedReason: string | null | undefined syncError: ErrorIsh | null | undefined lastImport: Dayjs | undefined lastImportSuccess: Dayjs | undefined}
export type ComponentDetailedApi = ComponentCommonApi & { hasAuth: boolean; hasAuthInteraction: boolean; authed: boolean authType: ComponentAuthType initialized: boolean}
export type ComponentCientApiBase = { tracksScrobbled: number supportsNowPlaying: boolean players: Record<string, SourcePlayerJson & {expiration?: string}>}
export type ComponentClientApi = ComponentDetailedApi & ComponentCientApiBase & Partial<ComponentHistoricalApi>;export type ComponentClientApiJson = DayjsToJson<ComponentClientApi>;
export type ComponentSourceApiBase = { sot: SOURCE_SOT_TYPES supportsUpstreamRecentlyPlayed: boolean; tracksDiscovered: number; wakeAt?: string sleeping: boolean}
export type ComponentSourceApi = ComponentDetailedApi & ComponentSourceApiBase & Partial<ComponentHistoricalApi>;export type ComponentSourceApiJson = DayjsToJson<ComponentSourceApi>;
export type SubsonicSourceApiJson = ComponentSourceApiJson & { playbackReporting?: boolean }
export type ComponentsApiJson = ComponentSourceApiJson | ComponentClientApiJson;
export const isComponentSourceApiJson = (data: ComponentCommonApiJson): data is ComponentSourceApiJson => { return data.mode === 'source';}export const isComponentClientApiJson = (data: ComponentCommonApiJson): data is ComponentClientApiJson => { return data.mode === 'client';}
export type MsSseEventPayload<T extends object = Record<string, any>> = { type: SourceType | ClientType name: string componentId: number from: ComponentType data: T event: string}
export type MsSseEvent = { playerUpdate: MsSseEventPayload<SourcePlayerJson> discovered: MsSseEventPayload scrobbleQueued: MsSseEventPayload scrobbleDequeued: MsSseEventPayload componentUpdate: MsSseEventPayload<ComponentCommonApiJson> playInsert: MsSseEventPayload<PlayApiCommonDetailed> playUpdate: MsSseEventPayload<Partial<PlayApiCommonDetailed>> log: LogEvent}
export type SortPlaysBy = 'played' | 'seen';export interface SortPlaysByProps { sortBy: SortPlaysBy}
export type PlayStateUI = PlayState | 'failed TBR';export type QueryPlaysOptsJsonRefreshable = Omit<QueryPlaysOptsJson, 'state'> & {nonce?: string, state?: PlayStateUI[]};
export type QueryPlaysOptsJson = { sort?: "playedAt" | "seenAt"; order?: "asc" | "desc"; with?: ("input" | "parent" | "parent-input" | "queues")[]; limit?: number; offset?: number; state?: ("queued" | "discovered" | "discarded" | "scrobbled" | "failed" | "duped")[]; stateNot?: ("queued" | "discovered" | "discarded" | "scrobbled" | "failed" | "duped")[]; componentId?: number; seenAt?: { type: "eq" | "ne" | "gt" | "gte" | "lt" | "lte"; date: string; } | { type: "between"; range: [string, string]; inclusive?: boolean; }; playedAt?: { type: "eq" | "ne" | "gt" | "gte" | "lt" | "lte"; date: string; } | { type: "between"; range: [string, string]; inclusive?: boolean; };queues?: { queueName: QueueName; queueStatus: ('queued' | 'failed' | 'completed')[] | ('queued' | 'failed' | 'completed'); }[]; uid?: string[]; text?: string[];}export interface PaginatedQueryResponse { limit: number; offset: number; total?: number;}export interface PaginatedResponse<T> { data: T[]; meta: PaginatedQueryResponse;}
export type CompareDateBetween<D extends DateLike = Dayjs> = { type: 'between'; range: [D, D]; inclusive?: boolean;};
export type CompareDateSingle<D extends DateLike = Dayjs> = { type: CompareOpKey<D>; date: D;};
export type CacheClearType = 'external-api' | 'transforms';
export const componentStateBodySchema = z.object({ state: z.enum(["stop","start","restart","ignore","monitor"]), reason: z.string().optional()});
export type ComponentStateBody = z.infer<typeof componentStateBodySchema>;
export const playStateBodySchema = z.object({ state: z.enum(["discarded"]), reason: z.string().optional()});
export type PlayStateBody = z.infer<typeof playStateBodySchema>;
export const metadataResultBaseSchema = z.object({ id: z.string()});export type MetadataResultBase = z.infer<typeof metadataResultBaseSchema>;
export const metadataResultServiceScoreSchema = z.object({ service: z.string(), score: z.int().nonnegative().optional(),})export type MetadataResultServiceScore = z.infer<typeof metadataResultServiceScoreSchema>;
export const metadataResultImageSchema = z.object({ image: z.string().optional()})export type MetadataResultImage = z.infer<typeof metadataResultImageSchema>;
export const metadataResultAlbumTypeSchema = z.object({ /** The kind of release the album is (album, single, ep...) */ albumType: z.string().optional(), albumTypeHint: z.string().optional()})
/** An error from a single metadata provider, serialized with serializeError */export const metadataSearchErrorSchema = z.object({ service: z.string(), error: z.looseObject({ name: z.string().optional(), message: z.string().optional(), stack: z.string().optional(), })});export type MetadataSearchError = {service: string, error: ErrorObject};
/** Media type a client sends in the Accept header to get metadata search results as a stream of lines instead of one JSON response */export const NDJSON_MEDIA_TYPE = 'application/x-ndjson';
/** One line of a streamed metadata search response. A stream that ends without a 'done' line was cut short. */export type MetadataSearchStreamLine<T> = | {type: 'result', service: string, data: T[]} | ({type: 'error'} & MetadataSearchError) | {type: 'done'};
/** A Credit for an artist with the service it was found on */export const artistSearchResultSchema = z.object({ ...creditSchema.shape, ...metadataResultBaseSchema.shape, ...metadataResultServiceScoreSchema.shape,});export type ArtistSearchResult = z.infer<typeof artistSearchResultSchema>;
export const artistSearchResultResponseSchema = z.object({ data: artistSearchResultSchema.array(), errors: metadataSearchErrorSchema.array()});export type ArtistSearchResultResponse = z.infer<typeof artistSearchResultResponseSchema>;
/** A Credit for an album with the service it was found on */export const albumSearchResultSchema = z.object({ ...creditSchema.shape, ...metadataResultBaseSchema.shape, ...metadataResultServiceScoreSchema.shape, ...metadataResultAlbumTypeSchema.shape, date: z.string().optional(), country: z.string().optional(), artists: artistSearchResultSchema.array().optional(),});export type AlbumSearchResult = z.infer<typeof albumSearchResultSchema>;
export const albumSearchResultResponseSchema = z.object({ data: albumSearchResultSchema.array(), errors: metadataSearchErrorSchema.array()});export type AlbumSearchResultResponse = z.infer<typeof albumSearchResultResponseSchema>;
/** TrackData with the service it was found on. Artists and album are results from the same service. */export const trackSearchResultSchema = z.object({ ...playTrackDataSchema.shape, ...metadataResultBaseSchema.shape, ...metadataResultServiceScoreSchema.shape, artists: artistSearchResultSchema.array().optional(), albumArtists: artistSearchResultSchema.array().optional(), album: albumSearchResultSchema.optional(), /** Number of albums the track was found on */ albumCount: z.int().nonnegative().optional(),});export type TrackSearchResult = z.infer<typeof trackSearchResultSchema>;
export const trackSearchResultResponseSchema = z.object({ data: trackSearchResultSchema.array(), errors: metadataSearchErrorSchema.array()});export type TrackSearchResultResponse = z.infer<typeof trackSearchResultResponseSchema>;
export const trackDataCreditBaseSchema = z.object({ track: creditBaseSchema.optional().meta({ description: `a track credit to search by` }), artists: creditBaseSchema.array().optional().meta({ description: 'a list of artist Credits' }), album: creditBaseSchema.optional().meta({ description: 'an album credit' })})export type TrackDataCreditBase = z.infer<typeof trackDataCreditBaseSchema>;
export const trackSearchSimpleRequestQuerySchema = z.object( { track: z.union([trackSearchHash, z.string()]).meta({ description: `the track name or prefix:id to search for` }), artists: z.string().array().optional().meta({ description: 'a list of plain artist strings associated with the track' }), album: z.string().optional().meta({ description: 'a plain album name string associated with the track' }) });export type TrackSearchSimpleRequestQuery = z.infer<typeof trackSearchSimpleRequestQuerySchema>;
export const albumSearchSimpleRequestQuerySchema = z.object( { album: z.union([albumSearchHash, z.string()]).meta({ description: 'the album name or prefix:id to search for' }), artists: z.string().array().optional().meta({ description: 'a list of plain artist strings associated with the album' }),
});
export type AlbumSearchSimpleRequestQuery = z.infer<typeof albumSearchSimpleRequestQuerySchema>;
export const artistSearchSimpleRequestQuerySchema = z.object({ artist: z.union([artistSearchHash, z.string()]).meta({ description: 'a plain artist name or prefix:id to search for' }),});export type ArtistSearchSimpleRequestQuery = z.infer<typeof artistSearchSimpleRequestQuerySchema>;
export const EXTERNAL_ART_URL_REGEX = new RegExp(/api\/components\/\d+\/art/);