/** * 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(path: string, init: RequestInit = {}): Promise { 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('/system/status') } qualityProfiles() { return this.req('/qualityprofile') } languageProfiles() { return this.req('/languageprofile') } rootFolders() { return this.req('/rootfolder') } tags() { return this.req('/tag') } series() { return this.req('/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( `/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 { const body = buildAddSonarrSeriesBody(payload) return this.req('/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(`/series/${series.id}`, { method: 'PUT', body: JSON.stringify(series), }) } removeSeries(id: number, deleteFiles = false) { return this.req( `/series/${id}?deleteFiles=${deleteFiles ? 'true' : 'false'}&addImportListExclusion=false`, { method: 'DELETE' }, ) } searchSeries(id: number) { return this.req('/command', { method: 'POST', body: JSON.stringify({ name: 'SeriesSearch', seriesId: id }), }) } searchSeason(seriesId: number, seasonNumber: number) { return this.req('/command', { method: 'POST', body: JSON.stringify({ name: 'SeasonSearch', seriesId, seasonNumber }), }) } } export function sonarrClient(instance: ArrInstance) { return new SonarrClient(instance) }