240 lines
6.4 KiB
TypeScript
240 lines
6.4 KiB
TypeScript
/**
|
|
* 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)
|
|
}
|