Files
allstarr/webui/src/lib/api.ts
T

1981 lines
62 KiB
TypeScript

export type Session = {
authenticated: boolean;
backend: string;
features?: {
intelligence: boolean;
};
user?: {
id: string;
name: string;
isAdministrator: boolean;
avatarUrl?: string | null;
};
};
export type OnboardingState = {
completed: boolean;
setupOpen: boolean;
shouldRedirectToSetup: boolean;
schemaVersion: string;
completedSteps: string[];
completionSource: string;
completedAt?: string | null;
reopenedAt?: string | null;
revision: number;
recoveryNotices: string[];
alreadyCompleted?: boolean;
migration: {
available: boolean;
completed: boolean;
firstRun: boolean;
lastAppliedAt?: string | null;
};
};
export type RuntimeStatus = {
version: string;
backendType: string;
durableStorage?: {
provider: string;
readiness: string;
errorCode?: string | null;
checkedAt?: string;
};
};
export type PlaylistSummary = {
id: string;
name: string;
trackCount: number;
localTracks: number;
externalTracks: number;
unmatchedTracks: number;
artworkUrl?: string | null;
sourceProvider?: string;
};
export type PlaylistResponse = {
playlists: PlaylistSummary[];
inventory: {
managed: number;
unmanaged: number;
};
};
export type Job = {
id: string;
type: string;
state: string;
attemptCount?: number;
failureCount?: number;
deferralCount?: number;
availableAt?: string;
cancellationRequestedAt?: string | null;
lastErrorCode?: string | null;
lastErrorMessage?: string | null;
updatedAt: string;
};
export type JobProgress = {
id: string;
jobId?: string | null;
action: string;
outcome: string;
detailsJson: string;
createdAt: string;
};
export type JobResponse = { jobs: Job[]; progress: JobProgress[] };
export type ActivityItem = {
id: string;
kind: string;
source: string;
label: string;
state: string;
detail: string;
occurredAt: string;
correlationId?: string | null;
severity?: string;
providerId?: string | null;
playlistLinkId?: string | null;
playlistName?: string | null;
artworkUrl?: string | null;
sourceTitle?: string | null;
sourceArtist?: string | null;
sourceAlbum?: string | null;
targetProviderId?: string | null;
targetTitle?: string | null;
targetArtist?: string | null;
confidenceLabel?: string | null;
isrc?: string | null;
sourceProviderTrackId?: string | null;
targetProviderTrackId?: string | null;
backendItemId?: string | null;
routeDecisionId?: string | null;
actorUserId?: string | null;
action?: string | null;
durationMilliseconds?: number | null;
technicalDetails?: Record<string, string> | null;
};
export type ActivityResponse = {
items: ActivityItem[];
hasMore: boolean;
nextCursor?: string | null;
nextCursorId?: string | null;
};
export type ProviderSummary = {
providerId: string;
connectedAccountName?: string | null;
enabledAccountCount: number;
capabilityTotal: number;
healthyCapabilityCount: number;
failedCapabilityCount: number;
lastCheckedAt?: string | null;
successRate?: number | null;
p95LatencyMilliseconds?: number | null;
lastFailureCode?: string | null;
};
export type NowPlayingItem = {
deviceId: string;
userId?: string | null;
userName: string;
avatarUrl?: string | null;
client: string;
device?: string | null;
itemId: string;
title: string;
artist: string;
album?: string | null;
providerId: string;
providerAccountName?: string | null;
catalogProviderId?: string;
sourceConfirmed?: boolean;
cached?: boolean;
routeReason?: string | null;
artworkUrl?: string | null;
positionSeconds: number;
durationSeconds?: number | null;
progress?: number | null;
lastActivity: string;
scrobbleThresholdSeconds?: number | null;
scrobbleEligible?: boolean;
scrobbleDeliveries?: Array<{
targetId: string;
kind: string;
state: string;
requiresReauthentication: boolean;
message?: string | null;
updatedAt: string;
}>;
scrobbled: boolean;
};
export type HomeOverview = {
schema: UiSchema;
status: RuntimeStatus;
stats: {
linkedPlaylists: number;
playableTracks: number;
unresolvedTracks: number;
activeJobs: number;
completedListens: number;
currentWeekListens: number;
previousWeekListens: number;
scrobbleDeliveries: number;
cacheTracks?: number | null;
keptTracks?: number | null;
topArtist?: { name?: string | null; listens: number } | null;
};
providerHealth: { providers: ProviderSummary[] };
activity: ActivityResponse;
};
export type ProviderSetting = {
key: string;
label: string;
type: string;
sensitive?: boolean;
required?: boolean;
options?: string[];
helpText?: string | null;
defaultValueJson?: string | null;
};
export type ProviderRuntimeCapability = {
id: string;
configuration?: string;
supported?: boolean;
health?: string;
ready: boolean;
canAttempt: boolean;
canTest?: boolean;
testedAt?: string | null;
latencyMilliseconds?: number | null;
reasonCode?: string | null;
};
export type ProviderDefinition = {
id: string;
name: string;
icon?: string | null;
description?: string | null;
logoUrl?: string | null;
categories?: string[];
status?: string;
notes?: string[];
configSchema?: ConfigField[];
accountSettings?: ProviderSetting[];
runtimeCapabilities?: ProviderRuntimeCapability[];
connectionKind?: string | null;
audience?: string | null;
implementationOrigin?: string | null;
routeId?: string | null;
capabilityRoutes?: Array<{
routeId?: string;
name?: string;
origin?: string;
capabilities: string[];
}>;
};
export type AppleDownloadStatus = {
state?: string;
ready?: boolean;
staged?: boolean;
daemon_running?: boolean;
wrapper_healthy?: boolean;
logged_in?: boolean;
login_state?: string;
api_version?: string | null;
account?: { state?: string; logged_in?: boolean };
};
export type UiSchema = {
activeBackend: string;
providerAccountManagementMode?: string;
providers: ProviderDefinition[];
configSections?: ConfigSection[];
priorityGroups?: PriorityGroup[];
};
export type ConfigField = {
key: string;
label: string;
type: string;
valuePath?: string | null;
options?: string[];
placeholder?: string | null;
sensitive?: boolean;
required?: boolean;
ownership?: string;
readOnly?: boolean;
helpText?: string | null;
min?: number | null;
max?: number | null;
};
export type ConfigSection = {
id: string;
label: string;
fields: ConfigField[];
};
export type PriorityGroup = {
id: string;
label: string;
description?: string | null;
envKey: string;
enabledEnvKey?: string | null;
providers: string[];
pinnedProvider?: {
id: string;
name: string;
icon: string;
reason: string;
} | null;
};
export type EnvMigrationPreview = {
previewToken: string;
revision: string;
expiresAt: string;
canApply: boolean;
importedSettingCount: number;
providerAccountCount: number;
manualCount: number;
backendIdentityCount: number;
playlistLinkCount: number;
scheduleCount: number;
items: Array<{
key: string;
sourceLine: number;
action: string;
reason: string;
sensitive: boolean;
valuePreview?: string | null;
warning?: string | null;
}>;
conflicts: string[];
warnings: string[];
};
export type SelectiveTransferOptions = {
settings: boolean;
accounts: boolean;
playlists: boolean;
intelligence: boolean;
extensions: boolean;
};
export type SelectiveTransferReport = {
includedCategories: string[];
excludedCategories: string[];
totalRows: number;
rowsByEntry: Record<string, number>;
};
export type SelectiveTransferPreview = {
canImport: boolean;
dependencies: string[];
conflicts: string[];
report: SelectiveTransferReport;
};
export type CacheTierUsage = {
tier: string;
entryCount: number;
payloadBytes: number;
maximumBytes?: number | null;
maximumEntryBytes?: number | null;
enabled: boolean;
hits: number;
misses: number;
writes: number;
evictions: number;
hitRatio: number;
};
export type CacheCategoryDiagnostics = {
category: string;
owner: string;
storageTier: string;
enabled: boolean;
entryCount: number;
payloadBytes: number;
freshSeconds: number;
staleSeconds: number;
maximumBytes: number;
maximumEntries: number;
warmingRule: string;
invalidationTrigger: string;
};
export type CacheDiagnostics = {
database: CacheTierUsage;
hot: CacheTierUsage;
media: CacheTierUsage;
categories: CacheCategoryDiagnostics[];
activity: {
coalescedRequests: number;
staleServes: number;
upstreamBytesAvoided: number;
};
artworkLimits: {
maximumEntryBytes: number;
maximumDecodedPixels: number;
};
extensionStorage: {
activeExtensions: number;
entryCount: number;
payloadBytes: number;
maximumBytes: number;
};
capturedAt: string;
};
export type CacheMaintenancePreview = {
metadata: {
scannedEntries: number;
scanLimitReached: boolean;
expiredEntries: number;
unknownOwnerEntries: number;
disabledCategoryEntries: number;
noExpiryEntries: number;
staleAuthorizationScopeEntries: number;
supersededEntries: number;
overQuotaEntries: number;
reclaimableBytes: number;
};
media: {
scannedFiles: number;
scanLimitReached: boolean;
temporaryFiles: number;
malformedMetadataFiles: number;
orphanedMetadataFiles: number;
orphanedPayloadFiles: number;
expiredEntries: number;
noExpiryEntries: number;
overQuotaEntries: number;
reclaimableBytes: number;
cleanupIntervalSeconds: number;
lastCleanupAt?: string | null;
lastCleanupDeletedEntries: number;
};
unreferencedArtworkPayloads: number;
unreferencedArtworkBytes: number;
artworkReferenceScanLimitReached: boolean;
capturedAt: string;
};
export type ProviderAccount = {
id: string;
providerId: string;
displayName: string;
sourceDisplayName?: string | null;
scope: "Global" | "User" | "Library";
ownerUserId?: string | null;
ownerDisplayName?: string | null;
createdByUserId?: string | null;
creatorDisplayName?: string | null;
libraryScopeId?: string | null;
enabled: boolean;
revision: number;
canChangeAudience?: boolean;
configuration?: Record<string, unknown>;
configuredFields?: string[];
secret: {
configured: boolean;
version?: number | null;
updatedAt?: string | null;
revoked: boolean;
};
createdAt: string;
updatedAt: string;
};
export type ProviderHealth = {
provider: string;
providerAccountId: string;
providerAccountName: string;
capability: string;
accountScope: string;
supported: boolean;
enabled: boolean;
configuration: string;
health: string;
ready: boolean;
canAttempt: boolean;
testedAt?: string | null;
reasonCode?: string | null;
canTest: boolean;
};
export type ConnectivityResult = {
success?: boolean;
healthy?: boolean;
health?: string;
latencyMs?: number;
bars?: number;
metric?: string;
testedAt?: string;
reasonCode?: string | null;
};
export type CtsMeasurement = {
providerAccountId?: string | null;
providerId: string;
health: string;
latencyMs: number;
bars: number;
testedAt: string;
failureCode?: string | null;
};
export type PlaylistLinkMetrics = {
total: number;
matched: number;
unresolved: number;
review: number;
rejected: number;
playable: number;
materialized: number;
snapshotId?: string | null;
snapshotVersion?: number | null;
};
export type PlaylistLink = {
id: string;
enabled: boolean;
name: string;
description?: string | null;
artworkUrl?: string | null;
sourceProviderId: string;
sourcePlaylistId: string;
sourceUpdateAvailable?: boolean;
providerAccountId: string;
libraryScopeId: string;
targetProtocol: string;
targetBackendInstanceId: string;
targetPlaylistId?: string | null;
targetCredentialReferenceId?: string | null;
mode: "virtual" | "materialized" | "hybrid";
projectionMode: "resolved" | "source" | "target";
materializationMode: "reconcile" | "recreate";
importMode: "oneTime" | "linked";
trackRetention: "onDemand" | "keepAll";
scheduleId?: string | null;
mirrorStaleEntries: boolean;
preserveManualEntries: boolean;
syncName: boolean;
syncDescription: boolean;
syncArtwork: boolean;
ruleVersion: string;
policyVersion: string;
revision: number;
lastRunAt?: string | null;
lastRunState?: string | null;
trackCount: number;
matchedCount: number;
unmatchedCount: number;
playableCount: number;
materializedCount: number;
routeCoverage: Array<{ providerId: string; count: number }>;
metrics: PlaylistLinkMetrics;
};
export type PlaylistSourceUpdatePreview = {
providerId: string;
providerName: string;
sourcePlaylistName: string;
backendPlaylistName: string;
backendProtocol: string;
sourceVersion: string;
expectedRevision: number;
confirmationId: string;
currentCount: number;
includedCount: number;
skippedCount: number;
addedCount: number;
removedCount: number;
movedCount: number;
duplicateCount: number;
canApply: boolean;
message: string;
changes: Array<{
kind: "add" | "remove" | "move";
fromPosition?: number | null;
toPosition?: number | null;
title: string;
artist: string;
}>;
skipped: Array<{
position: number;
title: string;
artist: string;
reason: string;
}>;
unshownChangeCount: number;
unshownSkippedCount: number;
};
export type PlaylistRematchPreview = {
confirmationId: string;
playlistCount: number;
libraryCount: number;
totalRows: number;
localRows: number;
exactProviderRows: number;
genericExternalRows: number;
unresolvedRows: number;
confirmedManualRows: number;
staleRevisionRows: number;
conflictingRows: number;
rowsToRematch: number;
uniqueTracksToRematch: number;
canApply: boolean;
};
export type PlaylistTrack = {
sourcePosition: number;
position: number;
externalSnapshotId: string;
title: string;
artists: string[];
album?: string | null;
isrc?: string | null;
durationMs?: number | null;
durationProvenance?: string | null;
artworkUrl?: string | null;
backendItemId?: string | null;
routeKind: "local" | "external" | "unmatched" | "unresolved";
routeProviderId?: string | null;
matchState?: string | null;
targetEligible?: boolean;
outcomeCode?: string | null;
targetStatus?: string | null;
providerRoutes: Array<{ providerId: string; externalId: string; pinned: boolean }>;
};
export type PlaylistClientTrack = {
position: number;
sourcePosition: number;
itemId: string;
playlistEntryId?: string | null;
title: string;
artists: string[];
album?: string | null;
durationMs?: number | null;
routeKind: "local" | "external" | "unresolved";
routeProviderId?: string | null;
};
export type PlaylistClientProjection = {
protocolId: string;
projectionMode: "resolved" | "source" | "target";
trackCount: number;
tracks: PlaylistClientTrack[];
};
export type PlaylistSchedule = {
id: string;
cronExpression: string;
timeZoneId: string;
overlapPolicy: "skip" | "queue";
misfirePolicy: "skip" | "runOnce";
enabled: boolean;
nextRunAt?: string | null;
revision: number;
};
export type PlaylistDetails = {
id: string;
snapshotId: string;
snapshotVersion: number;
latestSourceSnapshotVersion: number;
hasNewerSourceGeneration: boolean;
name: string;
sourceProviderId: string;
projectionMode: "resolved" | "source" | "target";
targetProtocol: string;
targetPlaylistId?: string | null;
artworkUrl?: string | null;
retrievedAt: string;
lastRematchedAt?: string | null;
completedAt?: string | null;
syncState?: string | null;
trackCount: number;
localCount: number;
externalCount: number;
unresolvedCount: number;
matchedCount: number;
reviewCount: number;
rejectedCount: number;
playableCount: number;
routeCoverage: Array<{ providerId: string; count: number }>;
durationMs?: number | null;
unknownDurationCount: number;
reconciliation?: {
providerAdvertisedRows: number;
rawRows: number;
mappedRows: number;
persistedSourceRows: number;
publishedRows: number;
accepted: number;
tentative: number;
rejected: number;
unresolved: number;
playableRoutes: number;
materializedTargetRows: number;
protocolVisibleRows: number;
addedPositions: number[];
removedPositions: number[];
movedPositions: number[];
duplicatedPositions: number[];
changedPositions: number[];
} | null;
schedule?: PlaylistSchedule | null;
clientProjection?: PlaylistClientProjection | null;
tracks: PlaylistTrack[];
};
export type PlaylistSourceAccount = {
id: string;
providerId: string;
displayName: string;
ownerDisplayName?: string | null;
libraryScopeId?: string | null;
accessLabel: string;
};
export type PlaylistDiscoveryItem = {
id: string;
providerId: string;
name: string;
owner?: string | null;
trackCount?: number | null;
artworkUrl?: string | null;
};
export type MediaTarget = {
id: string;
protocol: "jellyfin" | "subsonic";
backendInstanceId: string;
libraryScopeId?: string | null;
displayName: string;
credentialReferenceId?: string | null;
};
export type TargetPlaylist = {
id: string;
name: string;
description?: string | null;
trackCount?: number | null;
artworkUrl?: string | null;
writable: boolean;
};
export type MatchCandidate = {
libraryTrackId?: string | null;
backendItemId?: string | null;
confidence?: number | null;
title?: string | null;
artist?: string | null;
album?: string | null;
durationMilliseconds?: number | null;
sourceIsrc?: string | null;
candidateIsrc?: string | null;
normalizedSourceTitle?: string | null;
normalizedCandidateTitle?: string | null;
artistOverlap?: number | null;
albumEvidence?: number | null;
durationDeltaMilliseconds?: number | null;
isLocal?: boolean | null;
providerTrackIds?: Record<string, string> | null;
components?: Record<string, number> | null;
reasons?: string[] | null;
warnings?: string[] | null;
};
export type ManualMatchAuthority = {
id: string;
kind: "local_match" | "provider_match" | "rejection";
revision: number;
authoritySnapshotId: string;
reason: string;
createdAt: string;
effective: boolean;
targetProviderId?: string | null;
targetExternalId?: string | null;
};
export type MatchReviewItem = {
externalSnapshotId: string;
providerId: string;
providerAccountId?: string | null;
libraryScopeId: string;
state: string;
decisionSource: string;
confidence?: number | null;
threshold?: number | null;
decisionVersion?: number | null;
algorithmVersion?: string | null;
policyVersion?: string | null;
sourceSnapshotVersion?: number | null;
libraryIndexRevision?: number | null;
canonicalRecordingId?: string | null;
libraryTrackId?: string | null;
overrideId?: string | null;
overrideRevision?: number | null;
manualAuthorities?: ManualMatchAuthority[];
title?: string | null;
searchQuery?: string | null;
artist?: string | null;
album?: string | null;
artworkUrl?: string | null;
sourceArtworkUrl?: string | null;
candidateArtworkUrl?: string | null;
isrc?: string | null;
durationMilliseconds?: number | null;
localTrack?: {
id: string;
backendItemId: string;
title: string;
artist?: string | null;
album?: string | null;
durationMilliseconds?: number | null;
artworkUrl?: string | null;
providerIds?: Record<string, string>;
} | null;
providerIdentities: Array<{
id?: string;
providerId: string;
externalId: string;
scope: string;
verification: string;
verificationMethod?: string;
revision?: number;
}>;
candidates: MatchCandidate[];
reasons: string[];
warnings: string[];
decidedAt?: string | null;
reviewedAt?: string | null;
};
export type MatchReviewResponse = {
matches: MatchReviewItem[];
stats: {
total: number;
matched: number;
accepted: number;
unresolved: number;
suggested: number;
review: number;
ambiguous?: number;
rejected: number;
manualMatches?: number;
manualRejections?: number;
automatic?: number;
attention: number;
};
pagination: { page: number; pageSize: number; total: number; totalPages: number };
};
export type TrackRematchAllPreview = {
confirmationId: string;
algorithmVersion: string;
totalTracks: number;
resolvedTracks: number;
reviewTracks: number;
unresolvedTracks: number;
protectedManualTracks: number;
automaticDecisionsToReplace: number;
tracksToRematch: number;
canApply: boolean;
};
export type MatchTarget = {
id: string;
backendItemId?: string | null;
externalId?: string | null;
externalProvider?: string | null;
title: string;
artist?: string | null;
album?: string | null;
artworkUrl?: string | null;
durationMilliseconds?: number | null;
confidence?: number | null;
isrc?: string | null;
components?: Record<string, number> | null;
reasons?: string[];
warnings?: string[];
};
export type ManagedDownload = {
path: string;
storage: string;
artist: string;
album: string;
title: string;
fileName: string;
size: number;
sizeFormatted: string;
lastModified: string;
codec: string;
bitrateKbps?: number | null;
sampleRateHz?: number | null;
bitDepth?: number | null;
channels?: number | null;
durationMilliseconds?: number | null;
quality: string;
provider?: string | null;
externalId?: string | null;
artworkUrl?: string | null;
lastAccessedAt: string;
expiresAt?: string | null;
publicationState: string;
referenceCount?: number | null;
removable: boolean;
};
export type DownloadsResponse = {
storage: string;
files: ManagedDownload[];
totalSize: number;
totalSizeFormatted: string;
count: number;
managedCount: number;
diagnosticCount: number;
};
export type IntelligenceScope = {
protocol: string;
backendInstanceId: string;
libraryScopeId: string;
};
export type ListeningApp = {
id: string;
relayExternally: boolean;
createdAt: string;
};
export type ListeningAppCreated = ListeningApp & { token: string };
export type AudioMuseTrack = {
trackId: string;
title?: string | null;
artist?: string | null;
album?: string | null;
libraryTrackId?: string | null;
score: number;
explanation?: string | null;
};
export type AudioMuseAnalysis = {
jobId: string;
state: "queued" | "running" | "completed" | "failed" | "canceled";
completed: number;
total: number;
safeCode?: string | null;
};
export type AudioMuseCluster = {
id: string;
name: string;
tracks: AudioMuseTrack[];
};
export type AudioMuseMapPage = {
items: Array<AudioMuseTrack & { x: number; y: number; clusterId?: string | null }>;
projection: string;
nextCursor?: string | null;
isPartial: boolean;
snapshotVersion?: string | null;
};
export type IntelligenceState = {
state: string;
message?: string | null;
scope: IntelligenceScope;
policy?: {
enabled: boolean;
retentionDays: number;
revision: number;
targetCredentialReferenceId?: string | null;
targetCredentialConfigured?: boolean;
} | null;
availableSignalTypes: Array<{ id: string; label: string; enabled: boolean }>;
providers: Array<{
id: string;
label: string;
description: string;
enabled: boolean;
available: boolean;
state: string;
reasonCode?: string | null;
}>;
listeningServices?: Array<{
id: string;
label: string;
configured: boolean;
latestState?: string | null;
requiresReauthentication?: boolean | null;
updatedAt?: string | null;
}>;
songDetails?: {
pending: number;
resolved: number;
unresolved: number;
failed: number;
};
actions: {
canRun: boolean;
canGenerate: boolean;
latestRunId?: string | null;
latestRunState?: string | null;
latestJobId?: string | null;
attemptCount?: number | null;
failureCount?: number | null;
maxAttempts?: number | null;
canCancel?: boolean;
progress?: {
stage: string;
message: string;
completed?: number | null;
total?: number | null;
provider?: string | null;
playlist?: string | null;
track?: string | null;
} | null;
};
candidates: Array<{
id: string;
trackKey: string;
title?: string | null;
artist?: string | null;
album?: string | null;
artworkUrl?: string | null;
score: number;
source: string;
providerId: string;
sourceRevision: string;
revision: number;
explanations: Array<{ code: string; weight: number; explanation: string }>;
exclusions: string[];
feedback?: { kind: string; reasonCode?: string | null; revision: number } | null;
}>;
generatedSets: Array<{
id: string;
name: string;
trackCount: number;
state: string;
materialized: boolean;
backendPlaylistId?: string | null;
errorCode?: string | null;
}>;
schedules: IntelligenceSchedule[];
visualization: Array<{ key: string; label: string; value: number }>;
};
export type IntelligenceSchedule = {
id: string;
cronExpression: string;
timeZoneId: string;
overlapPolicy: "skip" | "queue";
misfirePolicy: "skip" | "runOnce";
enabled: boolean;
nextRunAt?: string | null;
revision: number;
name: string;
limit: number;
};
export type ListeningHistoryStats = {
completedListens: number;
distinctTracks: number;
distinctArtists: number;
listeningTimeMilliseconds: number;
firstListen?: string | null;
};
export type ListeningHistoryTargetStatus = {
target: string;
state: string;
code?: string | null;
message?: string | null;
retryAfter?: string | null;
requiresReauthentication: boolean;
updatedAt: string;
};
export type ListeningHistoryItem = {
id: string;
title?: string | null;
artist?: string | null;
album?: string | null;
listenedAt?: string | null;
durationMilliseconds?: number | null;
client?: string | null;
source: string;
provider?: string | null;
state: string;
enrichmentState: string;
artworkUrl?: string | null;
targetStatuses: ListeningHistoryTargetStatus[];
revision: number;
};
export type ListeningHistoryPeriod = { from: string; to: string; timeZoneId: string };
export type ListeningHistoryOverview = {
period: ListeningHistoryPeriod;
allTime: ListeningHistoryStats;
selected: ListeningHistoryStats;
currentStreakDays: number;
longestStreakDays: number;
breakdowns: {
sources: ListeningHistoryBreakdown[];
providers: ListeningHistoryBreakdown[];
clients: ListeningHistoryBreakdown[];
};
nowPlaying?: ListeningHistoryItem | null;
recent: ListeningHistoryItem[];
};
export type ListeningHistoryBreakdown = {
dimension: string;
value: string;
listenCount: number;
durationMilliseconds: number;
};
export type ListeningHistoryActivity = {
period: ListeningHistoryPeriod;
currentStreakDays: number;
longestStreakDays: number;
buckets: Array<{
date: string;
count: number;
importedCount: number;
playbackCount: number;
durationMilliseconds: number;
}>;
};
export type ListeningHistoryTopItem = {
title?: string | null;
artist?: string | null;
album?: string | null;
listenCount: number;
listeningTimeMilliseconds: number;
lastListenedAt?: string | null;
};
export type ListeningHistoryDetail = {
item: ListeningHistoryItem;
identity: {
recordingMusicBrainzId?: string | null;
isrc?: string | null;
albumArtist?: string | null;
trackNumber?: number | null;
musicBrainzEnrichmentConfidence?: number | null;
musicBrainzSourceRevision?: string | null;
musicBrainzEnrichedAt?: string | null;
musicBrainzFacts?: Record<string, unknown> | null;
};
provenance: {
source: string;
client?: string | null;
device?: string | null;
provider?: string | null;
imported: boolean;
};
};
export type ListeningHistoryImportPreview = {
format: string;
fileRows: number;
musicRows: number;
completed: number;
partial: number;
skipped: number;
episodes: number;
nonTrack: number;
malformed: number;
duplicateInFile: number;
duplicateExisting: number;
newRows: number;
resolvedNewRows: number;
unresolvedNewRows: number;
rowsWithoutProviderIdentity: number;
sourceUserCount: number;
estimatedMusicBrainzLookups: number;
earliest?: string | null;
latest?: string | null;
reasonCounts: Record<string, number>;
outsideRetentionRows?: number;
};
export type ListeningHistoryImport = {
importId: string;
revision: string;
displayFileName?: string;
sizeBytes?: number;
expiresAt?: string;
state: string;
jobId?: string | null;
jobState?: string | null;
lastErrorCode?: string | null;
lastErrorMessage?: string | null;
importedRows?: number;
duplicateRows?: number;
resolvedRows?: number;
unresolvedRows?: number;
outboundReplay: false;
preview?: ListeningHistoryImportPreview;
};
async function request(input: RequestInfo | URL, init?: RequestInit) {
const response = await fetch(input, {
cache: "no-store",
credentials: "same-origin",
...init,
});
if (!response.ok) {
const body = normalizeResponse(await response.json().catch(() => null));
throw new ApiError(response.status, response.statusText, body);
}
return response;
}
export class ApiError extends Error {
constructor(
readonly status: number,
statusText: string,
readonly details: unknown,
) {
const body = details as { error?: string; message?: string; stage?: string } | null;
const message = body?.error || body?.message || `${status} ${statusText}`;
super(body?.stage ? `${body.stage}: ${message}` : message);
}
}
export async function json<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await request(input, init);
if (response.status === 204) return undefined as T;
return normalizeResponse(await response.json()) as T;
}
export function normalizeResponse(value: unknown): unknown {
if (Array.isArray(value)) return value.map(normalizeResponse);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
key[0].toLowerCase() + key.slice(1),
normalizeResponse(item),
]));
}
function selectiveTransferForm(
file: File,
mode: "Conflict" | "Merge" | "Replace",
options: SelectiveTransferOptions,
) {
const body = new FormData();
body.append("File", file);
body.append("Mode", mode);
for (const [category, included] of Object.entries(options))
body.append(`Import${category[0].toUpperCase()}${category.slice(1)}`, String(included));
return body;
}
export const auth = {
session: () => json<Session>("/api/admin/auth/me"),
login: (username: string, password: string, rememberMe: boolean) =>
json<Session>("/api/admin/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, rememberMe }),
}),
logout: () => json<{ success: boolean }>("/api/admin/auth/logout", { method: "POST" }),
};
export const onboarding = {
status: () => json<OnboardingState>("/api/admin/onboarding/status"),
complete: () => json<OnboardingState>("/api/admin/onboarding/complete", { method: "POST" }),
reopen: () => json<OnboardingState>("/api/admin/onboarding/reopen", { method: "POST" }),
};
const intelligenceBody = (value: object, method = "POST") => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
});
const intelligenceQuery = (scope: IntelligenceScope, input: Record<string, string | number | undefined> = {}) => {
const query = new URLSearchParams(scope);
for (const [key, value] of Object.entries(input))
if (value !== undefined && value !== "") query.set(key, String(value));
return query;
};
export const intelligence = {
get: (scope: IntelligenceScope) =>
json<IntelligenceState>(`/api/admin/intelligence?${new URLSearchParams(scope)}`),
savePolicy: (scope: IntelligenceScope, input: object) =>
json<{ revision: number }>("/api/admin/intelligence/policy",
intelligenceBody({ ...scope, ...input }, "PUT")),
run: (scope: IntelligenceScope, seedTrackKeys: string[] = []) =>
json<{ runId: string; jobId: string }>("/api/admin/intelligence/runs",
intelligenceBody({ ...scope, seedTrackKeys, limit: 25, idempotencyKey: crypto.randomUUID() })),
generate: (scope: IntelligenceScope, runId: string, name: string) =>
json<{ id: string }>("/api/admin/intelligence/generated-sets",
intelligenceBody({ ...scope, runId, name })),
feedback: (scope: IntelligenceScope, candidateId: string, kind: string, expectedRevision: number) =>
json<{ revision: number }>(`/api/admin/intelligence/candidates/${encodeURIComponent(candidateId)}/feedback`,
intelligenceBody({ ...scope, kind, expectedRevision }, "PUT")),
purge: (scope: IntelligenceScope) =>
json<void>("/api/admin/intelligence/data", intelligenceBody(scope, "DELETE")),
listeningApps: (scope: IntelligenceScope) =>
json<{ items: ListeningApp[] }>(`/api/admin/intelligence/listening-apps?${intelligenceQuery(scope)}`),
createListeningApp: (scope: IntelligenceScope, sendToConnectedServices: boolean) =>
json<ListeningAppCreated>("/api/admin/intelligence/listening-apps",
intelligenceBody({ ...scope, sendToConnectedServices })),
revokeListeningApp: (scope: IntelligenceScope, id: string) =>
json<void>(`/api/admin/intelligence/listening-apps/${encodeURIComponent(id)}`,
intelligenceBody(scope, "DELETE")),
startAudioMuseAnalysis: (scope: IntelligenceScope, rebuild = false) =>
json<AudioMuseAnalysis>("/api/admin/intelligence/audiomuse/analysis",
intelligenceBody({ ...scope, rebuild, idempotencyKey: crypto.randomUUID() })),
audioMuseAnalysis: (scope: IntelligenceScope, jobId: string) =>
json<AudioMuseAnalysis>(`/api/admin/intelligence/audiomuse/analysis/${encodeURIComponent(jobId)}?${intelligenceQuery(scope)}`),
audioMuseSimilar: (scope: IntelligenceScope, seedTrackIds: string[], limit = 25) =>
json<{ tracks: AudioMuseTrack[] }>("/api/admin/intelligence/audiomuse/similar",
intelligenceBody({ ...scope, seedTrackIds, limit })),
audioMusePath: (scope: IntelligenceScope, startTrackId: string, endTrackId: string, limit = 25) =>
json<{ tracks: AudioMuseTrack[]; totalDistance: number }>("/api/admin/intelligence/audiomuse/path",
intelligenceBody({ ...scope, startTrackId, endTrackId, limit })),
audioMuseBlend: (scope: IntelligenceScope, includeTrackIds: string[], avoidTrackIds: string[], limit = 25) =>
json<{ tracks: AudioMuseTrack[] }>("/api/admin/intelligence/audiomuse/blend",
intelligenceBody({ ...scope, includeTrackIds, avoidTrackIds, limit })),
audioMuseFingerprint: (scope: IntelligenceScope, periodDays: 30 | 90 | 365, limit = 25) =>
json<{ tracks: AudioMuseTrack[]; periodDays: number; completedListens: number; seedCount: number }>(
"/api/admin/intelligence/audiomuse/fingerprint",
intelligenceBody({ ...scope, periodDays, limit })),
createAudioMusePlaylist: (scope: IntelligenceScope, name: string, trackIds: string[], idempotencyKey: string) =>
json<{ id: string; state: string }>("/api/admin/intelligence/audiomuse/generated-sets",
intelligenceBody({ ...scope, name, trackIds, idempotencyKey })),
audioMuseSearch: (scope: IntelligenceScope, query: string, mode: "text" | "lyrics", limit = 25) =>
json<{ tracks: AudioMuseTrack[]; mode: string }>("/api/admin/intelligence/audiomuse/search",
intelligenceBody({ ...scope, query, mode, limit })),
audioMuseClusters: (scope: IntelligenceScope, limit = 10, cursor?: string) =>
json<{ clusters: AudioMuseCluster[]; nextCursor?: string | null }>(
`/api/admin/intelligence/audiomuse/clusters?${intelligenceQuery(scope, { limit, cursor })}`),
audioMuseMap: (scope: IntelligenceScope, limit = 50, cursor?: string) =>
json<AudioMuseMapPage>(`/api/admin/intelligence/audiomuse/map?${intelligenceQuery(scope, { limit, cursor })}`),
historyOverview: (scope: IntelligenceScope, from: string | undefined, to: string | undefined, timeZoneId: string) =>
json<ListeningHistoryOverview>(`/api/admin/intelligence/history/overview?${intelligenceQuery(scope, { from, to, timeZoneId })}`),
history: (scope: IntelligenceScope, input: {
from?: string;
to?: string;
timeZoneId: string;
limit?: number;
cursor?: string;
source?: string;
client?: string;
artist?: string;
album?: string;
track?: string;
search?: string;
}) => json<{ period: ListeningHistoryPeriod; items: ListeningHistoryItem[]; nextCursor?: string | null }>(
`/api/admin/intelligence/history?${intelligenceQuery(scope, input)}`,
),
historyActivity: (scope: IntelligenceScope, from: string | undefined, to: string | undefined, timeZoneId: string) =>
json<ListeningHistoryActivity>(`/api/admin/intelligence/history/activity?${intelligenceQuery(scope, { from, to, timeZoneId })}`),
historyTop: (scope: IntelligenceScope, kind: "artist" | "album" | "track", from: string | undefined, to: string | undefined, timeZoneId: string) =>
json<{ period: ListeningHistoryPeriod; kind: string; items: ListeningHistoryTopItem[] }>(
`/api/admin/intelligence/history/top/${kind}?${intelligenceQuery(scope, { from, to, timeZoneId, limit: 10 })}`,
),
historyDetail: (scope: IntelligenceScope, id: string) =>
json<ListeningHistoryDetail>(`/api/admin/intelligence/history/${encodeURIComponent(id)}?${intelligenceQuery(scope)}`),
correctHistory: (scope: IntelligenceScope, id: string, input: {
title: string;
artist: string;
album?: string | null;
albumArtist?: string | null;
expectedRevision: number;
}) => json<{ id: string; revision: number }>(
`/api/admin/intelligence/history/${encodeURIComponent(id)}`,
intelligenceBody({ ...scope, ...input }, "PUT"),
),
deleteHistory: (scope: IntelligenceScope, id: string, expectedRevision: number) =>
json<void>(`/api/admin/intelligence/history/${encodeURIComponent(id)}`,
intelligenceBody({ ...scope, expectedRevision, confirmed: true }, "DELETE")),
historyExportUrl: (scope: IntelligenceScope) =>
`/api/admin/intelligence/history/export?${intelligenceQuery(scope)}`,
previewHistoryImport: (scope: IntelligenceScope, file: File) => {
const body = new FormData();
body.append("file", file);
for (const [key, value] of Object.entries(scope)) body.append(key, value);
return json<ListeningHistoryImport>("/api/admin/intelligence/history/imports/preview", { method: "POST", body });
},
historyImport: (scope: IntelligenceScope, id: string) =>
json<ListeningHistoryImport>(`/api/admin/intelligence/history/imports/${encodeURIComponent(id)}?${intelligenceQuery(scope)}`),
historyImports: (scope: IntelligenceScope) =>
json<{ items: ListeningHistoryImport[] }>(`/api/admin/intelligence/history/imports?${intelligenceQuery(scope)}`),
changeHistoryImport: (scope: IntelligenceScope, item: ListeningHistoryImport, operation: "apply" | "resume" | "cancel") =>
json<ListeningHistoryImport>(`/api/admin/intelligence/history/imports/${encodeURIComponent(item.importId)}/${operation}`,
intelligenceBody({ ...scope, revision: item.revision })),
removeHistoryImport: (scope: IntelligenceScope, item: ListeningHistoryImport) =>
json<{ removedImport: true; removedListens: number }>(
`/api/admin/intelligence/history/imports/${encodeURIComponent(item.importId)}`,
intelligenceBody({ ...scope, revision: item.revision, confirmed: true }, "DELETE"),
),
createSchedule: (scope: IntelligenceScope, input: Omit<IntelligenceSchedule, "id" | "revision" | "nextRunAt">) =>
json<IntelligenceSchedule>("/api/admin/intelligence/schedules", intelligenceBody({ ...scope, ...input })),
updateSchedule: (scope: IntelligenceScope, schedule: IntelligenceSchedule, input: Omit<IntelligenceSchedule, "id" | "revision" | "nextRunAt">) =>
json<IntelligenceSchedule>(`/api/admin/intelligence/schedules/${encodeURIComponent(schedule.id)}`,
intelligenceBody({ ...scope, ...input, expectedRevision: schedule.revision }, "PUT")),
deleteSchedule: (scope: IntelligenceScope, schedule: IntelligenceSchedule) =>
json<void>(`/api/admin/intelligence/schedules/${encodeURIComponent(schedule.id)}`,
intelligenceBody({ ...scope, expectedRevision: schedule.revision }, "DELETE")),
};
export const home = {
overview: () => json<HomeOverview>("/api/admin/ui/home"),
schema: () =>
json<UiSchema>("/api/admin/ui/schema"),
jobs: () => json<JobResponse>("/api/admin/jobs?limit=100"),
cancelJob: (id: string) =>
json<{ jobId: string; state: string }>(`/api/admin/jobs/${encodeURIComponent(id)}/cancel`, {
method: "POST",
}),
providers: () => json<{ providers: ProviderSummary[] }>("/api/admin/ui/provider-summaries"),
nowPlaying: () => json<{ items: NowPlayingItem[] }>("/api/admin/ui/now-playing"),
};
export const sources = {
accounts: () =>
json<{
managementMode: string;
audienceUsers: { id: string; displayName: string }[];
accounts: ProviderAccount[];
}>("/api/admin/provider-accounts"),
health: () => json<ProviderHealth[]>("/api/admin/providers/status"),
cts: () =>
json<{ measurements: CtsMeasurement[] }>("/api/admin/provider-diagnostics/deep-stream/latest"),
create: (input: {
providerId: string;
displayName: string;
scope: string;
libraryScopeId?: string | null;
enabled: boolean;
secret: Record<string, unknown>;
}) => json<ProviderAccount>("/api/admin/provider-accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
authenticateLastFm: (accountId: string, username: string, password: string) =>
json<{ success: boolean }>("/api/admin/scrobbling/lastfm/authenticate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountId, username, password }),
}),
setEnabled: (account: ProviderAccount, enabled: boolean) =>
json<ProviderAccount>(`/api/admin/provider-accounts/${account.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, expectedRevision: account.revision }),
}),
replaceSecret: (account: ProviderAccount, secret: Record<string, unknown>) =>
json<{ accountId: string; revision: number }>(`/api/admin/provider-accounts/${account.id}/secret`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret }),
}),
setAudience: (
account: ProviderAccount,
scope: string,
ownerUserId?: string | null,
libraryScopeId?: string | null,
) =>
json<ProviderAccount>(`/api/admin/provider-accounts/${account.id}/audience`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, ownerUserId, libraryScopeId, expectedRevision: account.revision }),
}),
remove: (id: string) =>
json<void>(`/api/admin/provider-accounts/${id}`, { method: "DELETE" }),
test: (account: ProviderAccount, capability?: string) =>
json<ConnectivityResult>(
`/api/admin/providers/test/${encodeURIComponent(account.providerId)}${capability ? `/${encodeURIComponent(capability)}` : ""}?accountId=${encodeURIComponent(account.id)}`,
{ method: "POST" },
),
deepStream: (providerId: string, providerAccountId?: string | null, quality = 0) =>
json<ConnectivityResult & {
clickToStreamMilliseconds?: number;
firstByteMilliseconds?: number;
sampleBytes?: number;
throughputKbps?: number;
cacheState?: string;
trackLabel?: string;
}>("/api/admin/provider-diagnostics/deep-stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId,
providerAccountId,
quality,
}),
}),
};
export const settings = {
config: () => json<Record<string, unknown>>("/api/admin/config"),
save: (updates: Record<string, string>) =>
json<{ message: string; updatedKeys: string[] }>("/api/admin/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
}),
storage: () => json<{
storage: { provider?: string; readiness?: string; checkedAt?: string };
backups: Array<{ id: string; status: string; createdAt: string; verifiedAt?: string | null }>;
}>("/api/admin/storage"),
backup: () => json<{ id: string; status: string }>("/api/admin/storage/backups", { method: "POST" }),
cache: () => json<CacheDiagnostics>("/api/admin/cache"),
cachePreview: () => json<CacheMaintenancePreview>("/api/admin/cache/maintenance/preview"),
cleanCache: () => json<{ deleted: number }>("/api/admin/cache/maintenance", { method: "POST" }),
purgeCache: (scope: "metadata" | "media" | "all") =>
json<{ deleted: number }>(`/api/admin/cache/${scope}`, { method: "DELETE" }),
purgeCacheCategory: (category: string) =>
json<{ category: string; deleted: number }>(
`/api/admin/cache/categories/${encodeURIComponent(category)}`,
{ method: "DELETE" },
),
mediaProbe: () => json<{ success: boolean; code: string; message: string }>("/api/admin/media-probe"),
playlistProbe: () => json<{ success: boolean; code: string; message: string }>("/api/admin/playlist-readiness"),
exportState: async (options: SelectiveTransferOptions, signal?: AbortSignal) => {
const response = await request("/api/admin/export-selective-state", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.fromEntries(Object.entries(options)
.map(([category, included]) => [`include${category[0].toUpperCase()}${category.slice(1)}`, included]))),
signal,
});
const filename = /filename="?([^";]+)"?/i.exec(response.headers.get("Content-Disposition") ?? "")?.[1] ??
"allstarr-selective-export.zip";
return { blob: await response.blob(), filename };
},
previewState: (
file: File,
mode: "Conflict" | "Merge" | "Replace",
options: SelectiveTransferOptions,
signal?: AbortSignal,
) => json<SelectiveTransferPreview>("/api/admin/preview-selective-state", {
method: "POST",
body: selectiveTransferForm(file, mode, options),
signal,
}),
importState: (
file: File,
mode: "Conflict" | "Merge" | "Replace",
options: SelectiveTransferOptions,
signal?: AbortSignal,
) => json<{ success: boolean; message: string; report: SelectiveTransferReport }>(
"/api/admin/import-selective-state", {
method: "POST",
body: selectiveTransferForm(file, mode, options),
signal,
}),
migrationStatus: () => json<{
available: boolean;
completed: boolean;
sourcePresent: boolean;
firstRun: boolean;
lastAppliedAt?: string | null;
}>("/api/admin/config/migration/status"),
previewMigration: (file: File) => {
const body = new FormData();
body.append("file", file);
return json<EnvMigrationPreview>("/api/admin/config/migration/preview", {
method: "POST",
body,
});
},
applyMigration: (preview: EnvMigrationPreview) =>
json<{ success: boolean; alreadyApplied: boolean }>("/api/admin/config/migration/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
previewToken: preview.previewToken,
revision: preview.revision,
confirmed: true,
}),
}),
resetMigration: (previewToken: string) =>
json<void>("/api/admin/config/migration/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ previewToken }),
}),
};
export type ExtensionRegistry = {
id: string;
name: string;
registryUrl: string;
enabled: boolean;
revision: number;
};
export type ExtensionPackage = {
id: string;
registryId?: string | null;
previousPackageId?: string | null;
extensionId: string;
displayName: string;
version: string;
lifecycle: string;
state: string;
active: boolean;
installed: boolean;
permissionReviewRequired: boolean;
hasPermissions: boolean;
description?: string | null;
author?: string | null;
iconUrl?: string | null;
capabilities?: string[];
compatibility?: string | null;
failureCode?: string | null;
stagedAt?: string | null;
revision: number;
};
export type ExtensionStoreItem = {
id: string;
displayName: string;
version: string;
description?: string | null;
author?: string | null;
downloadUrl: string;
sha256: string;
registryId?: string | null;
iconUrl?: string | null;
types?: string[];
};
export type ExtensionPermission = {
id: string;
permissionKind: string;
permissionValue: string;
required: boolean;
decision: string;
};
export type ExtensionLog = {
id: string;
extensionPackageId?: string | null;
extensionId?: string | null;
level: string;
eventCode?: string | null;
message?: string | null;
summary: string;
createdAt: string;
};
const revisionBody = (expectedRevision: number) => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expectedRevision }),
});
export const extensions = {
registries: () => json<ExtensionRegistry[]>("/api/admin/extensions/registries"),
addRegistry: (name: string, registryUrl: string) =>
json<ExtensionRegistry>("/api/admin/extensions/registries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, registryUrl, enabled: true }),
}),
setRegistryEnabled: (item: ExtensionRegistry, enabled: boolean) =>
json<ExtensionRegistry>(`/api/admin/extensions/registries/${item.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, expectedRevision: item.revision }),
}),
removeRegistry: (item: ExtensionRegistry) =>
json<void>(`/api/admin/extensions/registries/${item.id}?expectedRevision=${item.revision}`, {
method: "DELETE",
}),
packages: () => json<ExtensionPackage[]>("/api/admin/extensions/packages"),
store: () => json<{ items: ExtensionStoreItem[]; errors: Array<{ repository: string; message: string }> }>("/api/admin/extensions/store"),
logs: () => json<ExtensionLog[]>("/api/admin/extensions/logs?limit=100"),
install: (item: Pick<ExtensionStoreItem, "id" | "downloadUrl" | "sha256" | "registryId">) =>
json<{ packageId: string; message: string }>("/api/admin/extensions/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item),
}),
permissions: (id: string) =>
json<ExtensionPermission[]>(`/api/admin/extensions/packages/${id}/permissions`),
review: (item: ExtensionPackage, decisions: Array<{ kind: string; value: string; approved: boolean }>) =>
json<ExtensionPackage>(`/api/admin/extensions/packages/${item.id}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expectedRevision: item.revision, decisions }),
}),
activate: (item: ExtensionPackage) =>
json<ExtensionPackage>(`/api/admin/extensions/packages/${item.id}/activate`, revisionBody(item.revision)),
disable: (item: ExtensionPackage) =>
json<void>(`/api/admin/extensions/packages/${item.id}/disable`, revisionBody(item.revision)),
rollback: (item: ExtensionPackage) =>
json<ExtensionPackage>(`/api/admin/extensions/packages/${item.id}/rollback`, revisionBody(item.revision)),
revokePermissions: (item: ExtensionPackage) =>
json<ExtensionPackage>(`/api/admin/extensions/packages/${item.id}/permissions/revoke`, revisionBody(item.revision)),
cancelStaging: (item: ExtensionPackage) =>
json<ExtensionPackage>(`/api/admin/extensions/packages/${item.id}/staging/cancel`, revisionBody(item.revision)),
uninstall: (item: ExtensionPackage) =>
json<ExtensionPackage>(`/api/admin/extensions/packages/${item.id}`, {
...revisionBody(item.revision),
method: "DELETE",
}),
};
export const eventLog = {
list: (params: { limit?: number; before?: string; beforeId?: string } = {}) => {
const query = new URLSearchParams({ limit: String(params.limit ?? 50) });
if (params.before) query.set("before", params.before);
if (params.beforeId) query.set("beforeId", params.beforeId);
return json<ActivityResponse>(`/api/admin/ui/activity?${query}`);
},
};
export const downloads = {
list: (storage: "cache" | "kept") =>
json<DownloadsResponse>(`/api/admin/downloads?storage=${storage}`),
keep: (path: string) =>
json<{ success: boolean }>(
`/api/admin/downloads/promote?path=${encodeURIComponent(path)}`,
{ method: "POST" },
),
remove: (path: string, storage: "cache" | "kept") =>
json<{ success: boolean }>(
`/api/admin/downloads?path=${encodeURIComponent(path)}&storage=${storage}`,
{ method: "DELETE" },
),
removeAll: (storage: "cache" | "kept") =>
json<{ success: boolean; deletedCount: number; skippedUnknown: number; skippedReferenced: number }>(
`/api/admin/downloads/all?storage=${storage}`,
{ method: "DELETE" },
),
fileUrl: (path: string, storage: "cache" | "kept") =>
`/api/admin/downloads/file?path=${encodeURIComponent(path)}&storage=${storage}`,
};
export const playlistLinks = {
list: () => json<{ playlistLinks: PlaylistLink[] }>("/api/admin/playlist-links"),
details: (id: string, projectionMode?: "resolved" | "source" | "target") =>
json<PlaylistDetails>(`/api/admin/playlist-links/${encodeURIComponent(id)}${projectionMode ? `?projectionMode=${projectionMode}` : ""}`),
refresh: (id: string) =>
json(`/api/admin/playlist-links/${encodeURIComponent(id)}/refresh`, { method: "POST" }),
run: (id: string, snapshotId?: string) =>
json<{ jobId: string; created: boolean }>(`/api/admin/playlist-links/${encodeURIComponent(id)}/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(snapshotId ? { snapshotId } : {}),
}),
previewRematch: () =>
json<PlaylistRematchPreview>("/api/admin/playlist-links/rematch/preview"),
applyRematch: (confirmationId: string) =>
json<{ jobId: string; created: boolean }>("/api/admin/playlist-links/rematch/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmationId }),
}),
previewSourceUpdate: (id: string) =>
json<PlaylistSourceUpdatePreview>(
`/api/admin/playlist-links/${encodeURIComponent(id)}/source-update/preview`,
),
applySourceUpdate: (id: string, expectedRevision: number, confirmationId: string) =>
json<{ jobId: string; created: boolean }>(
`/api/admin/playlist-links/${encodeURIComponent(id)}/source-update/apply`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expectedRevision, confirmationId }),
},
),
setEnabled: (id: string, expectedRevision: number, enabled: boolean) =>
json<{ id: string; enabled: boolean; revision: number }>(
`/api/admin/playlist-links/${encodeURIComponent(id)}/state`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expectedRevision, enabled }),
},
),
sources: () => json<{
accounts: PlaylistSourceAccount[];
blockedAccounts: PlaylistSourceAccount[];
providers: Array<{ id: string; displayName: string }>;
}>("/api/admin/playlist-sources"),
sourcePlaylists: (accountId: string, query = "", cursor = "") => {
const params = new URLSearchParams({ limit: "100" });
if (query) params.set("query", query);
if (cursor) params.set("cursor", cursor);
return json<{ items: PlaylistDiscoveryItem[]; nextCursor?: string | null }>(
`/api/admin/playlist-sources/${encodeURIComponent(accountId)}/playlists?${params}`,
);
},
targets: () => json<{ targets: MediaTarget[] }>("/api/admin/media-targets"),
saveBackendCredential: (target: MediaTarget, username: string, password: string) =>
json<{ referenceId: string; activeVersion: number; updatedAt: string }>(
target.credentialReferenceId
? `/api/admin/playlist-links/backend-credentials/${encodeURIComponent(target.credentialReferenceId)}`
: "/api/admin/playlist-links/backend-credentials",
{
method: target.credentialReferenceId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetProtocol: target.protocol,
backendInstanceId: target.backendInstanceId,
username,
password,
}),
},
),
enqueueLibraryIndex: (target: MediaTarget, credentialReferenceId: string) =>
json<{ jobId: string; created: boolean; generation: number }>("/api/admin/library-index/enqueue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
libraryScopeId: target.libraryScopeId || "music",
credentialReferenceId,
pageSize: 200,
}),
}),
targetPlaylists: (targetId: string, query = "", cursor = "") => {
const params = new URLSearchParams({ limit: "100" });
if (query) params.set("query", query);
if (cursor) params.set("cursor", cursor);
return json<{ items: TargetPlaylist[]; nextCursor?: string | null }>(
`/api/admin/media-targets/${encodeURIComponent(targetId)}/playlists?${params}`,
);
},
create: (input: {
providerAccountId: string;
sourceProviderId: string;
sourcePlaylistId: string;
libraryScopeId: string;
targetProtocol: string;
targetBackendInstanceId: string;
targetCredentialReferenceId?: string | null;
targetPlaylistId?: string | null;
mode: "virtual" | "materialized" | "hybrid";
projectionMode?: "resolved" | "source" | "target";
materializationMode: "reconcile" | "recreate";
importMode: "oneTime" | "linked";
trackRetention: "onDemand" | "keepAll";
mirrorStaleEntries: boolean;
preserveManualEntries: boolean;
syncName: boolean;
syncDescription: boolean;
syncArtwork: boolean;
}) => json<{ id: string; initialJobId?: string | null }>("/api/admin/playlist-links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
update: (id: string, input: {
expectedRevision: number;
mode: "virtual" | "materialized" | "hybrid";
projectionMode: "resolved" | "source" | "target";
materializationMode: "reconcile" | "recreate";
importMode?: "oneTime" | "linked";
trackRetention?: "onDemand" | "keepAll";
scheduleId?: string | null;
targetPlaylistId?: string | null;
targetCredentialReferenceId?: string | null;
mirrorStaleEntries: boolean;
preserveManualEntries: boolean;
syncName: boolean;
syncDescription: boolean;
syncArtwork: boolean;
ruleVersion?: string;
policyVersion?: string;
}) => json<PlaylistLink & { retentionQueued?: number | null }>(`/api/admin/playlist-links/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
createSchedule: (id: string, input: {
cronExpression: string;
timeZoneId: string;
overlapPolicy: "skip" | "queue";
misfirePolicy: "skip" | "runOnce";
enabled?: boolean;
}) => json<PlaylistSchedule>(`/api/admin/playlist-links/${encodeURIComponent(id)}/schedules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
updateSchedule: (schedule: PlaylistSchedule, input: {
cronExpression: string;
timeZoneId: string;
enabled: boolean;
}) => json<PlaylistSchedule>(
`/api/admin/playlist-links/schedules/${encodeURIComponent(schedule.id)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...input,
overlapPolicy: schedule.overlapPolicy,
misfirePolicy: schedule.misfirePolicy,
expectedRevision: schedule.revision,
}),
},
),
};
export const matchReview = {
list: (params: {
page?: number;
pageSize?: number;
search?: string;
state?: string;
sort?: string;
libraryScopeId?: string;
externalSnapshotId?: string;
}) => {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") query.set(key, String(value));
}
return json<MatchReviewResponse>(`/api/admin/track-matches?${query}`);
},
get: async (externalSnapshotId: string) =>
(await matchReview.list({ externalSnapshotId, pageSize: 1 })).matches[0] ?? null,
searchLocal: (query: string, libraryScopeId: string, externalSnapshotId: string) =>
json<{ tracks: MatchTarget[] }>(
`/api/admin/track-matches/targets/local?query=${encodeURIComponent(query)}&libraryScopeId=${encodeURIComponent(libraryScopeId)}&externalSnapshotId=${encodeURIComponent(externalSnapshotId)}`,
),
searchProviders: (query: string, libraryScopeId: string, externalSnapshotId: string) => {
const params = new URLSearchParams({ query, libraryScopeId, externalSnapshotId, limit: "50" });
return json<{ tracks: MatchTarget[]; providers: string[] }>(
`/api/admin/track-matches/targets/provider?${params}`,
);
},
resolve: (
externalSnapshotId: string,
target:
| { targetType: "local"; libraryTrackId: string; reason: string }
| { targetType: "provider"; externalProvider: string; externalId: string; reason: string }
| { targetType: "reject"; reason: string },
) =>
json<{ success: boolean }>(
`/api/admin/track-matches/${encodeURIComponent(externalSnapshotId)}/resolve`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(target),
},
),
rematch: (externalSnapshotId: string) =>
json<{ rematched: boolean; state: string }>(
`/api/admin/track-matches/${encodeURIComponent(externalSnapshotId)}/rematch`,
{ method: "POST" },
),
};
export const appleDownload = {
status: () => json<AppleDownloadStatus>("/api/admin/apple-download/status"),
setup: (file: File) => {
const body = new FormData();
body.append("file", file, file.name);
return json<{ message?: string; fileName?: string; sizeBytes?: number }>(
"/api/admin/apple-download/setup",
{ method: "POST", body },
);
},
login: (username: string, password: string) =>
json<AppleDownloadStatus>("/api/admin/apple-download/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
}),
submit2fa: (code: string) =>
json<AppleDownloadStatus>("/api/admin/apple-download/login/2fa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
}),
};