import 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[] } export type SerializablePlaySelect = Replace, 'error', ErrorObject> & {queueStates?: Replace, 'error', ErrorObject | undefined>[]}; export const asSerializablePlaySelect = (data: MarkOptional): 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; 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 errors?: ErrorIsh[] warnings?: ErrorIsh[] monitoringStatus?: MonitoringStatus deadLetterPlays: number deadLetterPlaysTotal: number queued: number } & Omit /** 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 null type DayjsJsonVal = Dayjs extends V ? Exclude | string | (null extends V ? undefined : never) : V; type DayjsToJson = { [K in keyof T]: DayjsJsonVal }; export type ComponentCommonApiJson = DayjsToJson; 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 } export type ComponentClientApi = ComponentDetailedApi & ComponentCientApiBase & Partial; export type ComponentClientApiJson = DayjsToJson; export type ComponentSourceApiBase = { sot: SOURCE_SOT_TYPES supportsUpstreamRecentlyPlayed: boolean; tracksDiscovered: number; wakeAt?: string sleeping: boolean } export type ComponentSourceApi = ComponentDetailedApi & ComponentSourceApiBase & Partial; export type ComponentSourceApiJson = DayjsToJson; 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> = { type: SourceType | ClientType name: string componentId: number from: ComponentType data: T event: string } export type MsSseEvent = { playerUpdate: MsSseEventPayload discovered: MsSseEventPayload scrobbleQueued: MsSseEventPayload scrobbleDequeued: MsSseEventPayload componentUpdate: MsSseEventPayload playInsert: MsSseEventPayload playUpdate: MsSseEventPayload> log: LogEvent } export type SortPlaysBy = 'played' | 'seen'; export interface SortPlaysByProps { sortBy: SortPlaysBy } export type PlayStateUI = PlayState | 'failed TBR'; export type QueryPlaysOptsJsonRefreshable = Omit & {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 { data: T[]; meta: PaginatedQueryResponse; } export type CompareDateBetween = { type: 'between'; range: [D, D]; inclusive?: boolean; }; export type CompareDateSingle = { type: CompareOpKey; 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; export const playStateBodySchema = z.object({ state: z.enum(["discarded"]), reason: z.string().optional() }); export type PlayStateBody = z.infer; export const metadataResultBaseSchema = z.object({ id: z.string() }); export type MetadataResultBase = z.infer; export const metadataResultServiceScoreSchema = z.object({ service: z.string(), score: z.int().nonnegative().optional(), }) export type MetadataResultServiceScore = z.infer; export const metadataResultImageSchema = z.object({ image: z.string().optional() }) export type MetadataResultImage = z.infer; 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 = | {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; export const artistSearchResultResponseSchema = z.object({ data: artistSearchResultSchema.array(), errors: metadataSearchErrorSchema.array() }); export type ArtistSearchResultResponse = z.infer; /** 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; export const albumSearchResultResponseSchema = z.object({ data: albumSearchResultSchema.array(), errors: metadataSearchErrorSchema.array() }); export type AlbumSearchResultResponse = z.infer; /** 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; export const trackSearchResultResponseSchema = z.object({ data: trackSearchResultSchema.array(), errors: metadataSearchErrorSchema.array() }); export type TrackSearchResultResponse = z.infer; 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; 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; 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; 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; export const EXTERNAL_ART_URL_REGEX = new RegExp(/api\/components\/\d+\/art/);