api clients for jellyfin, tmdb, fanart, etc.

This commit is contained in:
2026-03-23 13:32:13 +02:00
parent d65da148d4
commit 292b3f42cf
14 changed files with 2903 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
/**
* Thin Sonarr v3 API client. Same shape as the Radarr client - same
* auth, same null-on-failure convention - but the data model is
* series + seasons + episodes rather than movies, and Sonarr keys off
* TVDB ids natively (TMDB ids are accepted via `lookup?term=tmdb:N`).
*/
import type { ArrInstance } from '../stores/arr-instances-store'
export interface SonarrRootFolder {
id: number
path: string
freeSpace?: number
}
export interface SonarrQualityProfile {
id: number
name: string
}
export interface SonarrLanguageProfile {
id: number
name: string
}
export interface SonarrTag {
id: number
label: string
}
export interface SonarrSeason {
seasonNumber: number
monitored: boolean
statistics?: {
episodeCount: number
episodeFileCount: number
totalEpisodeCount: number
sizeOnDisk: number
percentOfEpisodes: number
}
}
export interface SonarrSeries {
id?: number
title: string
titleSlug?: string
year?: number
tvdbId?: number
tmdbId?: number
imdbId?: string | null
status?: 'continuing' | 'ended' | 'upcoming' | 'deleted'
monitored?: boolean
seasons?: SonarrSeason[]
qualityProfileId?: number
languageProfileId?: number
rootFolderPath?: string
tags?: number[]
added?: string
images?: { coverType: string; remoteUrl?: string; url?: string }[]
}
export interface SonarrLookupResult extends SonarrSeries {
folder?: string
}
export interface SonarrQueueItem {
id: number
seriesId?: number
episodeId?: number
seasonNumber?: number
title: string
status: string
trackedDownloadStatus?: string
size?: number
sizeleft?: number
timeleft?: string
}
export interface SonarrSystemStatus {
version: string
startTime?: string
branch?: string
isProduction?: boolean
}
export interface AddSonarrSeriesPayload {
tvdbId: number
qualityProfileId: number
languageProfileId?: number
rootFolderPath: string
monitored?: boolean
seasonFolder?: boolean
searchOnAdd?: boolean
tags?: number[]
title: string
titleSlug: string
year: number
images?: { coverType: string; remoteUrl?: string; url?: string }[]
/** Per-season monitor flags so the user can pick which seasons to fetch. */
seasons: { seasonNumber: number; monitored: boolean }[]
}
export function buildAddSonarrSeriesBody(payload: AddSonarrSeriesPayload) {
const body: {
tvdbId: number
qualityProfileId: number
rootFolderPath: string
monitored: boolean
seasonFolder: boolean
title: string
titleSlug: string
year: number
images: { coverType: string; remoteUrl?: string; url?: string }[]
tags: number[]
seasons?: SonarrSeason[]
languageProfileId?: number
addOptions: {
searchForMissingEpisodes: boolean
searchForCutoffUnmetEpisodes: boolean
monitor: 'all'
}
} = {
tvdbId: payload.tvdbId,
qualityProfileId: payload.qualityProfileId,
rootFolderPath: payload.rootFolderPath,
monitored: payload.monitored ?? true,
seasonFolder: payload.seasonFolder ?? true,
title: payload.title,
titleSlug: payload.titleSlug,
year: payload.year,
images: payload.images || [],
tags: payload.tags || [],
seasons: payload.seasons,
addOptions: {
searchForMissingEpisodes: payload.searchOnAdd ?? true,
searchForCutoffUnmetEpisodes: false,
monitor: 'all',
},
}
if (payload.languageProfileId != null) body.languageProfileId = payload.languageProfileId
return body
}
class SonarrClient {
instance: ArrInstance
constructor(instance: ArrInstance) {
this.instance = instance
}
private url(path: string): string {
const base = this.instance.baseUrl.replace(/\/+$/, '')
return `${base}/api/v3${path}`
}
private async req<T>(path: string, init: RequestInit = {}): Promise<T | null> {
try {
const res = await fetch(this.url(path), {
...init,
headers: {
'X-Api-Key': this.instance.apiKey,
'Content-Type': 'application/json',
...(init.headers || {}),
},
})
if (!res.ok) return null
const text = await res.text()
return text ? (JSON.parse(text) as T) : (null as unknown as T)
} catch {
return null
}
}
systemStatus() { return this.req<SonarrSystemStatus>('/system/status') }
qualityProfiles() { return this.req<SonarrQualityProfile[]>('/qualityprofile') }
languageProfiles() { return this.req<SonarrLanguageProfile[]>('/languageprofile') }
rootFolders() { return this.req<SonarrRootFolder[]>('/rootfolder') }
tags() { return this.req<SonarrTag[]>('/tag') }
series() { return this.req<SonarrSeries[]>('/series') }
queue() { return this.req<{ records: SonarrQueueItem[] }>('/queue') }
/**
* Series lookup by TVDB id (preferred), IMDB id (`imdb:tt...`), or
* TMDB id (`tmdb:N`). Sonarr accepts the term-prefix syntax for the
* latter two.
*/
lookup(term: string) {
return this.req<SonarrLookupResult[]>(
`/series/lookup?term=${encodeURIComponent(term)}`,
)
}
lookupByTvdbId(tvdbId: number) { return this.lookup(`tvdb:${tvdbId}`) }
lookupByImdbId(imdbId: string) { return this.lookup(`imdb:${imdbId}`) }
lookupByTmdbId(tmdbId: number) { return this.lookup(`tmdb:${tmdbId}`) }
async addSeries(payload: AddSonarrSeriesPayload): Promise<SonarrSeries | null> {
const body = buildAddSonarrSeriesBody(payload)
return this.req<SonarrSeries>('/series', {
method: 'POST',
body: JSON.stringify(body),
})
}
/**
* Update an existing series - used when the user changes which seasons
* to monitor or swaps quality profile.
*/
updateSeries(series: SonarrSeries) {
if (!series.id) return Promise.resolve(null)
return this.req<SonarrSeries>(`/series/${series.id}`, {
method: 'PUT',
body: JSON.stringify(series),
})
}
removeSeries(id: number, deleteFiles = false) {
return this.req<void>(
`/series/${id}?deleteFiles=${deleteFiles ? 'true' : 'false'}&addImportListExclusion=false`,
{ method: 'DELETE' },
)
}
searchSeries(id: number) {
return this.req<void>('/command', {
method: 'POST',
body: JSON.stringify({ name: 'SeriesSearch', seriesId: id }),
})
}
searchSeason(seriesId: number, seasonNumber: number) {
return this.req<void>('/command', {
method: 'POST',
body: JSON.stringify({ name: 'SeasonSearch', seriesId, seasonNumber }),
})
}
}
export function sonarrClient(instance: ArrInstance) {
return new SonarrClient(instance)
}