api clients for jellyfin, tmdb, fanart, etc.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user