diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 510616d3..7d3fe864 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,6 @@ import java.util.Properties -val appVersion = "1.0.0" +val appVersion = "1.0.1" // Release signing: `keystore.properties` (not committed) points at the keystore; without it the release build is left unsigned. val keystoreProps = Properties().apply { @@ -23,7 +23,7 @@ android { applicationId = "com.brebond.tipsarr" minSdk = 26 targetSdk = 36 - versionCode = 10 + versionCode = 11 versionName = appVersion buildConfigField("String", "APP_VERSION", "\"$appVersion\"") } diff --git a/android/app/src/main/java/com/brebond/tipsarr/core/api/DetailModels.kt b/android/app/src/main/java/com/brebond/tipsarr/core/api/DetailModels.kt index 90f4f6ea..49d84b53 100644 --- a/android/app/src/main/java/com/brebond/tipsarr/core/api/DetailModels.kt +++ b/android/app/src/main/java/com/brebond/tipsarr/core/api/DetailModels.kt @@ -43,6 +43,10 @@ data class MediaDetail( val monitored: Boolean = false, val seasons: List? = null, val numberOfSeasons: Int? = null, + /** Whose season numbers `seasons` uses: "tvdb" (Sonarr) or "tmdb"; older servers send none. */ + val seasonNumbering: String? = null, + /** True when TVDB splits the show differently from TMDB (the detail shows a note). */ + val seasonNumberingDiffers: Boolean = false, val studios: List? = null, val cast: List = emptyList(), val recommendations: List = emptyList(), @@ -83,7 +87,7 @@ data class RequestOptions( ) @Serializable -data class CreateRequestBody(val type: MediaType, val tmdbId: Int, val seasons: List? = null, val qualityProfileId: Int? = null, val rootFolder: String? = null) +data class CreateRequestBody(val type: MediaType, val tmdbId: Int, val seasons: List? = null, val qualityProfileId: Int? = null, val rootFolder: String? = null, val seasonNumbering: String? = null) @Serializable data class UserRef(val id: String = "", val name: String = "") @@ -115,6 +119,7 @@ data class RequestView( val createdAt: Long = 0, val qualityProfileId: Int? = null, val rootFolder: String? = null, + val seasonNumbering: String? = null, /** Set when the request was imported from what Radarr/Sonarr already monitor. */ val source: String? = null, /** What happened to the request and when, oldest first; a server older than 0.11 sends none. */ diff --git a/android/app/src/main/java/com/brebond/tipsarr/core/api/TipsarrApi+Media.kt b/android/app/src/main/java/com/brebond/tipsarr/core/api/TipsarrApi+Media.kt index 65bc897f..6f793bf3 100644 --- a/android/app/src/main/java/com/brebond/tipsarr/core/api/TipsarrApi+Media.kt +++ b/android/app/src/main/java/com/brebond/tipsarr/core/api/TipsarrApi+Media.kt @@ -19,8 +19,8 @@ suspend fun TipsarrApi.flags(type: MediaType, id: Int): TitleFlags = get("/media suspend fun TipsarrApi.requestOptions(type: MediaType): RequestOptions? = try { get("/requests/options?type=${type.path()}", RequestOptions.serializer()) } catch (e: ApiError) { null } -suspend fun TipsarrApi.createRequest(type: MediaType, tmdbId: Int, seasons: List?, profileId: Int?, folder: String?): RequestRecord = - post("/requests", CreateRequestBody(type, tmdbId, seasons, profileId, folder), CreateRequestBody.serializer(), RequestView.serializer()).toRecord() +suspend fun TipsarrApi.createRequest(type: MediaType, tmdbId: Int, seasons: List?, profileId: Int?, folder: String?, seasonNumbering: String? = null): RequestRecord = + post("/requests", CreateRequestBody(type, tmdbId, seasons, profileId, folder, seasonNumbering), CreateRequestBody.serializer(), RequestView.serializer()).toRecord() suspend fun TipsarrApi.myRequests(take: Int = 100): List = get("/requests?filter=mine&take=$take", RequestList.serializer()).items.map { it.toRecord() } diff --git a/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailModel.kt b/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailModel.kt index 1588da83..2de6971c 100644 --- a/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailModel.kt +++ b/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailModel.kt @@ -177,7 +177,7 @@ class MediaDetailModel( suspend fun submitRequest(seasons: List?, profileId: Int?, folder: String?): RequestRecord { busy = true try { - val record = api.createRequest(route.type, route.tmdbId, seasons, profileId, folder) + val record = api.createRequest(route.type, route.tmdbId, seasons, profileId, folder, detail?.seasonNumbering?.takeIf { route.type == MediaType.Tv }) request = record coveredSeasons = coveredSeasons + record.seasons detail = detail?.copy(requestStatus = com.brebond.tipsarr.core.api.RequestStatus.Pending) diff --git a/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailScreen.kt b/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailScreen.kt index d1ae55f4..fa5d3546 100644 --- a/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailScreen.kt +++ b/android/app/src/main/java/com/brebond/tipsarr/features/detail/MediaDetailScreen.kt @@ -198,7 +198,7 @@ fun MediaDetailScreen(model: MediaDetailModel, serverUrl: String, onBack: () -> Banners(model) Overview(detail) if (detail.type == MediaType.Tv && model.regularSeasons.isNotEmpty()) { - SeasonList(model.api, model.route.tmdbId, model.regularSeasons, model.coveredSeasons, model.request?.state ?: RequestState.Requested) + SeasonList(model.api, model.route.tmdbId, model.regularSeasons, model.coveredSeasons, model.request?.state ?: RequestState.Requested, model.detail?.seasonNumberingDiffers == true) } Facts(detail) if (detail.cast.isNotEmpty()) Cast(detail) { onOpenPerson(com.brebond.tipsarr.features.person.PersonRoute(it.id, it.name)) } diff --git a/android/app/src/main/java/com/brebond/tipsarr/features/detail/RequestSheet.kt b/android/app/src/main/java/com/brebond/tipsarr/features/detail/RequestSheet.kt index c3c32956..22191dcc 100644 --- a/android/app/src/main/java/com/brebond/tipsarr/features/detail/RequestSheet.kt +++ b/android/app/src/main/java/com/brebond/tipsarr/features/detail/RequestSheet.kt @@ -124,6 +124,7 @@ fun RequestSheet(model: MediaDetailModel, onDismiss: () -> Unit, onDone: (Reques Text(stringResource(if (allSelected) R.string.req_select_none else R.string.req_select_all)) } } + if (detail?.seasonNumberingDiffers == true) Text(stringResource(R.string.seasons_numbering_note), fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) model.regularSeasons.forEach { season -> val inLibrary = season.isComplete val tracked = season.monitored && !inLibrary @@ -142,7 +143,7 @@ fun RequestSheet(model: MediaDetailModel, onDismiss: () -> Unit, onDone: (Reques ) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(season.name, fontSize = Tokens.FontSize.body, fontWeight = FontWeight.SemiBold, color = Tokens.palette.fg) - Text(context.plural("seasons_episodes", season.episodeCount), fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) + if (season.episodeCount > 0) Text(context.plural("seasons_episodes", season.episodeCount), fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) } season.badges.forEach { StatusBadge(it) } if (covered && !inLibrary && !tracked) StatusBadge(model.request?.state ?: com.brebond.tipsarr.ui.RequestState.Requested) diff --git a/android/app/src/main/java/com/brebond/tipsarr/features/detail/SeasonList.kt b/android/app/src/main/java/com/brebond/tipsarr/features/detail/SeasonList.kt index c8c8791b..c67ccb01 100644 --- a/android/app/src/main/java/com/brebond/tipsarr/features/detail/SeasonList.kt +++ b/android/app/src/main/java/com/brebond/tipsarr/features/detail/SeasonList.kt @@ -59,7 +59,7 @@ import java.util.Locale /** Seasons of a show; a tap opens the episodes of that season (loaded on first open), as on the web. */ @Composable -fun SeasonList(api: TipsarrApi, tmdbId: Int, seasons: List, covered: Set, state: RequestState) { +fun SeasonList(api: TipsarrApi, tmdbId: Int, seasons: List, covered: Set, state: RequestState, numberingDiffers: Boolean = false) { val context = LocalContext.current var open by remember { mutableStateOf(null) } val episodes = remember { mutableStateMapOf>() } @@ -84,6 +84,7 @@ fun SeasonList(api: TipsarrApi, tmdbId: Int, seasons: List, covered: stringResource(R.string.seasons_title), fontSize = Tokens.FontSize.title3, fontWeight = FontWeight.Bold, color = Tokens.palette.fg, modifier = Modifier.semantics { heading() }, ) + if (numberingDiffers) Text(stringResource(R.string.seasons_numbering_note), fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) seasons.forEach { season -> val shape = RoundedCornerShape(Tokens.Radius.md) Column(Modifier.fillMaxWidth().clip(shape).background(Tokens.palette.card).border(BorderStroke(1.dp, Tokens.palette.border), shape).animateContentSize()) { @@ -93,9 +94,11 @@ fun SeasonList(api: TipsarrApi, tmdbId: Int, seasons: List, covered: ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(season.name, fontSize = Tokens.FontSize.body, fontWeight = FontWeight.SemiBold, color = Tokens.palette.fg, maxLines = 1, overflow = TextOverflow.Ellipsis) - val count = context.plural("seasons_episodes", season.episodeCount) + // 0 means the server does not know the count yet: show the year alone. + val count = season.episodeCount.takeIf { it > 0 }?.let { context.plural("seasons_episodes", it) } val year = season.airDate?.takeIf { it.length >= 4 }?.take(4) - Text(if (year != null) "$count · $year" else count, fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) + val line = listOfNotNull(count, year).joinToString(" · ") + if (line.isNotEmpty()) Text(line, fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) } season.badges.forEach { StatusBadge(it) } // The request's own state, unless the library already says the same (an available season showed "Available" twice). @@ -114,6 +117,7 @@ fun SeasonList(api: TipsarrApi, tmdbId: Int, seasons: List, covered: when { loading == season.number -> Text(stringResource(R.string.seasons_loading), fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) message != null -> Text(message, fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) + episodes[season.number]?.isEmpty() == true -> Text(stringResource(R.string.seasons_no_episodes), fontSize = Tokens.FontSize.footnote, color = Tokens.palette.mutedFg) else -> episodes[season.number].orEmpty().forEach { EpisodeRow(it) } } } diff --git a/android/app/src/main/openapi/openapi.yaml b/android/app/src/main/openapi/openapi.yaml index f2926153..46b4bf6e 100644 --- a/android/app/src/main/openapi/openapi.yaml +++ b/android/app/src/main/openapi/openapi.yaml @@ -302,6 +302,12 @@ components: rootFolder: description: Root folder to use (omit for the default) type: string + seasonNumbering: + description: "TV only: the detail's seasonNumbering the seasons were picked in; a different one than the server's now is 409 season_numbering_changed" + enum: + - tvdb + - tmdb + type: string seasons: description: TV only; empty means every season items: @@ -466,6 +472,15 @@ components: runtimeMinutes: format: int64 type: integer + seasonNumbering: + description: "Numbering of seasons: tvdb (Sonarr) or tmdb (fallback when Sonarr cannot tell)" + enum: + - tvdb + - tmdb + type: string + seasonNumberingDiffers: + description: TMDB splits this show's seasons differently from Sonarr (TVDB) + type: boolean seasons: items: $ref: "#/components/schemas/Season" @@ -1529,7 +1544,7 @@ components: minimum: 0 type: integer tvQuotaLimit: - description: Seasons of shows allowed per period, 0 = no limit + description: Seasons of shows allowed per period (one request costs at most this), 0 = no limit format: int64 maximum: 100000 minimum: 0 @@ -2117,6 +2132,7 @@ components: airDate: type: string episodeCount: + description: Episodes in the season; 0 = unknown format: int64 type: integer libraryEpisodes: @@ -3202,6 +3218,12 @@ components: rootFolder: description: Chosen root folder (absent = the instance default) type: string + seasonNumbering: + description: "Shows only: numbering of seasons, tvdb (Sonarr) or tmdb (made while Sonarr could not tell; translated when it is sent)" + enum: + - tvdb + - tmdb + type: string seasonProgress: description: "Shows only: completion per season" items: @@ -7373,6 +7395,7 @@ paths: - media /media/tv/{id}/seasons/{season}: get: + description: "`season` follows the numbering of the show's detail (`seasonNumbering`): Sonarr's (TVDB) when it can tell. A season no source can list yet has no episodes." operationId: tvSeason parameters: - in: path diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index fee2366c..5f4f3a76 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -148,6 +148,7 @@ Tu as atteint ta limite de demandes pour le moment. Actualisé à l’instant, réessaie dans une minute. Saisis à nouveau la clé API quand tu changes l’adresse. + Les saisons de cette série ont changé. Recharge la page et choisis-les à nouveau. La configuration est déjà terminée. Jeton d’installation requis : consulte le journal du serveur Tipsarr. Impossible de joindre un serveur Jellyfin à cette adresse. @@ -315,11 +316,13 @@ Connecte-toi à ton serveur Impossible de joindre ce serveur. Vérifie l’adresse et ta connexion. Refusé par un admin. + La liste des épisodes apparaîtra quand Sonarr suivra cette série. Aucun résumé disponible. Ouvrir dans Jellyfin Seules les notes TMDB existent pour les séries. Redemander %1$s saison | %1$s saisons + Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB. Partager Pour toi Films @@ -708,6 +711,7 @@ Réessayer Dossier Saison %1$s + Saisons %1$s à %2$s Saison %1$s | Saisons %1$s Tout sélectionner Tout désélectionner @@ -772,7 +776,9 @@ Mots-clés : %1$s épisode | %1$s épisodes Chargement des épisodes… + La liste des épisodes apparaîtra quand Sonarr suivra cette série. Pas d’aperçu + Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB. Saison %1$s Saisons %1$s classements enregistrés @@ -796,6 +802,8 @@ %1$s fichiers prêts, %2$s en attente, %3$s en échec yt-dlp est à jour (%1$s) yt-dlp mis à jour de %1$s vers %2$s + Saison %1$s + Épisodes spéciaux Approuvé Disponible Refusé diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 75a2a335..385834ca 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -148,6 +148,7 @@ You have reached your request limit for now. Refreshed a moment ago, try again in a minute. Enter the API key again when you change the address. + The seasons of this show changed. Reload and pick them again. Setup has already been completed. Setup token required: see the Tipsarr server log. Cannot reach a Jellyfin server at that address. @@ -315,11 +316,13 @@ Connect to your server Can’t reach this server. Check the address and your connection. Declined by an admin. + The episode list appears once Sonarr tracks this show. No overview available. Open in Jellyfin Only TMDB scores exist for TV shows. Request again %1$s season | %1$s seasons + Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB. Share For you Movies @@ -708,6 +711,7 @@ Retry Folder Season %1$s + Seasons %1$s–%2$s Season %1$s | Seasons %1$s Select all Select none @@ -772,7 +776,9 @@ Tags: %1$s episode | %1$s episodes Loading episodes… + The episode list appears once Sonarr tracks this show. No preview + Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB. Season %1$s Seasons %1$s charts stored @@ -796,6 +802,8 @@ %1$s trailer files ready, %2$s waiting, %3$s failed yt-dlp is up to date (%1$s) yt-dlp updated from %1$s to %2$s + Season %1$s + Specials Approved Available Declined diff --git a/api/openapi.yaml b/api/openapi.yaml index f2926153..46b4bf6e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -302,6 +302,12 @@ components: rootFolder: description: Root folder to use (omit for the default) type: string + seasonNumbering: + description: "TV only: the detail's seasonNumbering the seasons were picked in; a different one than the server's now is 409 season_numbering_changed" + enum: + - tvdb + - tmdb + type: string seasons: description: TV only; empty means every season items: @@ -466,6 +472,15 @@ components: runtimeMinutes: format: int64 type: integer + seasonNumbering: + description: "Numbering of seasons: tvdb (Sonarr) or tmdb (fallback when Sonarr cannot tell)" + enum: + - tvdb + - tmdb + type: string + seasonNumberingDiffers: + description: TMDB splits this show's seasons differently from Sonarr (TVDB) + type: boolean seasons: items: $ref: "#/components/schemas/Season" @@ -1529,7 +1544,7 @@ components: minimum: 0 type: integer tvQuotaLimit: - description: Seasons of shows allowed per period, 0 = no limit + description: Seasons of shows allowed per period (one request costs at most this), 0 = no limit format: int64 maximum: 100000 minimum: 0 @@ -2117,6 +2132,7 @@ components: airDate: type: string episodeCount: + description: Episodes in the season; 0 = unknown format: int64 type: integer libraryEpisodes: @@ -3202,6 +3218,12 @@ components: rootFolder: description: Chosen root folder (absent = the instance default) type: string + seasonNumbering: + description: "Shows only: numbering of seasons, tvdb (Sonarr) or tmdb (made while Sonarr could not tell; translated when it is sent)" + enum: + - tvdb + - tmdb + type: string seasonProgress: description: "Shows only: completion per season" items: @@ -7373,6 +7395,7 @@ paths: - media /media/tv/{id}/seasons/{season}: get: + description: "`season` follows the numbering of the show's detail (`seasonNumbering`): Sonarr's (TVDB) when it can tell. A season no source can list yet has no episodes." operationId: tvSeason parameters: - in: path diff --git a/backend/cmd/devenv/arr.go b/backend/cmd/devenv/arr.go index 177657a3..d2687189 100644 --- a/backend/cmd/devenv/arr.go +++ b/backend/cmd/devenv/arr.go @@ -56,6 +56,20 @@ func (e *arrEntry) frac(k int) float64 { return clamp((e.since() - arrWait.Seconds() - float64(k)*seasonSec) / seasonSec) } +// files is how many episodes of season s are downloaded (0 when the season is not monitored) +func (e *arrEntry) files(s int) int { + k := sort.SearchInts(e.seasons, s) + if k >= len(e.seasons) || e.seasons[k] != s { + return 0 + } + ec := tvdbEpisodeCount(e.tmdb, s) + f := e.frac(k) + if f >= 1 { + return ec + } + return int(f * float64(ec)) +} + func (a *arr) seriesJSON(e *arrEntry) obj { monitored := map[int]bool{} for _, s := range e.seasons { @@ -63,19 +77,14 @@ func (a *arr) seriesJSON(e *arrEntry) obj { } seasons := []obj{} totalEps, totalFiles := 0, 0 - for s := 0; s <= seasonCount(e.tmdb); s++ { - ec := episodeCount(e.tmdb, s) - files := 0 + for s := 0; s <= tvdbSeasonCount(e.tmdb); s++ { + total := tvdbEpisodeCount(e.tmdb, s) + ec, files := 0, e.files(s) if monitored[s] { - k := sort.SearchInts(e.seasons, s) - f := e.frac(k) - files = int(f * float64(ec)) - if f >= 1 { - files = ec - } + ec = total totalEps, totalFiles = totalEps+ec, totalFiles+files } - seasons = append(seasons, obj{"seasonNumber": s, "monitored": monitored[s], "statistics": obj{"episodeCount": ec, "episodeFileCount": files}}) + seasons = append(seasons, obj{"seasonNumber": s, "monitored": monitored[s], "statistics": obj{"episodeCount": ec, "episodeFileCount": files, "totalEpisodeCount": total}}) } return obj{"id": e.id, "title": e.title, "tvdbId": e.tvdb, "tmdbId": e.tmdb, "monitored": true, "year": year(e.tmdb), "seasons": seasons, "statistics": obj{"episodeCount": totalEps, "episodeFileCount": totalFiles}} @@ -97,7 +106,7 @@ func (a *arr) queue() []obj { if f <= 0 || f >= 1 { continue } - ec := episodeCount(e.tmdb, s) + ec := tvdbEpisodeCount(e.tmdb, s) cur := int(f * float64(ec)) for ep := cur; ep < ec; ep++ { rec := obj{"seriesId": e.id, "title": e.title, "size": 1000.0, "sizeleft": 1000.0, "timeleft": hhmmss((1 - f) * seasonSec), "status": "queued", "seasonNumber": s} @@ -230,11 +239,67 @@ func newArr(kind string) http.Handler { return } seasons := []obj{} - for s := 0; s <= seasonCount(tmdb); s++ { + for s := 0; s <= tvdbSeasonCount(tmdb); s++ { seasons = append(seasons, obj{"seasonNumber": s, "monitored": false}) } writeJSON(w, []obj{{"title": title(tmdb, "en"), "tvdbId": tvdb, "tmdbId": tmdb, "year": year(tmdb), "seasons": seasons, "seriesType": "standard"}}) }) + h("GET /api/v3/episode", func(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.Atoi(r.URL.Query().Get("seriesId")) + e, ok := a.items[id] + if !ok { + writeJSON(w, []obj{}) + return + } + only, filtered := 0, r.URL.Query().Get("seasonNumber") != "" + if filtered { + only, _ = strconv.Atoi(r.URL.Query().Get("seasonNumber")) + } + out := []obj{} + for s := 0; s <= tvdbSeasonCount(e.tmdb); s++ { + if filtered && s != only { + continue + } + k := sort.SearchInts(e.seasons, s) + monitored := k < len(e.seasons) && e.seasons[k] == s + files, first := e.files(s), tvdbFirstAired(e.tmdb, s) + for ep := 1; ep <= tvdbEpisodeCount(e.tmdb, s); ep++ { + out = append(out, obj{"id": id*10000 + s*100 + ep, "seriesId": id, "seasonNumber": s, "episodeNumber": ep, + "title": fmt.Sprintf("Chapter %d.%d", s, ep), "overview": overview(e.tmdb, "en"), "airDate": first.AddDate(0, 0, 7*(ep-1)).Format("2006-01-02"), + "runtime": 40 + e.tmdb%20, "hasFile": ep <= files, "monitored": monitored, "images": nil}) + } + } + writeJSON(w, out) + }) + // MonitorSeasons on a series Sonarr already has: PUT the series back with more seasons monitored, then search. + h("PUT /api/v3/series/{id}", func(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.Atoi(r.PathValue("id")) + e, ok := a.items[id] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + var b struct { + Seasons []struct { + SeasonNumber int `json:"seasonNumber"` + Monitored bool `json:"monitored"` + } `json:"seasons"` + } + _ = json.NewDecoder(r.Body).Decode(&b) + for _, s := range b.Seasons { + k := sort.SearchInts(e.seasons, s.SeasonNumber) + if s.Monitored && (k >= len(e.seasons) || e.seasons[k] != s.SeasonNumber) { + e.seasons = append(e.seasons, s.SeasonNumber) + sort.Ints(e.seasons) + } + } + w.WriteHeader(http.StatusAccepted) + writeJSON(w, a.seriesJSON(e)) + }) + h("POST /api/v3/command", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + writeJSON(w, obj{"id": 1, "name": "SeasonSearch", "status": "queued"}) + }) h("POST /api/v3/series", func(w http.ResponseWriter, r *http.Request) { var b struct { TVDBID int `json:"tvdbId"` diff --git a/backend/cmd/devenv/catalog.go b/backend/cmd/devenv/catalog.go index 7648c6ca..4a5b80cc 100644 --- a/backend/cmd/devenv/catalog.go +++ b/backend/cmd/devenv/catalog.go @@ -117,8 +117,69 @@ func vote(id int) float64 { return float64(50+(id*13)%45) / 10 } func runtime(id int) int { return 80 + (id*7)%80 } func popularity(id int) float64 { return float64((id*37)%100) + 0.5 } -func seasonCount(id int) int { return id%4 + 1 } -func episodeCount(id, season int) int { return 8 + (id+season)%5 } +// mismatchShow is numbered differently by TMDB and TVDB (like Bleach): TMDB has two seasons (30 + 12 episodes), +// TVDB (Sonarr, Jellyfin) splits the first one into four arcs, so TVDB S5 is TMDB S2. +const mismatchShow = 240 + +func seasonCount(id int) int { + if id == mismatchShow { + return 2 + } + return id%4 + 1 +} + +func episodeCount(id, season int) int { + if id == mismatchShow && season > 0 { + return []int{30, 12}[min(season, 2)-1] + } + return 8 + (id+season)%5 +} + +// tmdbSeasonAir is the TMDB air date of a season. +func tmdbSeasonAir(id, season int) time.Time { + return time.Date(year(id)+season-1, 1, 10, 0, 0, 0, 0, time.UTC) +} + +// tmdbSeasonName names TMDB seasons; mismatchShow's S2 has its own name, so its TVDB S5 shows it. +func tmdbSeasonName(id, season int) string { + if id == mismatchShow && season == 2 { + return "The Last War" + } + return fmt.Sprintf("Season %d", season) +} + +// tvdbSeasonEps is the TVDB (Sonarr) layout of a show: episode counts per season, index 0 = specials. +func tvdbSeasonEps(id int) []int { + if id == mismatchShow { + return []int{2, 8, 7, 8, 7, 12} + } + out := []int{2} + for s := 1; s <= seasonCount(id); s++ { + out = append(out, episodeCount(id, s)) + } + return out +} + +func tvdbSeasonCount(id int) int { return len(tvdbSeasonEps(id)) - 1 } + +func tvdbEpisodeCount(id, season int) int { + eps := tvdbSeasonEps(id) + if season < 0 || season >= len(eps) { + return 0 + } + return eps[season] +} + +// tvdbFirstAired is the first air date of a TVDB season; the last arc of mismatchShow matches TMDB S2. +func tvdbFirstAired(id, season int) time.Time { + if id == mismatchShow { + if season == 5 { + return tmdbSeasonAir(id, 2) + } + return tmdbSeasonAir(id, 1).AddDate(0, 0, 70*(season-1)) + } + return tmdbSeasonAir(id, season) +} func imdbID(id int) string { return fmt.Sprintf("tt%07d", 1000000+id*13) } func tvdbID(id int) int { return 90000 + id } diff --git a/backend/cmd/devenv/jellyfin.go b/backend/cmd/devenv/jellyfin.go index 8d9863dd..4794d3d6 100644 --- a/backend/cmd/devenv/jellyfin.go +++ b/backend/cmd/devenv/jellyfin.go @@ -36,7 +36,7 @@ func libShows() []int { // seasons a library show has: all of them, except the newest for every third show (those show as partial) func libSeasons(show int) []int { - n := seasonCount(show) + n := tvdbSeasonCount(show) // Jellyfin folders follow Sonarr (TVDB) numbering if show%3 == 0 && n > 1 { n-- } @@ -70,7 +70,7 @@ func libTitle(id int) fakejf.Title { t := fakejf.Title{TMDB: id, Name: jfName(id), Year: year(id), Genres: jfGenres(id), RuntimeMin: runtime(id), Rating: vote(id), AddedDaysAgo: 3 + (id*11)%70} if isShow(id) { t.Series, t.RuntimeMin, t.Seasons = true, 40+id%20, libSeasons(id) - t.Episodes = func(s int) int { return episodeCount(id, s) } + t.Episodes = func(s int) int { return tvdbEpisodeCount(id, s) } } return t } diff --git a/backend/cmd/devenv/tmdb.go b/backend/cmd/devenv/tmdb.go index 355abdd0..fe625b09 100644 --- a/backend/cmd/devenv/tmdb.go +++ b/backend/cmd/devenv/tmdb.go @@ -165,7 +165,7 @@ func detail(id int, lang string) obj { seasons := []obj{{"season_number": 0, "name": "Specials", "episode_count": 2}} total := 0 for s := 1; s <= n; s++ { - seasons = append(seasons, obj{"season_number": s, "name": "Season " + strconv.Itoa(s), "episode_count": episodeCount(id, s), "air_date": time.Date(year(id)+s-1, 1, 10, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), "poster_path": img("t", id)}) + seasons = append(seasons, obj{"season_number": s, "name": tmdbSeasonName(id, s), "episode_count": episodeCount(id, s), "air_date": tmdbSeasonAir(id, s).Format("2006-01-02"), "poster_path": img("t", id)}) total += episodeCount(id, s) } d["seasons"], d["number_of_seasons"], d["number_of_episodes"] = seasons, n, total @@ -383,7 +383,7 @@ func newTMDB() http.Handler { eps = append(eps, obj{"episode_number": e, "name": name, "overview": overview(id, lang), "air_date": time.Date(year(id)+n-1, 1, 10+e, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), "still_path": img("s", id), "runtime": 40 + (id+e)%20, "vote_average": vote(id + e)}) } - writeJSON(w, obj{"season_number": n, "name": "Season " + strconv.Itoa(n), "overview": overview(id, lang), "air_date": time.Date(year(id)+n-1, 1, 10, 0, 0, 0, 0, time.UTC).Format("2006-01-02"), + writeJSON(w, obj{"season_number": n, "name": tmdbSeasonName(id, n), "overview": overview(id, lang), "air_date": tmdbSeasonAir(id, n).Format("2006-01-02"), "poster_path": img("t", id), "episodes": eps}) })) mux.HandleFunc("/person/{id}", hit(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/cmd/tipsarr/main.go b/backend/cmd/tipsarr/main.go index bd7fe14b..504107a7 100644 --- a/backend/cmd/tipsarr/main.go +++ b/backend/cmd/tipsarr/main.go @@ -156,6 +156,7 @@ func run(cfg config.Config) error { jm.Register(jobs.Job{Name: "boxoffice-refresh", Every: 12 * time.Hour, InitialDelay: time.Minute, Run: box.Refresh}) reqSvc := requests.New(st, mediaSvc, hub, notifier, cfg.DryRun) reqSvc.SetLibrary(lib) + mediaSvc.SetSeasonSource(reqSvc.SeasonSource()) // TV seasons numbered like Sonarr (TVDB) issueSvc := issues.New(st, mediaSvc, hub, notifier) issueSvc.SetArr(reqSvc) jm.Register(jobs.Job{Name: "request-poll", Every: 15 * time.Second, InitialDelay: 20 * time.Second, Quiet: true, Run: func(ctx context.Context) (i18n.Msg, error) { diff --git a/backend/internal/api/discover.go b/backend/internal/api/discover.go index 919ca2e2..aa41dee5 100644 --- a/backend/internal/api/discover.go +++ b/backend/internal/api/discover.go @@ -208,6 +208,7 @@ func registerDiscover(api huma.API, d Deps) { if err != nil { return nil, err } + o.Seasons = true // TV seasons numbered like Sonarr (TVDB) r, err := d.Media.Detail(ctx, o, in.Type, in.ID) if err == nil { d.Requests.Annotate(ctx, r) // what Radarr/Sonarr already monitor @@ -217,7 +218,7 @@ func registerDiscover(api huma.API, d Deps) { huma.Register(api, huma.Operation{ OperationID: "tvSeason", Method: http.MethodGet, Path: "/media/tv/{id}/seasons/{season}", - Summary: "Episodes of a season", Tags: []string{"media"}, Security: sec, Errors: append(errs, http.StatusNotFound), + Summary: "Episodes of a season", Description: "`season` follows the numbering of the show's detail (`seasonNumbering`): Sonarr's (TVDB) when it can tell. A season no source can list yet has no episodes.", Tags: []string{"media"}, Security: sec, Errors: append(errs, http.StatusNotFound), }, func(ctx context.Context, in *struct { ID int `path:"id" minimum:"1"` Season int `path:"season" minimum:"0"` @@ -226,10 +227,7 @@ func registerDiscover(api huma.API, d Deps) { if err != nil { return nil, err } - r, err := d.Media.Season(ctx, o, in.ID, in.Season) - if err == nil { - d.Requests.AnnotateEpisodes(ctx, o, in.ID, r) // which episodes Sonarr already has - } + r, err := d.Requests.SeasonDetail(ctx, o, in.ID, in.Season) // season numbered like Sonarr, episodes marked return &struct{ Body *media.SeasonDetail }{r}, mediaErr(err) }) diff --git a/backend/internal/api/permissions.go b/backend/internal/api/permissions.go index 71b26d7f..ad4264d7 100644 --- a/backend/internal/api/permissions.go +++ b/backend/internal/api/permissions.go @@ -17,7 +17,7 @@ type permissionDefaults struct { Permissions int64 `json:"permissions" doc:"Mask new people start with (see flags)"` MovieQuotaLimit int `json:"movieQuotaLimit" minimum:"0" maximum:"100000" doc:"Movie requests allowed per period, 0 = no limit"` MovieQuotaDays int `json:"movieQuotaDays" minimum:"0" maximum:"3650"` - TVQuotaLimit int `json:"tvQuotaLimit" minimum:"0" maximum:"100000" doc:"Seasons of shows allowed per period, 0 = no limit"` + TVQuotaLimit int `json:"tvQuotaLimit" minimum:"0" maximum:"100000" doc:"Seasons of shows allowed per period (one request costs at most this), 0 = no limit"` TVQuotaDays int `json:"tvQuotaDays" minimum:"0" maximum:"3650"` } diff --git a/backend/internal/api/requests.go b/backend/internal/api/requests.go index 78c49ccd..a47c06fa 100644 --- a/backend/internal/api/requests.go +++ b/backend/internal/api/requests.go @@ -26,6 +26,8 @@ func reqErr(err error) error { return fail(409, "already_available", err.Error()) case errors.Is(err, requests.ErrAlreadyMonitored): return fail(409, "already_monitored", err.Error()) + case errors.Is(err, requests.ErrNumberingChanged): + return fail(409, "season_numbering_changed", err.Error()) case errors.Is(err, requests.ErrDuplicate): return fail(409, "duplicate_request", err.Error()) case errors.Is(err, requests.ErrBadState): @@ -127,13 +129,14 @@ func registerRequests(api huma.API, d Deps) { Seasons []int `json:"seasons,omitempty" doc:"TV only; empty means every season"` QualityProfileID *int `json:"qualityProfileId,omitempty" doc:"Radarr/Sonarr quality profile to use (omit for the default); see /requests/options"` RootFolder string `json:"rootFolder,omitempty" doc:"Root folder to use (omit for the default)"` + SeasonNumbering string `json:"seasonNumbering,omitempty" enum:"tvdb,tmdb" doc:"TV only: the detail's seasonNumbering the seasons were picked in; a different one than the server's now is 409 season_numbering_changed"` } }) (*requestOutput, error) { u, err := requireUser(ctx) if err != nil { return nil, err } - p := requests.CreateParams{Type: in.Body.Type, TMDBID: in.Body.TMDBID, Seasons: in.Body.Seasons} + p := requests.CreateParams{Type: in.Body.Type, TMDBID: in.Body.TMDBID, Seasons: in.Body.Seasons, SeasonNumbering: in.Body.SeasonNumbering} if in.Body.QualityProfileID != nil || in.Body.RootFolder != "" { p.Overrides = &requests.Overrides{ProfileID: in.Body.QualityProfileID, RootFolder: in.Body.RootFolder} } diff --git a/backend/internal/clients/servarr/client_test.go b/backend/internal/clients/servarr/client_test.go index c1d2bcff..1e473382 100644 --- a/backend/internal/clients/servarr/client_test.go +++ b/backend/internal/clients/servarr/client_test.go @@ -225,3 +225,126 @@ func TestQualityProfilesKnow4K(t *testing.T) { t.Error("IsUHD") } } + +// trackedSonarr serves one tracked series (id 86, tvdb 74796) with S1 monitored and S2, S17 not. +func trackedSonarr(t *testing.T, puts *[]map[string]any, cmds *[]map[string]any) *httptest.Server { + t.Helper() + var mu sync.Mutex + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + mu.Lock() + defer mu.Unlock() + switch { + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/series/86": + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + *puts = append(*puts, b) + _, _ = w.Write([]byte(`{}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v3/command": + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + *cmds = append(*cmds, b) + _, _ = w.Write([]byte(`{"id":1}`)) + case r.Method != http.MethodGet: + t.Errorf("unexpected write %s %s", r.Method, r.URL.Path) + case r.URL.Path == "/api/v3/series" || r.URL.Path == "/api/v3/series/86": + s := `{"id":86,"title":"Bleach","tvdbId":74796,"monitored":false,"extra":"kept","seasons":[{"seasonNumber":1,"monitored":true},{"seasonNumber":2,"monitored":false},{"seasonNumber":17,"monitored":false}]}` + if r.URL.Path == "/api/v3/series" { + s = "[" + s + "]" + } + _, _ = w.Write([]byte(s)) + case r.URL.Path == "/api/v3/series/lookup": + _, _ = w.Write([]byte(`[{"id":null,"title":"Other","tvdbId":1},{"id":null,"title":"Bleach","tvdbId":74796,"statistics":{"seasonCount":17,"totalEpisodeCount":0},"seasons":[{"seasonNumber":0,"monitored":false},{"seasonNumber":17,"monitored":true}]}]`)) + case r.URL.Path == "/api/v3/episode": + _, _ = w.Write([]byte(`[{"id":5,"seasonNumber":17,"episodeNumber":1,"title":"The Blood Warfare","overview":"o","airDate":"2022-10-11","runtime":24,"absoluteEpisodeNumber":367,"hasFile":true,"monitored":true,"images":null}]`)) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) +} + +func TestLookupSeriesUntracked(t *testing.T) { + var puts, cmds []map[string]any + srv := trackedSonarr(t, &puts, &cmds) + defer srv.Close() + c := New(KindSonarr, srv.URL, "key", false) + s, err := c.LookupSeries(context.Background(), 74796) + if err != nil || s == nil { + t.Fatalf("lookup = %+v %v", s, err) + } + if s.ID != 0 || s.Title != "Bleach" || len(s.Seasons) != 2 || s.Seasons[1].SeasonNumber != 17 || !s.Seasons[1].Monitored { + t.Fatalf("lookup = %+v", s) + } + if s, err := c.LookupSeries(context.Background(), 999); s != nil || err != nil { + t.Fatalf("unknown = %+v %v", s, err) + } +} + +func TestSeriesEpisodesFields(t *testing.T) { + var puts, cmds []map[string]any + srv := trackedSonarr(t, &puts, &cmds) + defer srv.Close() + eps, err := New(KindSonarr, srv.URL, "key", false).SeriesEpisodes(context.Background(), 86) + if err != nil || len(eps) != 1 { + t.Fatalf("episodes = %+v %v", eps, err) + } + e := eps[0] + if e.Title != "The Blood Warfare" || e.Overview != "o" || e.AirDate != "2022-10-11" || e.Runtime != 24 || e.AbsoluteEpisodeNumber != 367 || !e.HasFile { + t.Fatalf("episode = %+v", e) + } +} + +func TestSeasonTotalEpisodeCount(t *testing.T) { + var s Series + if err := json.Unmarshal([]byte(`{"seasons":[{"seasonNumber":17,"statistics":{"episodeCount":0,"totalEpisodeCount":50}}]}`), &s); err != nil { + t.Fatal(err) + } + if s.Seasons[0].Statistics.TotalEpisodeCount != 50 { + t.Fatalf("total = %+v", s.Seasons[0].Statistics) + } +} + +// Asking for a season of a show Sonarr already tracks monitors and searches it, and never unmonitors anything. +func TestAddSeriesExistingMonitorsSeasons(t *testing.T) { + var puts, cmds []map[string]any + srv := trackedSonarr(t, &puts, &cmds) + defer srv.Close() + id, err := New(KindSonarr, srv.URL, "key", false).AddSeries(context.Background(), 74796, 1, "/tv", []int{17}, "") + if err != nil || id != 86 { + t.Fatalf("add = %d %v", id, err) + } + if len(puts) != 1 { + t.Fatalf("puts = %v", puts) + } + b := puts[0] + if b["monitored"] != true || b["extra"] != "kept" { + t.Fatalf("series body = %v", b) + } + got := map[float64]bool{} + for _, s := range b["seasons"].([]any) { + m := s.(map[string]any) + got[m["seasonNumber"].(float64)] = m["monitored"].(bool) + } + if !got[1] || got[2] || !got[17] { + t.Fatalf("seasons = %v (S1 stays, S2 untouched, S17 on)", got) + } + if len(cmds) != 1 || cmds[0]["name"] != "SeasonSearch" || cmds[0]["seriesId"] != float64(86) || cmds[0]["seasonNumber"] != float64(17) { + t.Fatalf("commands = %v", cmds) + } +} + +func TestMonitorSeasonsDryRun(t *testing.T) { + var puts, cmds []map[string]any + srv := trackedSonarr(t, &puts, &cmds) + defer srv.Close() + c := New(KindSonarr, srv.URL, "key", true) + if err := c.MonitorSeasons(context.Background(), 86, []int{17}); !errors.Is(err, ErrDryRun) { + t.Fatalf("err = %v", err) + } + if _, err := c.AddSeries(context.Background(), 74796, 1, "/tv", []int{17}, ""); !errors.Is(err, ErrDryRun) { + t.Fatalf("add err = %v", err) + } + if len(puts)+len(cmds) != 0 { + t.Fatalf("dry-run wrote: %v %v", puts, cmds) + } +} diff --git a/backend/internal/clients/servarr/sonarr.go b/backend/internal/clients/servarr/sonarr.go index e9954bd4..6bc57446 100644 --- a/backend/internal/clients/servarr/sonarr.go +++ b/backend/internal/clients/servarr/sonarr.go @@ -19,14 +19,18 @@ type Series struct { EpisodeFileCount int `json:"episodeFileCount"` EpisodeCount int `json:"episodeCount"` } `json:"statistics"` - Seasons []struct { - SeasonNumber int `json:"seasonNumber"` - Monitored bool `json:"monitored"` - Statistics struct { - EpisodeFileCount int `json:"episodeFileCount"` - EpisodeCount int `json:"episodeCount"` - } `json:"statistics"` - } `json:"seasons"` + Seasons []SeriesSeason `json:"seasons"` +} + +// SeriesSeason is one season of a Sonarr series (TVDB numbering). Lookups of untracked shows carry no statistics. +type SeriesSeason struct { + SeasonNumber int `json:"seasonNumber"` + Monitored bool `json:"monitored"` + Statistics struct { + EpisodeFileCount int `json:"episodeFileCount"` + EpisodeCount int `json:"episodeCount"` + TotalEpisodeCount int `json:"totalEpisodeCount"` // whole season, monitored or not (episodeCount is 0 when unmonitored) + } `json:"statistics"` } // MissingSeasons lists the monitored seasons that still lack episode files. @@ -42,11 +46,84 @@ func (s Series) MissingSeasons() []int { // Episode is the part of a Sonarr episode the app needs. type Episode struct { - ID int `json:"id"` - SeasonNumber int `json:"seasonNumber"` - EpisodeNumber int `json:"episodeNumber"` - HasFile bool `json:"hasFile"` - Monitored bool `json:"monitored"` + ID int `json:"id"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + HasFile bool `json:"hasFile"` + Monitored bool `json:"monitored"` + Title string `json:"title"` + Overview string `json:"overview"` + AirDate string `json:"airDate"` // YYYY-MM-DD, empty when unknown + Runtime int `json:"runtime"` // minutes + AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"` +} + +// SeriesEpisodes lists every episode of a series, all seasons. +func (c *Client) SeriesEpisodes(ctx context.Context, seriesID int) ([]Episode, error) { + var out []Episode + return out, c.get(ctx, fmt.Sprintf("/episode?seriesId=%d", seriesID), &out) +} + +// LookupSeries asks Sonarr's metadata source about a show, tracked or not. ID is 0 when Sonarr does not track it, +// and seasons carry no statistics. nil, nil when Sonarr does not know the show. +func (c *Client) LookupSeries(ctx context.Context, tvdbID int) (*Series, error) { + var raw []struct { + Series + ID *int `json:"id"` // null for shows Sonarr does not track + } + if err := c.get(ctx, fmt.Sprintf("/series/lookup?term=tvdb:%d", tvdbID), &raw); err != nil { + return nil, err + } + for _, r := range raw { + if r.TVDBID == tvdbID { + out := r.Series + if r.ID != nil { + out.ID = *r.ID + } + return &out, nil + } + } + return nil, nil +} + +// MonitorSeasons turns monitoring on for the given seasons of a tracked series (and the series itself), then +// searches each of them. It never unmonitors anything. In dry-run mode both writes are blocked. +func (c *Client) MonitorSeasons(ctx context.Context, seriesID int, seasons []int) error { + var series map[string]any + if err := c.get(ctx, fmt.Sprintf("/series/%d", seriesID), &series); err != nil { + return fmt.Errorf("read series: %w", err) + } + want := map[int]bool{} + for _, n := range seasons { + want[n] = true + } + if list, ok := series["seasons"].([]any); ok { + for _, s := range list { + if m, ok := s.(map[string]any); ok { + n, _ := m["seasonNumber"].(float64) + if want[int(n)] { + m["monitored"] = true + } + } + } + } + series["monitored"] = true + if err := c.send(ctx, http.MethodPut, fmt.Sprintf("/series/%d", seriesID), series, nil); err != nil { + if errors.Is(err, ErrDryRun) { + return err + } + return fmt.Errorf("monitor seasons: %w", err) + } + for _, n := range seasons { + cmd := map[string]any{"name": "SeasonSearch", "seriesId": seriesID, "seasonNumber": n} + if err := c.send(ctx, http.MethodPost, "/command", cmd, nil); err != nil { + if errors.Is(err, ErrDryRun) { + return err + } + return fmt.Errorf("search season %d: %w", n, err) + } + } + return nil } // Episodes lists the episodes of one season of a series. @@ -86,11 +163,18 @@ func (c *Client) Series(ctx context.Context, id int) (*Series, error) { // AddSeries adds the series with only the given seasons monitored and starts a search; a non-empty // seriesType ("anime") overrides the type Sonarr suggested. -// Idempotent: an existing series is returned untouched. In dry-run mode the POST is blocked. +// An existing series gets the given seasons monitored and searched (MonitorSeasons, nothing unmonitored). +// In dry-run mode every write is blocked. func (c *Client) AddSeries(ctx context.Context, tvdbID, qualityProfileID int, rootFolder string, seasons []int, seriesType string) (int, error) { if existing, err := c.SeriesByTVDB(ctx, tvdbID); err != nil { return 0, err } else if existing != nil { + if len(seasons) == 0 { + return existing.ID, nil + } + if err := c.MonitorSeasons(ctx, existing.ID, seasons); err != nil { + return existing.ID, err + } return existing.ID, nil } var results []map[string]any diff --git a/backend/internal/demo/demo_test.go b/backend/internal/demo/demo_test.go index d6c0cca9..efb2e6a6 100644 --- a/backend/internal/demo/demo_test.go +++ b/backend/internal/demo/demo_test.go @@ -2,6 +2,7 @@ package demo import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -156,3 +157,67 @@ func TestServices(t *testing.T) { t.Fatalf("radarr write = %d", resp.StatusCode) } } + +// The fake Sonarr numbers Bleach like TVDB (17 arcs) and leaves other shows to TMDB. +func TestFakeSonarrBleach(t *testing.T) { + srv := httptest.NewServer(Services(time.Now(), nil)) + defer srv.Close() + get := func(path string, v any) { + t.Helper() + req, _ := http.NewRequest("GET", srv.URL+PathSonarr+path, nil) + req.Header.Set("X-Api-Key", ArrKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatalf("%s: %v", path, err) + } + } + type season struct { + SeasonNumber int `json:"seasonNumber"` + Statistics struct { + TotalEpisodeCount int `json:"totalEpisodeCount"` + } `json:"statistics"` + } + var lookup []struct{ Seasons []season } + get("/api/v3/series/lookup?term=tvdb:12345", &lookup) + if len(lookup) != 1 || len(lookup[0].Seasons) != 0 { + t.Fatalf("unknown lookup = %+v", lookup) + } + get("/api/v3/series/lookup?term=tvdb:74796", &lookup) + if n := len(lookup[0].Seasons); n != 18 { + t.Fatalf("bleach lookup seasons = %d", n) + } + var series []struct { + ID int `json:"id"` + Monitored bool `json:"monitored"` + Seasons []season + } + get("/api/v3/series?tvdbId=74796", &series) + if len(series) != 1 || series[0].ID != 86 || series[0].Monitored || len(series[0].Seasons) != 18 || + series[0].Seasons[17].Statistics.TotalEpisodeCount != 50 || series[0].Seasons[0].Statistics.TotalEpisodeCount != 7 { + t.Fatalf("series = %+v", series) + } + get("/api/v3/series?tvdbId=1", &series) + if len(series) != 0 { + t.Fatalf("other series = %+v", series) + } + var eps []struct { + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + AirDate string `json:"airDate"` + } + get("/api/v3/episode?seriesId=86", &eps) + count := map[int]int{} + for _, e := range eps { + count[e.SeasonNumber]++ + if e.SeasonNumber == 17 && e.EpisodeNumber == 1 && e.AirDate != "2022-10-11" { + t.Fatalf("S17E1 aired %s", e.AirDate) + } + } + if count[17] != 50 || count[14] != 51 || count[0] != 7 { + t.Fatalf("episode counts = %v", count) + } +} diff --git a/backend/internal/demo/services.go b/backend/internal/demo/services.go index df5a1dfe..1bad2362 100644 --- a/backend/internal/demo/services.go +++ b/backend/internal/demo/services.go @@ -75,16 +75,33 @@ func fakeArr(kind string) http.Handler { h("GET /api/v3/languageprofile", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }) h("GET /api/v3/queue", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, obj{"records": []obj{}, "totalRecords": 0}) }) h("GET /api/v3/movie", empty) - h("GET /api/v3/series", empty) + h("GET /api/v3/series", func(w http.ResponseWriter, r *http.Request) { + if kind == "sonarr" && r.URL.Query().Get("tvdbId") == strconv.Itoa(bleachTVDB) { + writeJSON(w, []obj{bleachSeries()}) + return + } + writeJSON(w, []obj{}) + }) + h("GET /api/v3/episode", func(w http.ResponseWriter, r *http.Request) { + if kind == "sonarr" && r.URL.Query().Get("seriesId") == strconv.Itoa(bleachSeriesID) { + writeJSON(w, bleachEpisodes()) + return + } + writeJSON(w, []obj{}) + }) h("GET /api/v3/movie/lookup/tmdb", func(w http.ResponseWriter, r *http.Request) { id, _ := strconv.Atoi(r.URL.Query().Get("tmdbId")) writeJSON(w, obj{"title": "", "tmdbId": id}) }) h("GET /api/v3/series/lookup", func(w http.ResponseWriter, r *http.Request) { tvdb, _ := strconv.Atoi(strings.TrimPrefix(r.URL.Query().Get("term"), "tvdb:")) + // Unknown shows have no seasons, so Tipsarr keeps the TMDB ones; Bleach has its TVDB layout (no counts, + // like a real lookup of a show Sonarr does not track). seasons := []obj{} - for s := 0; s <= 12; s++ { - seasons = append(seasons, obj{"seasonNumber": s, "monitored": false}) + if tvdb == bleachTVDB { + for s := range bleachSeasonEps { + seasons = append(seasons, obj{"seasonNumber": s, "monitored": false}) + } } writeJSON(w, []obj{{"title": "", "tvdbId": tvdb, "seasons": seasons, "seriesType": "standard"}}) }) @@ -93,3 +110,59 @@ func fakeArr(kind string) http.Handler { }) return mux } + +// Bleach is the demo's show whose TVDB (Sonarr) seasons differ from TMDB's: 17 arcs on TVDB, 2 seasons on TMDB. +const ( + bleachTVDB = 74796 + bleachSeriesID = 86 +) + +// bleachSeasonEps is Sonarr's totalEpisodeCount per season, index = season number (0 = specials). +var bleachSeasonEps = []int{7, 20, 21, 22, 28, 18, 22, 20, 16, 22, 16, 7, 17, 36, 51, 26, 24, 50} + +// bleachSeries is the tracked but unmonitored series, as GET /series?tvdbId= returns it. +func bleachSeries() obj { + seasons := []obj{} + total := 0 + for s, n := range bleachSeasonEps { + total += n + seasons = append(seasons, obj{"seasonNumber": s, "monitored": false, + "statistics": obj{"episodeCount": 0, "episodeFileCount": 0, "totalEpisodeCount": n}}) + } + return obj{"id": bleachSeriesID, "title": "Bleach", "tvdbId": bleachTVDB, "monitored": false, "seriesType": "anime", + "seasons": seasons, "statistics": obj{"seasonCount": len(bleachSeasonEps) - 1, "totalEpisodeCount": total}} +} + +// bleachEpisodes lists every episode, weekly from 2004-10-05 for the original run and from 2022-10-11 for the +// Thousand-Year Blood War (S17), so each season's first air date is stable. +func bleachEpisodes() []obj { + eps := []obj{} + day := time.Date(2004, 10, 5, 0, 0, 0, 0, time.UTC) + abs := 0 + for s := 1; s < len(bleachSeasonEps); s++ { + if s == 17 { + day = time.Date(2022, 10, 11, 0, 0, 0, 0, time.UTC) + } + for e := 1; e <= bleachSeasonEps[s]; e++ { + abs++ + eps = append(eps, bleachEpisode(s, e, abs, day)) + day = day.AddDate(0, 0, 7) + } + } + day = time.Date(2006, 12, 16, 0, 0, 0, 0, time.UTC) + for e := 1; e <= bleachSeasonEps[0]; e++ { + eps = append(eps, bleachEpisode(0, e, 0, day)) + day = day.AddDate(0, 6, 0) + } + return eps +} + +func bleachEpisode(season, ep, abs int, day time.Time) obj { + o := obj{"seriesId": bleachSeriesID, "seasonNumber": season, "episodeNumber": ep, + "title": "Episode " + strconv.Itoa(ep), "overview": "", "airDate": day.Format("2006-01-02"), + "runtime": 24, "hasFile": false, "monitored": false} + if abs > 0 { + o["absoluteEpisodeNumber"] = abs + } + return o +} diff --git a/backend/internal/i18n/locales/en.json b/backend/internal/i18n/locales/en.json index c9f5a34d..5a3a26fe 100644 --- a/backend/internal/i18n/locales/en.json +++ b/backend/internal/i18n/locales/en.json @@ -92,5 +92,7 @@ "webhook.var.comment.message": "Comment text (free text: use json)", "webhook.var.comment.userId": "Id of the comment author", "webhook.var.comment.name": "Name of the comment author", - "webhook.var.data.message": "Raw event data (the data of the JSON envelope)" + "webhook.var.data.message": "Raw event data (the data of the JSON envelope)", + "srv.season": "Season {n}", + "srv.season.specials": "Specials" } diff --git a/backend/internal/i18n/locales/fr.json b/backend/internal/i18n/locales/fr.json index c69df080..f74d5b9e 100644 --- a/backend/internal/i18n/locales/fr.json +++ b/backend/internal/i18n/locales/fr.json @@ -92,5 +92,7 @@ "webhook.var.comment.message": "Texte du commentaire (texte libre : utilise json)", "webhook.var.comment.userId": "Identifiant de l’auteur du commentaire", "webhook.var.comment.name": "Nom de l’auteur du commentaire", - "webhook.var.data.message": "Données brutes de l’événement (le data de l’enveloppe JSON)" + "webhook.var.data.message": "Données brutes de l’événement (le data de l’enveloppe JSON)", + "srv.season": "Saison {n}", + "srv.season.specials": "Épisodes spéciaux" } diff --git a/backend/internal/issues/service.go b/backend/internal/issues/service.go index 42481433..e225664c 100644 --- a/backend/internal/issues/service.go +++ b/backend/internal/issues/service.go @@ -113,7 +113,7 @@ func (s *Service) Create(ctx context.Context, u *store.User, p CreateParams) (*I if p.Type == "movie" { p.Season, p.Episode = 0, 0 } - d, err := s.media.Detail(ctx, media.Opts{Language: u.Language, Region: u.Region}, p.Type, p.TMDBID) + d, err := s.media.Detail(ctx, media.Opts{Language: u.Language, Region: u.Region, Seasons: true}, p.Type, p.TMDBID) if err != nil { return nil, err } diff --git a/backend/internal/media/seasons.go b/backend/internal/media/seasons.go new file mode 100644 index 00000000..01064f93 --- /dev/null +++ b/backend/internal/media/seasons.go @@ -0,0 +1,206 @@ +package media + +import ( + "context" + "sort" + "strconv" + "time" + + "github.com/Rombond/tipsarr/backend/internal/i18n" +) + +// Season numbering of a Detail: TV seasons follow TVDB (through Sonarr) whenever Sonarr can tell, TMDB otherwise. +const ( + NumberingTVDB = "tvdb" + NumberingTMDB = "tmdb" +) + +// TVDBSeason is one season as Sonarr (TVDB) numbers it. +type TVDBSeason struct { + Number int + EpisodeCount int // 0 = unknown (Sonarr lookup of a show it does not track) + FirstAired string + Monitored bool +} + +// SeasonSource gives the TVDB season list of a show (implemented over Sonarr, injected at startup). +type SeasonSource interface { + // ok=false => Sonarr unavailable/not configured/unknown show: caller keeps TMDB seasons. + TVDBSeasons(ctx context.Context, tvdbID int) (seasons []TVDBSeason, inSonarr bool, ok bool) +} + +// SetSeasonSource wires the TVDB season source; nil keeps TMDB seasons. +func (s *Service) SetSeasonSource(src SeasonSource) { s.seasons = src } + +// NumberingDiffers says whether TMDB and TVDB split the show's regular seasons differently. A single +// extra season past the other side's last one (a newly announced season) is not a difference. +func NumberingDiffers(tmdb []Season, tvdb []TVDBSeason) bool { + tm := map[int]int{} + for _, s := range tmdb { + if s.Number > 0 && s.EpisodeCount > 0 { + tm[s.Number] = s.EpisodeCount + } + } + tv := map[int]int{} + for _, s := range tvdb { + if s.Number > 0 { + tv[s.Number] = s.EpisodeCount + } + } + if len(tm) == 0 || len(tv) == 0 { + return false + } + if extraDiffers(tm, tv) || extraDiffers(tv, tm) { + return true + } + for n, c := range tm { + v, ok := tv[n] + if !ok || v == 0 { + continue + } + d := c - v + if d < 0 { + d = -d + } + if d > max(2, c/10) { + return true + } + } + return false +} + +// extraDiffers: a has seasons b lacks, more than one, or one that is not past b's highest season. +func extraDiffers(a, b map[int]int) bool { + hi := 0 + for n := range b { + hi = max(hi, n) + } + var extra []int + for n := range a { + if _, ok := b[n]; !ok { + extra = append(extra, n) + } + } + return len(extra) > 1 || (len(extra) == 1 && extra[0] <= hi) +} + +// MergeSeasons returns the TVDB seasons enriched with their TMDB counterpart (name, poster, air date, +// count when TVDB does not know it). name gives the label of a season without counterpart; never empty. +func MergeSeasons(tmdb []Season, tvdb []TVDBSeason, name func(n int) string) (out []Season, differs bool) { + differs = NumberingDiffers(tmdb, tvdb) + byNum := map[int]Season{} + for _, s := range tmdb { + byNum[s.Number] = s + } + sorted := append([]TVDBSeason(nil), tvdb...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Number < sorted[j].Number }) + out = make([]Season, 0, len(sorted)) + for _, tv := range sorted { + var cp *Season + if t, ok := byNum[tv.Number]; ok && !differs { + cp = &t + } else if tv.EpisodeCount > 0 && tv.FirstAired != "" { + for _, t := range tmdb { + if t.Number > 0 && t.AirDate == tv.FirstAired && t.EpisodeCount == tv.EpisodeCount { + t := t + cp = &t + break + } + } + } + sn := Season{Number: tv.Number, EpisodeCount: tv.EpisodeCount, Monitored: tv.Monitored} + if cp != nil { + sn.TMDBNumber, sn.Name, sn.PosterPath, sn.AirDate = cp.Number, cp.Name, cp.PosterPath, cp.AirDate + if sn.EpisodeCount == 0 { + sn.EpisodeCount = cp.EpisodeCount + } + } else { + sn.AirDate = tv.FirstAired + } + if sn.Name == "" { + sn.Name = name(tv.Number) + } + sn.CountUnknown = sn.EpisodeCount == 0 + out = append(out, sn) + } + return out, differs +} + +// SeasonName is the localized label of a season without a TMDB name ("Season 3", "Specials"). +func SeasonName(lang string) func(n int) string { + return func(n int) string { + code, params := "srv.season", map[string]any{"n": n} + if n == 0 { + code, params = "srv.season.specials", nil + } + if t, ok := i18n.T(lang, code, params); ok && t != "" { + return t + } + if n == 0 { + return "Specials" + } + return "Season " + strconv.Itoa(n) + } +} + +// applySeasonSource switches a TV detail to TVDB numbering when the source can tell. +func (s *Service) applySeasonSource(ctx context.Context, o Opts, d *Detail) { + d.SeasonNumbering = NumberingTMDB + if s.seasons == nil || d.TVDBID == 0 { + return + } + tvdb, _, ok := s.seasons.TVDBSeasons(ctx, d.TVDBID) + if !ok || len(tvdb) == 0 { + return + } + d.Seasons, d.SeasonNumberingDiffers = MergeSeasons(d.Seasons, tvdb, SeasonName(o.lang())) + d.SeasonNumbering = NumberingTVDB + d.NumberOfSeasons = 0 + for _, sn := range d.Seasons { + if sn.Number > 0 { + d.NumberOfSeasons++ + } + } +} + +// ApplyLibrary (re)computes library availability of a TV detail from its seasons; safe to call again +// after the seasons changed. A season of unknown size counts as complete once the library holds any of it. +func (s *Service) ApplyLibrary(ctx context.Context, d *Detail) { + have, err := s.store.LibraryIDs(ctx, "tv", []int{d.TMDBID}) + if err != nil || !have[d.TMDBID] { + return + } + lib, err := s.store.LibrarySeasons(ctx, d.TMDBID) + if err != nil { + return + } + d.Availability = libraryAvailability(d.Seasons, lib) +} + +func libraryAvailability(seasons []Season, lib map[int]int) string { + complete, anyEpisode := true, false + for i, sn := range seasons { + seasons[i].LibraryEpisodes = lib[sn.Number] + if sn.Number == 0 || (sn.EpisodeCount == 0 && !sn.CountUnknown) { + continue // specials and announced-but-empty seasons don't count + } + if lib[sn.Number] > 0 { + anyEpisode = true + } + if sn.CountUnknown { + if lib[sn.Number] == 0 && aired(sn.AirDate) { + complete = false // a season not aired yet (or undated) does not make the show partial + } + } else if lib[sn.Number] < sn.EpisodeCount { + complete = false + } + } + if complete && anyEpisode { + return AvailabilityAvailable + } + return AvailabilityPartial +} + +func aired(date string) bool { + return date != "" && date <= time.Now().UTC().Format("2006-01-02") +} diff --git a/backend/internal/media/seasons_test.go b/backend/internal/media/seasons_test.go new file mode 100644 index 00000000..93d84465 --- /dev/null +++ b/backend/internal/media/seasons_test.go @@ -0,0 +1,192 @@ +package media + +import ( + "context" + "path/filepath" + "strconv" + "testing" + + "github.com/Rombond/tipsarr/backend/internal/store" +) + +var bleachTMDB = []Season{ + {Number: 0, Name: "Specials", EpisodeCount: 4}, + {Number: 1, Name: "Bleach", EpisodeCount: 366, AirDate: "2004-10-05", PosterPath: "/s1.jpg"}, + {Number: 2, Name: "Thousand-Year Blood War", EpisodeCount: 50, AirDate: "2022-10-11", PosterPath: "/s2.jpg"}, +} + +var bleachCounts = []int{7, 20, 21, 22, 28, 18, 22, 20, 16, 22, 16, 7, 17, 36, 51, 26, 24, 50} + +func bleachTVDB(withCounts bool) []TVDBSeason { + var out []TVDBSeason + for n, c := range bleachCounts { + s := TVDBSeason{Number: n} + if withCounts { + s.EpisodeCount = c + if n == 17 { + s.FirstAired = "2022-10-11" + } else if n == 1 { + s.FirstAired = "2004-10-05" + } + } + out = append(out, s) + } + return out +} + +func testName(n int) string { return "Season " + strconv.Itoa(n) } + +func TestNumberingDiffers(t *testing.T) { + tvdb := func(ns ...int) []TVDBSeason { + var out []TVDBSeason + for _, n := range ns { + out = append(out, TVDBSeason{Number: n}) + } + return out + } + tm := func(counts ...int) []Season { + var out []Season + for i, c := range counts { + out = append(out, Season{Number: i + 1, EpisodeCount: c}) + } + return out + } + cases := []struct { + name string + tmdb []Season + tvdb []TVDBSeason + want bool + }{ + {"bleach", bleachTMDB, bleachTVDB(true), true}, + {"bleach lookup only", bleachTMDB, bleachTVDB(false), true}, + {"witch hat new season", tm(13), tvdb(1, 2), false}, + {"equal", tm(10, 10), tvdb(1, 2), false}, + {"gap", tm(10, 10), tvdb(1, 3), true}, + {"counts off", tm(10, 30), []TVDBSeason{{Number: 1, EpisodeCount: 10}, {Number: 2, EpisodeCount: 20}}, true}, + {"counts close", tm(10, 22), []TVDBSeason{{Number: 1, EpisodeCount: 10}, {Number: 2, EpisodeCount: 20}}, false}, + {"empty tvdb", tm(10), nil, false}, + } + for _, c := range cases { + if got := NumberingDiffers(c.tmdb, c.tvdb); got != c.want { + t.Errorf("%s: differs = %v, want %v", c.name, got, c.want) + } + } +} + +func TestMergeSeasonsBleach(t *testing.T) { + out, differs := MergeSeasons(bleachTMDB, bleachTVDB(true), testName) + if !differs || len(out) != 18 { + t.Fatalf("differs=%v len=%d", differs, len(out)) + } + s17 := out[17] + if s17.Number != 17 || s17.TMDBNumber != 2 || s17.Name != "Thousand-Year Blood War" || s17.EpisodeCount != 50 || s17.PosterPath != "/s2.jpg" { + t.Fatalf("S17 = %+v", s17) + } + s1 := out[1] + if s1.TMDBNumber != 0 || s1.EpisodeCount != 20 || s1.Name != "Season 1" || s1.PosterPath != "" || s1.CountUnknown { + t.Fatalf("S1 = %+v", s1) + } + if out[0].TMDBNumber != 0 || out[0].EpisodeCount != 7 { + t.Fatalf("S0 = %+v", out[0]) + } +} + +func TestMergeSeasonsLookupOnly(t *testing.T) { + out, differs := MergeSeasons(bleachTMDB, bleachTVDB(false), testName) + if !differs || len(out) != 18 { + t.Fatalf("differs=%v len=%d", differs, len(out)) + } + for _, s := range out[1:] { + if s.Name != testName(s.Number) || !s.CountUnknown || s.EpisodeCount != 0 || s.TMDBNumber != 0 { + t.Fatalf("season %+v", s) + } + } +} + +func TestMergeSeasonsAligned(t *testing.T) { + tmdb := []Season{{Number: 1, Name: "Pilot season", EpisodeCount: 13, AirDate: "2020-01-01", PosterPath: "/p.jpg"}} + out, differs := MergeSeasons(tmdb, []TVDBSeason{{Number: 1, Monitored: true}, {Number: 2}}, testName) + if differs || len(out) != 2 { + t.Fatalf("differs=%v out=%+v", differs, out) + } + if out[0].Name != "Pilot season" || out[0].EpisodeCount != 13 || out[0].TMDBNumber != 1 || !out[0].Monitored || out[0].CountUnknown { + t.Fatalf("S1 = %+v", out[0]) + } + if out[1].Name != "Season 2" || !out[1].CountUnknown { + t.Fatalf("S2 = %+v", out[1]) + } +} + +func TestSeasonNameLocalized(t *testing.T) { + if got := SeasonName("fr-FR")(3); got != "Saison 3" { + t.Fatalf("fr = %q", got) + } + if got := SeasonName("en")(0); got != "Specials" { + t.Fatalf("en specials = %q", got) + } +} + +func TestLibraryAvailabilityUnknownCounts(t *testing.T) { + seasons := []Season{{Number: 1, CountUnknown: true, AirDate: "2004-10-05"}, {Number: 2, EpisodeCount: 10}} + if got := libraryAvailability(seasons, map[int]int{1: 3, 2: 10}); got != AvailabilityAvailable { + t.Fatalf("got %s", got) + } + if seasons[0].LibraryEpisodes != 3 { + t.Fatal("library episodes not set") + } + if got := libraryAvailability(seasons, map[int]int{2: 10}); got != AvailabilityPartial { + t.Fatalf("aired unknown season missing: got %s", got) + } + future := []Season{{Number: 1, EpisodeCount: 10}, {Number: 2, CountUnknown: true}} + if got := libraryAvailability(future, map[int]int{1: 10}); got != AvailabilityAvailable { + t.Fatalf("undated unknown season must not count: got %s", got) + } +} + +type fakeSource struct { + seasons []TVDBSeason + ok bool + calls int +} + +func (f *fakeSource) TVDBSeasons(context.Context, int) ([]TVDBSeason, bool, bool) { + f.calls++ + return f.seasons, true, f.ok +} + +func TestApplySeasonSource(t *testing.T) { + ctx := context.Background() + st, err := store.Open(ctx, "sqlite:"+filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + s := New(st, "") + newDetail := func() *Detail { + return &Detail{TVDBID: 74796, NumberOfSeasons: 2, Seasons: append([]Season(nil), bleachTMDB...)} + } + + d := newDetail() + s.applySeasonSource(ctx, Opts{}, d) // no source + if d.SeasonNumbering != NumberingTMDB || len(d.Seasons) != 3 { + t.Fatalf("no source: %+v", d) + } + + src := &fakeSource{ok: false} + s.SetSeasonSource(src) + d = newDetail() + s.applySeasonSource(ctx, Opts{}, d) + if d.SeasonNumbering != NumberingTMDB || len(d.Seasons) != 3 || d.NumberOfSeasons != 2 || src.calls != 1 { + t.Fatalf("ok=false: %+v", d) + } + + src.ok, src.seasons = true, bleachTVDB(true) + d = newDetail() + s.applySeasonSource(ctx, Opts{Language: "fr-FR"}, d) + if d.SeasonNumbering != NumberingTVDB || !d.SeasonNumberingDiffers || d.NumberOfSeasons != 17 || len(d.Seasons) != 18 { + t.Fatalf("tvdb: numbering=%s differs=%v n=%d", d.SeasonNumbering, d.SeasonNumberingDiffers, d.NumberOfSeasons) + } + if d.Seasons[5].Name != "Saison 5" { + t.Fatalf("localized name = %q", d.Seasons[5].Name) + } +} diff --git a/backend/internal/media/service.go b/backend/internal/media/service.go index 1aa8275e..2cacd794 100644 --- a/backend/internal/media/service.go +++ b/backend/internal/media/service.go @@ -34,6 +34,7 @@ const ( type Service struct { store *store.Store baseURL string // TMDB base URL override (tests) + seasons SeasonSource } func New(s *store.Store, baseURL string) *Service { return &Service{store: s, baseURL: baseURL} } @@ -42,6 +43,9 @@ func New(s *store.Store, baseURL string) *Service { return &Service{store: s, ba type Opts struct { Language string // e.g. fr-FR; empty = en-US Region string // e.g. FR; empty = none + // Seasons: a TV Detail asks the SeasonSource (Sonarr) for TVDB-numbered seasons. Only callers that + // show or use seasons set it; others get TMDB seasons without paying for a Sonarr call. + Seasons bool } func (o Opts) lang() string { @@ -135,37 +139,6 @@ func (s *Service) annotate(ctx context.Context, items []Item) { } } -// showAvailability compares TMDB season sizes with what the library holds. -func (s *Service) showAvailability(ctx context.Context, d *Detail) { - have, err := s.store.LibraryIDs(ctx, "tv", []int{d.TMDBID}) - if err != nil || !have[d.TMDBID] { - return - } - lib, err := s.store.LibrarySeasons(ctx, d.TMDBID) - if err != nil { - return - } - complete, anyEpisode := true, false - for i, sn := range d.Seasons { - d.Seasons[i].LibraryEpisodes = lib[sn.Number] - if sn.Number == 0 || sn.EpisodeCount == 0 { - continue // specials and announced-but-empty seasons don't count - } - if lib[sn.Number] > 0 { - anyEpisode = true - } - if lib[sn.Number] < sn.EpisodeCount { - complete = false - } - } - switch { - case complete && anyEpisode: - d.Availability = AvailabilityAvailable - default: - d.Availability = AvailabilityPartial - } -} - // ---- raw TMDB shapes ------------------------------------------------------ type rawItem struct { @@ -560,7 +533,11 @@ func (s *Service) Detail(ctx context.Context, o Opts, mediaType string, id int) s.annotate(ctx, one) d.Availability, d.RequestStatus = one[0].Availability, one[0].RequestStatus if mediaType == "tv" { - s.showAvailability(ctx, d) + d.SeasonNumbering = NumberingTMDB + if o.Seasons { + s.applySeasonSource(ctx, o, d) + } + s.ApplyLibrary(ctx, d) } if d.Availability != AvailabilityNone { d.WatchURL = s.watchURL(ctx, mediaType, d.TMDBID) diff --git a/backend/internal/media/types.go b/backend/internal/media/types.go index 9f93c97f..6e2a734b 100644 --- a/backend/internal/media/types.go +++ b/backend/internal/media/types.go @@ -82,13 +82,17 @@ type Review struct { type Season struct { Number int `json:"number"` Name string `json:"name"` - EpisodeCount int `json:"episodeCount"` + EpisodeCount int `json:"episodeCount" doc:"Episodes in the season; 0 = unknown"` AirDate string `json:"airDate,omitempty"` PosterPath string `json:"posterPath,omitempty"` // LibraryEpisodes counts the episodes of this season the Jellyfin library holds (0 when the show is not in it). LibraryEpisodes int `json:"libraryEpisodes" doc:"Episodes of this season already in the library"` // Monitored: Sonarr already tracks this season, it fetches what is missing by itself (and airing episodes as they come). Monitored bool `json:"monitored" doc:"Sonarr already monitors this season"` + // TMDBNumber is the TMDB season matching this (TVDB-numbered) season, 0 when none. + TMDBNumber int `json:"-"` + // CountUnknown: neither Sonarr nor TMDB told how many episodes the season has. + CountUnknown bool `json:"-"` } // Complete says the library holds every announced episode of the season. @@ -115,6 +119,8 @@ type Detail struct { NumberOfSeasons int `json:"numberOfSeasons,omitempty"` NumberOfEpisodes int `json:"numberOfEpisodes,omitempty"` Seasons []Season `json:"seasons,omitempty"` + SeasonNumbering string `json:"seasonNumbering,omitempty" enum:"tvdb,tmdb" doc:"Numbering of seasons: tvdb (Sonarr) or tmdb (fallback when Sonarr cannot tell)"` + SeasonNumberingDiffers bool `json:"seasonNumberingDiffers,omitempty" doc:"TMDB splits this show's seasons differently from Sonarr (TVDB)"` CollectionID int `json:"collectionId,omitempty"` CollectionName string `json:"collectionName,omitempty"` CollectionBackdropPath string `json:"collectionBackdropPath,omitempty"` diff --git a/backend/internal/requests/arr.go b/backend/internal/requests/arr.go index 84526676..09bea1bb 100644 --- a/backend/internal/requests/arr.go +++ b/backend/internal/requests/arr.go @@ -45,7 +45,7 @@ func (s *Service) arrTarget(ctx context.Context, o media.Opts, typ string, tmdbI } return kind, name, base, strconv.Itoa(tmdbID), m.ID, c, nil } - d, derr := s.media.Detail(ctx, o, "tv", tmdbID) + d, derr := s.media.Detail(ctx, media.Opts{Language: o.Language, Region: o.Region, Seasons: true}, "tv", tmdbID) if derr != nil { return "", "", "", "", 0, nil, derr } diff --git a/backend/internal/requests/import.go b/backend/internal/requests/import.go index 45831648..cd00a214 100644 --- a/backend/internal/requests/import.go +++ b/backend/internal/requests/import.go @@ -104,7 +104,7 @@ func (s *Service) importCandidates(ctx context.Context, inst *store.ServarrInsta } looked++ title, poster, release := c.title, "", "" - d, derr := s.media.Detail(ctx, media.Opts{}, c.mediaType, c.tmdbID) + d, derr := s.media.Detail(ctx, media.Opts{Seasons: true}, c.mediaType, c.tmdbID) switch { case derr == nil: if d.Availability == media.AvailabilityAvailable { @@ -121,6 +121,9 @@ func (s *Service) importCandidates(ctx context.Context, inst *store.ServarrInsta ID: store.NewID(), MediaType: c.mediaType, TMDBID: int64(c.tmdbID), Title: title, PosterPath: poster, ReleaseDate: release, Status: store.StatusApproved, InstanceID: inst.ID, ServarrID: int64(c.servarrID), SentAt: nowUnix(), Source: inst.Kind, } + if c.mediaType == "tv" { + r.SeasonNumbering = media.NumberingTVDB // the seasons Sonarr monitors, in its own numbering + } if err := s.store.CreateRequest(ctx, r, c.seasons); err != nil { return imported, looked, err } diff --git a/backend/internal/requests/monitoring.go b/backend/internal/requests/monitoring.go index 8be2fa03..ab88cd06 100644 --- a/backend/internal/requests/monitoring.go +++ b/backend/internal/requests/monitoring.go @@ -29,62 +29,7 @@ func (s *Service) Annotate(ctx context.Context, d *media.Detail) { d.Monitored = m.Monitored && !m.HasFile } case "tv": - sr := s.series(ctx, d.TVDBID) - if sr == nil || !sr.Monitored { - return - } - monitored := map[int]bool{} - for _, sn := range sr.Seasons { - monitored[sn.SeasonNumber] = sn.Monitored - } - for i := range d.Seasons { - d.Seasons[i].Monitored = monitored[d.Seasons[i].Number] - } - } -} - -func (s *Service) series(ctx context.Context, tvdbID int) *servarr.Series { - c := s.instance(ctx, servarr.KindSonarr) - if c == nil || tvdbID == 0 { - return nil - } - sr, err := c.SeriesByTVDB(ctx, tvdbID) - if err != nil { - return nil - } - return sr -} - -// AnnotateEpisodes marks each episode of a season as available, monitored (Sonarr is looking for it) or missing. -func (s *Service) AnnotateEpisodes(ctx context.Context, o media.Opts, tmdbID int, sd *media.SeasonDetail) { - d, err := s.media.Detail(ctx, o, "tv", tmdbID) - if err != nil { - return - } - sr := s.series(ctx, d.TVDBID) - if sr == nil { - return - } - c := s.instance(ctx, servarr.KindSonarr) - eps, err := c.Episodes(ctx, sr.ID, sd.Number) - if err != nil { - return - } - by := map[int]servarr.Episode{} - for _, e := range eps { - by[e.EpisodeNumber] = e - } - for i := range sd.Episodes { - e, ok := by[sd.Episodes[i].Number] - switch { - case !ok: - // Sonarr does not know the episode (not aired in its database): leave the state empty - case e.HasFile: - sd.Episodes[i].State = "available" - case e.Monitored && sr.Monitored: - sd.Episodes[i].State = "monitored" - default: - sd.Episodes[i].State = "missing" - } + // Season monitoring comes with the seasons themselves: media.Detail asks Sonarr (Opts.Seasons) and + // numbers them like Sonarr does. TMDB-numbered seasons (Sonarr unreachable) cannot be matched by number. } } diff --git a/backend/internal/requests/poll_test.go b/backend/internal/requests/poll_test.go index c7513fe4..1ab7726b 100644 --- a/backend/internal/requests/poll_test.go +++ b/backend/internal/requests/poll_test.go @@ -10,14 +10,7 @@ func TestShowProgress(t *testing.T) { var sr servarr.Series sr.Statistics.EpisodeCount, sr.Statistics.EpisodeFileCount = 20, 10 add := func(n, files int) { - sr.Seasons = append(sr.Seasons, struct { - SeasonNumber int `json:"seasonNumber"` - Monitored bool `json:"monitored"` - Statistics struct { - EpisodeFileCount int `json:"episodeFileCount"` - EpisodeCount int `json:"episodeCount"` - } `json:"statistics"` - }{SeasonNumber: n, Monitored: true}) + sr.Seasons = append(sr.Seasons, servarr.SeriesSeason{SeasonNumber: n, Monitored: true}) last := &sr.Seasons[len(sr.Seasons)-1] last.Statistics.EpisodeCount, last.Statistics.EpisodeFileCount = 10, files } diff --git a/backend/internal/requests/quota.go b/backend/internal/requests/quota.go index f697398a..15923483 100644 --- a/backend/internal/requests/quota.go +++ b/backend/internal/requests/quota.go @@ -78,7 +78,7 @@ func (s *Service) window(ctx context.Context, u *store.User, mediaType string) ( return w, nil } now := s.now() - used, oldest, err := s.store.QuotaUsage(ctx, u.ID, mediaType, now.AddDate(0, 0, -days).Unix()) + used, oldest, err := s.store.QuotaUsage(ctx, u.ID, mediaType, now.AddDate(0, 0, -days).Unix(), perRequestCap(mediaType, limit)) if err != nil { return w, err } @@ -105,12 +105,24 @@ func (s *Service) Quota(ctx context.Context, u *store.User) (*Quota, error) { return &Quota{Movie: m, TV: t}, nil } -// checkQuota refuses a request that needs more than what is left (one for a movie, one per season for a show). +// perRequestCap is the most one request may cost: a show request counts its seasons, but never more than the limit. +func perRequestCap(mediaType string, limit int) int { + if mediaType == "tv" { + return limit + } + return 0 +} + +// checkQuota refuses a request that needs more than what is left (one for a movie, one per season for a show, +// at most the whole limit for one show request). func (s *Service) checkQuota(ctx context.Context, u *store.User, mediaType string, needed int) error { w, err := s.window(ctx, u, mediaType) if err != nil { return err } + if c := perRequestCap(mediaType, w.Limit); c > 0 { + needed = min(needed, c) + } if w.Limit > 0 && w.Used+needed > w.Limit { reset := w.ResetsAt if reset == 0 { diff --git a/backend/internal/requests/seasons.go b/backend/internal/requests/seasons.go new file mode 100644 index 00000000..51026554 --- /dev/null +++ b/backend/internal/requests/seasons.go @@ -0,0 +1,310 @@ +package requests + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sort" + "time" + + "github.com/Rombond/tipsarr/backend/internal/clients/servarr" + "github.com/Rombond/tipsarr/backend/internal/media" + "github.com/Rombond/tipsarr/backend/internal/store" +) + +// How long the season source waits for Sonarr and how long it keeps what it learned. +const ( + seriesTimeout = 4 * time.Second // tracked series (fast in Sonarr) + lookupTimeout = 6 * time.Second // metadata lookup of an untracked show + lookupTTL = 24 * time.Hour // untracked show: its season list rarely moves + trackedTTL = 24 * time.Hour // tracked show: only the offline fallback reads it (live first) + firstAiredTTL = 6 * time.Hour // first air date of each Sonarr season + firstAiredQuick = 3 * time.Second // budget of the episode list when nothing is cached +) + +// ErrSeasonNumbering: a request made in TMDB numbering cannot be translated to Sonarr's seasons. +var ErrSeasonNumbering = errors.New("TMDB and Sonarr number this show's seasons differently: ask for every season, or pick the season in Sonarr") + +// ErrNumberingChanged: the person picked seasons in a numbering the server no longer uses (a stale page). +var ErrNumberingChanged = errors.New("the seasons of this show changed: reload and pick them again") + +// SeasonSource is the TVDB season list read from the default Sonarr instance; wire it with media.Service.SetSeasonSource. +func (s *Service) SeasonSource() media.SeasonSource { return sonarrSeasons{s} } + +type sonarrSeasons struct{ s *Service } + +type cachedSeasons struct { + Seasons []media.TVDBSeason `json:"seasons"` + InSonarr bool `json:"inSonarr"` +} + +func (src sonarrSeasons) TVDBSeasons(ctx context.Context, tvdbID int) ([]media.TVDBSeason, bool, bool) { + s := src.s + if tvdbID <= 0 { + return nil, false, false + } + inst, err := s.store.DefaultServarr(ctx, servarr.KindSonarr) + if err != nil { + return nil, false, false + } + c := s.newServarr(inst) + key := fmt.Sprintf("sonarr:seasons:%s:%d", inst.ID, tvdbID) + + sctx, cancel := context.WithTimeout(ctx, seriesTimeout) + sr, err := c.SeriesByTVDB(sctx, tvdbID) + cancel() + if err != nil { + return s.staleSeasons(ctx, key, err) + } + if sr != nil { + out := make([]media.TVDBSeason, 0, len(sr.Seasons)) + for _, sn := range sr.Seasons { + out = append(out, media.TVDBSeason{Number: sn.SeasonNumber, EpisodeCount: sn.Statistics.TotalEpisodeCount, Monitored: sr.Monitored && sn.Monitored}) + } + first := s.firstAired(ctx, c, inst.ID, sr.ID) + for i := range out { + out[i].FirstAired = first[out[i].Number] + } + sortTVDB(out) + s.putSeasons(ctx, key, cachedSeasons{Seasons: out, InSonarr: true}, trackedTTL) + return out, true, len(out) > 0 + } + + // Sonarr does not track it: its metadata lookup still knows the TVDB season list (without sizes). + if body, ok, _ := s.store.GetCache(ctx, key); ok { + var cs cachedSeasons + if json.Unmarshal(body, &cs) == nil && !cs.InSonarr && len(cs.Seasons) > 0 { + return cs.Seasons, false, true + } + } + lctx, cancel := context.WithTimeout(ctx, lookupTimeout) + lk, err := c.LookupSeries(lctx, tvdbID) + cancel() + if err != nil { + return s.staleSeasons(ctx, key, err) + } + if lk == nil || len(lk.Seasons) == 0 { + return nil, false, false + } + out := make([]media.TVDBSeason, 0, len(lk.Seasons)) + for _, sn := range lk.Seasons { + out = append(out, media.TVDBSeason{Number: sn.SeasonNumber, EpisodeCount: sn.Statistics.TotalEpisodeCount}) + } + sortTVDB(out) + s.putSeasons(ctx, key, cachedSeasons{Seasons: out}, lookupTTL) + return out, false, true +} + +// staleSeasons is the fallback while Sonarr cannot be reached: the last list it gave, however old. +func (s *Service) staleSeasons(ctx context.Context, key string, cause error) ([]media.TVDBSeason, bool, bool) { + slog.Debug("sonarr seasons", "key", key, "err", cause) + body, ok, err := s.store.GetCacheStale(ctx, key) + if err != nil || !ok { + return nil, false, false + } + var cs cachedSeasons + if json.Unmarshal(body, &cs) != nil || len(cs.Seasons) == 0 { + return nil, false, false + } + return cs.Seasons, cs.InSonarr, true +} + +func (s *Service) putSeasons(ctx context.Context, key string, cs cachedSeasons, ttl time.Duration) { + if body, err := json.Marshal(cs); err == nil { + _ = s.store.PutCache(ctx, key, body, ttl) + } +} + +// firstAired gives the first air date of each season of a tracked series (from its episode list, cached). +// Best effort: an empty map only means no TMDB season can be matched by date. +func (s *Service) firstAired(ctx context.Context, c *servarr.Client, instID string, seriesID int) map[int]string { + key := fmt.Sprintf("sonarr:firstaired:%s:%d", instID, seriesID) + out := map[int]string{} + if body, ok, _ := s.store.GetCache(ctx, key); ok && json.Unmarshal(body, &out) == nil { + return out + } + ectx, cancel := context.WithTimeout(ctx, firstAiredQuick) + eps, err := c.SeriesEpisodes(ectx, seriesID) + cancel() + if err != nil { + if body, ok, _ := s.store.GetCacheStale(ctx, key); ok { + _ = json.Unmarshal(body, &out) + } + return out + } + out = map[int]string{} + for _, e := range eps { + if e.AirDate == "" { + continue + } + if cur, ok := out[e.SeasonNumber]; !ok || e.AirDate < cur { + out[e.SeasonNumber] = e.AirDate + } + } + if body, err := json.Marshal(out); err == nil { + _ = s.store.PutCache(ctx, key, body, firstAiredTTL) + } + return out +} + +func sortTVDB(list []media.TVDBSeason) { + sort.Slice(list, func(i, j int) bool { return list[i].Number < list[j].Number }) +} + +// ---- season detail ---------------------------------------------------------------------------- + +// SeasonDetail lists the episodes of season n of a show, n in the numbering of the show's Detail (TVDB when +// Sonarr can tell). It takes TMDB's episodes when a TMDB season matches, Sonarr's otherwise, and marks what +// Sonarr already has. A season nobody can list yet comes back with no episodes. +func (s *Service) SeasonDetail(ctx context.Context, o media.Opts, tmdbID, n int) (*media.SeasonDetail, error) { + o.Seasons = true + d, err := s.media.Detail(ctx, o, "tv", tmdbID) + if err != nil { + return nil, err + } + if d.SeasonNumbering != media.NumberingTVDB { + sd, err := s.media.Season(ctx, o, tmdbID, n) + if err != nil { + return nil, err + } + if !d.SeasonNumberingDiffers { + s.markEpisodes(ctx, d.TVDBID, sd) + } + return sd, nil + } + var sn *media.Season + for i := range d.Seasons { + if d.Seasons[i].Number == n { + sn = &d.Seasons[i] + } + } + if sn == nil { + return nil, media.ErrNotFound + } + if sn.TMDBNumber > 0 || (n == 0 && !d.SeasonNumberingDiffers) { + tn := sn.TMDBNumber + if sd, err := s.media.Season(ctx, o, tmdbID, tn); err == nil { + sd.Number = n + s.markEpisodes(ctx, d.TVDBID, sd) + return sd, nil + } else if !errors.Is(err, media.ErrNotFound) { + return nil, err + } + } + sd := &media.SeasonDetail{Number: n, Name: sn.Name, AirDate: sn.AirDate, PosterPath: sn.PosterPath, Episodes: []media.Episode{}} + sr, c := s.liveSeries(ctx, d.TVDBID) + if sr == nil { + return sd, nil + } + ectx, cancel := context.WithTimeout(ctx, seriesTimeout) + eps, err := c.Episodes(ectx, sr.ID, n) + cancel() + if err != nil { + return sd, nil + } + sort.Slice(eps, func(i, j int) bool { return eps[i].EpisodeNumber < eps[j].EpisodeNumber }) + for _, e := range eps { + if e.SeasonNumber != n { + continue + } + sd.Episodes = append(sd.Episodes, media.Episode{Number: e.EpisodeNumber, Name: e.Title, Overview: e.Overview, AirDate: e.AirDate, Runtime: e.Runtime, State: episodeState(e, true, sr.Monitored)}) + } + return sd, nil +} + +// liveSeries reads the series Sonarr tracks for a TVDB id; nil when there is none or Sonarr cannot tell. +func (s *Service) liveSeries(ctx context.Context, tvdbID int) (*servarr.Series, *servarr.Client) { + c := s.instance(ctx, servarr.KindSonarr) + if c == nil || tvdbID == 0 { + return nil, nil + } + sctx, cancel := context.WithTimeout(ctx, seriesTimeout) + defer cancel() + sr, err := c.SeriesByTVDB(sctx, tvdbID) + if err != nil || sr == nil { + return nil, nil + } + return sr, c +} + +// markEpisodes marks each episode of a (Sonarr-numbered) season as available, monitored or missing. +func (s *Service) markEpisodes(ctx context.Context, tvdbID int, sd *media.SeasonDetail) { + sr, c := s.liveSeries(ctx, tvdbID) + if sr == nil { + return + } + ectx, cancel := context.WithTimeout(ctx, seriesTimeout) + eps, err := c.Episodes(ectx, sr.ID, sd.Number) + cancel() + if err != nil { + return + } + by := map[int]servarr.Episode{} + for _, e := range eps { + by[e.EpisodeNumber] = e + } + for i := range sd.Episodes { + e, ok := by[sd.Episodes[i].Number] + sd.Episodes[i].State = episodeState(e, ok, sr.Monitored) + } +} + +// episodeState is what Sonarr says about one episode; empty when Sonarr does not know it (not aired in its database). +func episodeState(e servarr.Episode, known, seriesMonitored bool) string { + switch { + case !known: + return "" + case e.HasFile: + return "available" + case e.Monitored && seriesMonitored: + return "monitored" + default: + return "missing" + } +} + +// ---- numbering guard -------------------------------------------------------------------------- + +// sonarrSeasonsFor turns the seasons of a request into Sonarr's numbering. 'tvdb' rows pass as they are; 'tmdb' +// rows (old ones, or made while Sonarr was unreachable) pass when both numberings agree, or become every Sonarr +// season when they asked for every TMDB season. Anything else is ErrSeasonNumbering: never send wrong seasons. +func (s *Service) sonarrSeasonsFor(ctx context.Context, r *store.Request, requested []int) ([]int, error) { + if r.SeasonNumbering != media.NumberingTMDB || len(requested) == 0 { + return requested, nil + } + d, err := s.media.Detail(ctx, media.Opts{Seasons: true}, "tv", int(r.TMDBID)) + if err != nil { + return nil, err + } + if d.SeasonNumbering != media.NumberingTVDB { + return requested, nil // Sonarr still cannot tell: same guess the request was made with + } + if !d.SeasonNumberingDiffers { + return requested, nil + } + tmdb, err := s.media.Detail(ctx, media.Opts{}, "tv", int(r.TMDBID)) // without the source: TMDB's own seasons + if err != nil { + return nil, err + } + want := map[int]bool{} + for _, n := range requested { + want[n] = true + } + for _, sn := range tmdb.Seasons { + if sn.Number > 0 && sn.EpisodeCount > 0 && !want[sn.Number] { + return nil, ErrSeasonNumbering + } + } + var all []int + for _, sn := range d.Seasons { + if sn.Number > 0 { + all = append(all, sn.Number) + } + } + if len(all) == 0 { + return nil, ErrSeasonNumbering + } + return all, nil +} diff --git a/backend/internal/requests/service.go b/backend/internal/requests/service.go index 7000877c..9a6e02c1 100644 --- a/backend/internal/requests/service.go +++ b/backend/internal/requests/service.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "sort" "strconv" "sync" @@ -102,6 +103,7 @@ type View struct { UHD bool `json:"uhd,omitempty" doc:"The quality profile allows 4K (2160p); absent when not (and from servers older than 4K requests)"` RootFolder string `json:"rootFolder,omitempty" doc:"Chosen root folder (absent = the instance default)"` Source string `json:"source,omitempty" doc:"Set when the request was imported from what Radarr/Sonarr already monitor"` + SeasonNumbering string `json:"seasonNumbering,omitempty" enum:"tvdb,tmdb" doc:"Shows only: numbering of seasons, tvdb (Sonarr) or tmdb (made while Sonarr could not tell; translated when it is sent)"` Progress *Progress `json:"progress,omitempty"` SeasonProgress []SeasonProgress `json:"seasonProgress,omitempty" doc:"Shows only: completion per season"` Events []Event `json:"events,omitempty" doc:"What happened to the request and when, oldest first. Requests older than this history show an approximate one"` @@ -177,7 +179,7 @@ func (s *Service) views(ctx context.Context, rows []store.Request) ([]View, erro ID: r.ID, Type: r.MediaType, TMDBID: int(r.TMDBID), Title: r.Title, PosterPath: r.PosterPath, ReleaseDate: r.ReleaseDate, Seasons: seasons[r.ID], Status: r.Status, Stage: stage, RequestedBy: UserRef{ID: r.RequestedBy, Name: names[r.RequestedBy]}, DeclineReason: r.DeclineReason, - DryRun: r.DryRun == 1, Error: r.Error, Source: r.Source, QualityProfileID: r.ProfileID, UHD: r.UHD == 1, RootFolder: r.RootFolder, Progress: prog, SeasonProgress: s.seasonProgress(r.ID), CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + DryRun: r.DryRun == 1, Error: r.Error, Source: r.Source, SeasonNumbering: r.SeasonNumbering, QualityProfileID: r.ProfileID, UHD: r.UHD == 1, RootFolder: r.RootFolder, Progress: prog, SeasonProgress: s.seasonProgress(r.ID), CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } if r.Status == store.StatusAvailable && r.MediaType == "tv" { // nothing is downloading any more: every requested season is complete (the live percentages are gone) @@ -340,6 +342,9 @@ type CreateParams struct { Seasons []int // TV only; empty = every regular season // Send with this quality profile / root folder instead of the instance defaults. Overrides *Overrides + // SeasonNumbering is the numbering the person picked Seasons in (the detail's seasonNumbering); empty = not said. + // A different one than the server's current numbering is ErrNumberingChanged. + SeasonNumbering string } // Create records a request from a user. Requests from people who auto-approve (admins, managers, the @@ -354,10 +359,13 @@ func (s *Service) Create(ctx context.Context, u *store.User, p CreateParams) (*V if p.Overrides != nil && p.Overrides.RootFolder != "" && !u.Can(perms.RequestAdvanced) { return nil, ErrForbidden // the quality profile is everyone's choice, the folder needs the advanced-requests right } - d, err := s.media.Detail(ctx, media.Opts{Language: u.Language, Region: u.Region}, p.Type, p.TMDBID) + d, err := s.media.Detail(ctx, media.Opts{Language: u.Language, Region: u.Region, Seasons: true}, p.Type, p.TMDBID) if err != nil { return nil, err // media.ErrNotFound / ErrNotConfigured pass through } + if p.Type == "tv" && p.SeasonNumbering != "" && p.SeasonNumbering != d.SeasonNumbering { + return nil, ErrNumberingChanged + } if d.Availability == media.AvailabilityAvailable { return nil, ErrAlreadyAvailable } @@ -425,6 +433,9 @@ func (s *Service) Create(ctx context.Context, u *store.User, p CreateParams) (*V ID: store.NewID(), MediaType: p.Type, TMDBID: int64(p.TMDBID), Title: d.Title, PosterPath: d.PosterPath, ReleaseDate: d.ReleaseDate, RequestedBy: u.ID, Status: store.StatusPending, } + if p.Type == "tv" { + r.SeasonNumbering = d.SeasonNumbering // 'tmdb' while Sonarr cannot tell: checked again when it is sent + } if p.Overrides != nil { if p.Overrides.ProfileID != nil { r.ProfileID = *p.Overrides.ProfileID @@ -631,18 +642,30 @@ func (s *Service) send(ctx context.Context, c *servarr.Client, r *store.Request, if r.MediaType == "movie" { return c.AddMovie(ctx, int(r.TMDBID), profile, root) } - d, err := s.media.Detail(ctx, media.Opts{}, "tv", int(r.TMDBID)) + d, err := s.media.Detail(ctx, media.Opts{Seasons: true}, "tv", int(r.TMDBID)) if err != nil { return 0, err } if d.TVDBID == 0 { return 0, errors.New("TMDB has no TVDB id for this show, so Sonarr cannot add it") } - seasons, err := s.store.RequestSeasons(ctx, []string{r.ID}) + stored, err := s.store.RequestSeasons(ctx, []string{r.ID}) if err != nil { return 0, err } - return c.AddSeries(ctx, d.TVDBID, profile, root, seasons[r.ID], seriesType) + seasons, err := s.sonarrSeasonsFor(ctx, r, stored[r.ID]) + if err != nil { + return 0, err + } + if r.SeasonNumbering == media.NumberingTMDB && d.SeasonNumbering == media.NumberingTVDB { + if !slices.Equal(seasons, stored[r.ID]) { + if err := s.store.ReplaceRequestSeasons(ctx, r.ID, seasons); err != nil { + return 0, err + } + } + r.SeasonNumbering = media.NumberingTVDB // saved with the rest of the request by approve + } + return c.AddSeries(ctx, d.TVDBID, profile, root, seasons, seriesType) } func (s *Service) Decline(ctx context.Context, by *store.User, id, reason string) (*View, error) { diff --git a/backend/internal/server/bleach_fixture_test.go b/backend/internal/server/bleach_fixture_test.go new file mode 100644 index 00000000..5a804bc5 --- /dev/null +++ b/backend/internal/server/bleach_fixture_test.go @@ -0,0 +1,180 @@ +package server_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" +) + +// Bleach as Sonarr (TVDB 74796) numbers it: 17 arc seasons plus specials, where TMDB (30984) has S1 (366 episodes) +// and S2 "Thousand-Year Blood War" (50 episodes, first aired 2022-10-11 like Sonarr's S17). +var bleachCounts = []int{7, 20, 21, 22, 28, 18, 22, 20, 16, 22, 16, 7, 17, 36, 51, 26, 24, 50} // index = season + +func bleachFirstAired(n int) string { + switch n { + case 1: + return "2004-10-05" + case 17: + return "2022-10-11" + case 0: + return "2006-12-16" + } + return fmt.Sprintf("%d-01-10", 2004+n/2) // anything not matching a TMDB season +} + +// fakeBleachSonarr is a Sonarr knowing only Bleach; tracked says whether it already has the series (id 86). +type fakeBleachSonarr struct { + srv *httptest.Server + tracked atomic.Bool + down atomic.Bool + monitored atomic.Bool // series monitored + S1 with missing files (for the import) + mu sync.Mutex + calls []string + put map[string]any + commands []map[string]any + added map[string]any +} + +func (f *fakeBleachSonarr) seasonsJSON(withStats bool) string { + var parts []string + for n, c := range bleachCounts { + mon := f.monitored.Load() && n == 1 + if withStats { + ec := 0 + if mon { + ec = c + } + parts = append(parts, fmt.Sprintf(`{"seasonNumber":%d,"monitored":%t,"statistics":{"episodeFileCount":0,"episodeCount":%d,"totalEpisodeCount":%d}}`, n, mon, ec, c)) + } else { + parts = append(parts, fmt.Sprintf(`{"seasonNumber":%d,"monitored":false}`, n)) + } + } + return "[" + strings.Join(parts, ",") + "]" +} + +func (f *fakeBleachSonarr) seriesJSON() string { + return fmt.Sprintf(`{"id":86,"title":"Bleach","tvdbId":74796,"tmdbId":30984,"titleSlug":"bleach","monitored":%t,"seasons":%s}`, f.monitored.Load(), f.seasonsJSON(true)) +} + +func newFakeBleachSonarr(t *testing.T) *fakeBleachSonarr { + f := &fakeBleachSonarr{} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/api/v3") + f.mu.Lock() + f.calls = append(f.calls, r.Method+" "+p) + f.mu.Unlock() + if f.down.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + out := func(s string) { _, _ = w.Write([]byte(s)) } + decode := func() map[string]any { + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + return b + } + switch { + case p == "/system/status": + out(`{"appName":"Sonarr","version":"4.0.20"}`) + case p == "/qualityprofile": + out(`[{"id":4,"name":"HD-1080p","items":[{"quality":{"name":"Bluray-1080p","resolution":1080},"allowed":true}]}]`) + case p == "/rootfolder": + out(`[{"id":1,"path":"/data/media","freeSpace":123}]`) + case p == "/queue": + out(`{"records":[],"totalRecords":0}`) + case p == "/languageprofile": + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost && p == "/command": + b := decode() + f.mu.Lock() + f.commands = append(f.commands, b) + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + out(`{"id":1}`) + case r.Method == http.MethodPost && p == "/series": + b := decode() + f.mu.Lock() + f.added = b + f.mu.Unlock() + w.WriteHeader(http.StatusCreated) + out(`{"id":86,"tvdbId":74796}`) + case r.Method == http.MethodPut && p == "/series/86": + b := decode() + f.mu.Lock() + f.put = b + f.mu.Unlock() + out(f.seriesJSON()) + case p == "/series/86": + out(f.seriesJSON()) + case p == "/series/lookup": + if r.URL.Query().Get("term") != "tvdb:74796" { + out(`[]`) + return + } + id := "null" + if f.tracked.Load() { + id = "86" + } + out(`[{"title":"Bleach","tvdbId":74796,"id":` + id + `,"statistics":{"seasonCount":17,"totalEpisodeCount":0},"seasons":` + f.seasonsJSON(false) + `}]`) + case p == "/series": + if !f.tracked.Load() || (r.URL.Query().Get("tvdbId") != "" && r.URL.Query().Get("tvdbId") != "74796") { + out(`[]`) + return + } + out("[" + f.seriesJSON() + "]") + case p == "/episode": + if r.URL.Query().Get("seriesId") != "86" || !f.tracked.Load() { + out(`[]`) + return + } + only := -1 + if v := r.URL.Query().Get("seasonNumber"); v != "" { + only, _ = strconv.Atoi(v) + } + var eps []string + for n := range bleachCounts { + if only >= 0 && n != only { + continue + } + for e := 1; e <= 2; e++ { + eps = append(eps, fmt.Sprintf(`{"id":%d,"seasonNumber":%d,"episodeNumber":%d,"title":"Arc %d episode %d","overview":"o","airDate":"%s","runtime":24,"hasFile":%t,"monitored":false}`, + n*100+e, n, e, n, e, bleachFirstAired(n), e == 1)) + } + } + out("[" + strings.Join(eps, ",") + "]") + default: + out(`{}`) + } + })) + t.Cleanup(f.srv.Close) + return f +} + +// writes lists every call that is not a read. +func (f *fakeBleachSonarr) writes() []string { + f.mu.Lock() + defer f.mu.Unlock() + var w []string + for _, c := range f.calls { + if !strings.HasPrefix(c, "GET ") { + w = append(w, c) + } + } + return w +} + +func addBleachSonarr(t *testing.T, e *env, admin *http.Cookie, f *fakeBleachSonarr) { + t.Helper() + resp, body := call(t, e.app, "POST", "/api/v1/admin/servarr", + `{"kind":"sonarr","name":"sonarr","url":"`+f.srv.URL+`","apiKey":"rk","qualityProfileId":4,"rootFolder":"/data/media","isDefault":true}`, admin) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create instance = %d %s", resp.StatusCode, body) + } +} diff --git a/backend/internal/server/seasons_test.go b/backend/internal/server/seasons_test.go new file mode 100644 index 00000000..cb283303 --- /dev/null +++ b/backend/internal/server/seasons_test.go @@ -0,0 +1,276 @@ +package server_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/Rombond/tipsarr/backend/internal/store" +) + +type seasonsDetail struct { + SeasonNumbering string `json:"seasonNumbering"` + SeasonNumberingDiffers bool `json:"seasonNumberingDiffers"` + NumberOfSeasons int `json:"numberOfSeasons"` + Seasons []struct { + Number int `json:"number"` + Name string `json:"name"` + EpisodeCount int `json:"episodeCount"` + PosterPath string `json:"posterPath"` + } `json:"seasons"` +} + +func bleachDetail(t *testing.T, e *env, c *http.Cookie) seasonsDetail { + t.Helper() + resp, body := call(t, e.app, "GET", "/api/v1/media/tv/30984", "", c) + var d seasonsDetail + if resp.StatusCode != 200 || json.Unmarshal([]byte(body), &d) != nil { + t.Fatalf("detail = %d %s", resp.StatusCode, body) + } + return d +} + +type seasonDetailBody struct { + Number int `json:"number"` + Name string `json:"name"` + Episodes []struct { + Number int `json:"number"` + Name string `json:"name"` + State string `json:"state"` + } `json:"episodes"` +} + +func bleachSeason(t *testing.T, e *env, c *http.Cookie, n int) seasonDetailBody { + t.Helper() + resp, body := call(t, e.app, "GET", fmt.Sprintf("/api/v1/media/tv/30984/seasons/%d", n), "", c) + var sd seasonDetailBody + if resp.StatusCode != 200 || json.Unmarshal([]byte(body), &sd) != nil || sd.Episodes == nil { + t.Fatalf("season %d = %d %s", n, resp.StatusCode, body) + } + return sd +} + +func regular(d seasonsDetail) int { + n := 0 + for _, s := range d.Seasons { + if s.Number > 0 { + n++ + } + } + return n +} + +// Sonarr only knows Bleach from its lookup: 17 TVDB seasons of unknown size, nothing to list yet. +func TestBleachSeasonsLookupOnly(t *testing.T) { + e := newEnv(t, true) + _, admin, bob := setupUsers(t, e) + sonarr := newFakeBleachSonarr(t) + addBleachSonarr(t, e, admin, sonarr) + + d := bleachDetail(t, e, bob) + if d.SeasonNumbering != "tvdb" || !d.SeasonNumberingDiffers || regular(d) != 17 || d.NumberOfSeasons != 17 { + t.Fatalf("detail = %+v", d) + } + for _, s := range d.Seasons { + if s.Name == "" || s.EpisodeCount != 0 { + t.Fatalf("season without counterpart = %+v", s) + } + } + if d.Seasons[5].Name != "Season 5" { + t.Fatalf("S5 name = %q", d.Seasons[5].Name) + } + if sd := bleachSeason(t, e, bob, 5); sd.Number != 5 || len(sd.Episodes) != 0 { + t.Fatalf("untracked season = %+v", sd) + } + if w := sonarr.writes(); len(w) != 0 { + t.Fatalf("reads only: %v", w) + } +} + +// Sonarr tracks Bleach: its S17 is TMDB's S2, other arcs get Sonarr's episodes. +func TestBleachSeasonsTracked(t *testing.T) { + e := newEnv(t, true) + _, admin, bob := setupUsers(t, e) + sonarr := newFakeBleachSonarr(t) + sonarr.tracked.Store(true) + addBleachSonarr(t, e, admin, sonarr) + + d := bleachDetail(t, e, bob) + if d.SeasonNumbering != "tvdb" || !d.SeasonNumberingDiffers || regular(d) != 17 { + t.Fatalf("detail = %+v", d) + } + s17 := d.Seasons[len(d.Seasons)-1] + if s17.Number != 17 || s17.Name != "Thousand-Year Blood War" || s17.EpisodeCount != 50 || s17.PosterPath != "/tybw.jpg" { + t.Fatalf("S17 = %+v", s17) + } + if s1 := d.Seasons[1]; s1.Number != 1 || s1.EpisodeCount != 20 || s1.Name != "Season 1" { + t.Fatalf("S1 = %+v", s1) + } + + sd := bleachSeason(t, e, bob, 17) + if sd.Number != 17 || sd.Name != "Thousand-Year Blood War" || len(sd.Episodes) != 3 || sd.Episodes[0].Name != "The Blood Warfare" || sd.Episodes[0].State != "available" || sd.Episodes[1].State != "missing" { + t.Fatalf("S17 episodes = %+v", sd) + } + sd = bleachSeason(t, e, bob, 5) + if sd.Number != 5 || len(sd.Episodes) != 2 || sd.Episodes[0].Name != "Arc 5 episode 1" || sd.Episodes[0].State != "available" { + t.Fatalf("S5 episodes = %+v", sd) + } + if resp, _ := call(t, e.app, "GET", "/api/v1/media/tv/30984/seasons/18", "", bob); resp.StatusCode != http.StatusNotFound { + t.Fatalf("S18 = %d", resp.StatusCode) + } +} + +// Sonarr down: TMDB seasons quickly, unless an earlier answer is cached. +func TestBleachSeasonsSonarrDown(t *testing.T) { + e := newEnv(t, true) + _, admin, bob := setupUsers(t, e) + sonarr := newFakeBleachSonarr(t) + addBleachSonarr(t, e, admin, sonarr) + + sonarr.down.Store(true) + start := time.Now() + d := bleachDetail(t, e, bob) + if time.Since(start) > 5*time.Second || d.SeasonNumbering != "tmdb" || d.SeasonNumberingDiffers || regular(d) != 2 { + t.Fatalf("down = %+v in %v", d, time.Since(start)) + } + + sonarr.down.Store(false) + sonarr.tracked.Store(true) + if d := bleachDetail(t, e, bob); d.SeasonNumbering != "tvdb" { + t.Fatalf("up = %+v", d) + } + sonarr.down.Store(true) + if d := bleachDetail(t, e, bob); d.SeasonNumbering != "tvdb" || regular(d) != 17 || d.Seasons[17].Name != "Thousand-Year Blood War" { + t.Fatalf("stale = %+v", d) + } +} + +func bleachRequest(t *testing.T, e *env, id string) (*store.Request, []int) { + t.Helper() + ctx := context.Background() + r, err := e.st.GetRequest(ctx, id) + if err != nil { + t.Fatal(err) + } + s, err := e.st.RequestSeasons(ctx, []string{id}) + if err != nil { + t.Fatal(err) + } + return r, s[id] +} + +// Asking for S17 sends Sonarr's S17, never S2 (the TMDB number). +func TestBleachRequestLive(t *testing.T) { + e := newEnv(t, false) + _, admin, bob := setupUsers(t, e) + sonarr := newFakeBleachSonarr(t) + sonarr.tracked.Store(true) + addBleachSonarr(t, e, admin, sonarr) + + resp, body := call(t, e.app, "POST", "/api/v1/requests", `{"type":"tv","tmdbId":30984,"seasons":[17],"seasonNumbering":"tmdb"}`, bob) + if resp.StatusCode != http.StatusConflict || !strings.Contains(body, "season_numbering_changed") { + t.Fatalf("stale numbering = %d %s", resp.StatusCode, body) + } + resp, body = call(t, e.app, "POST", "/api/v1/requests", `{"type":"tv","tmdbId":30984,"seasons":[17],"seasonNumbering":"tvdb"}`, admin) + v := decodeReq(t, body) + if resp.StatusCode != http.StatusCreated || v.Status != "approved" || v.DryRun || !strings.Contains(body, `"seasonNumbering":"tvdb"`) { + t.Fatalf("create = %d %s", resp.StatusCode, body) + } + r, seasons := bleachRequest(t, e, v.ID) + if r.SeasonNumbering != "tvdb" || fmt.Sprint(seasons) != "[17]" { + t.Fatalf("stored = %q %v", r.SeasonNumbering, seasons) + } + sonarr.mu.Lock() + defer sonarr.mu.Unlock() + if sonarr.put == nil || len(sonarr.commands) != 1 || sonarr.commands[0]["seasonNumber"] != float64(17) || sonarr.commands[0]["name"] != "SeasonSearch" { + t.Fatalf("put = %v commands = %v", sonarr.put != nil, sonarr.commands) + } + for _, s := range sonarr.put["seasons"].([]any) { + m := s.(map[string]any) + if n := m["seasonNumber"].(float64); (n == 17) != (m["monitored"] == true) { + t.Fatalf("season %v monitored = %v", n, m["monitored"]) + } + } +} + +// Old TMDB-numbered requests: one season cannot be translated, all of them become every Sonarr season. +func TestBleachLegacyTMDBRequests(t *testing.T) { + e := newEnv(t, false) + _, admin, bob := setupUsers(t, e) + sonarr := newFakeBleachSonarr(t) + addBleachSonarr(t, e, admin, sonarr) // not tracked: an approval adds the series + ctx := context.Background() + mk := func(seasons ...int) string { + r := &store.Request{ID: store.NewID(), MediaType: "tv", TMDBID: 30984, Title: "Bleach", Status: store.StatusPending, RequestedBy: "x", SeasonNumbering: "tmdb"} + if err := e.st.CreateRequest(ctx, r, seasons); err != nil { + t.Fatal(err) + } + return r.ID + } + + one := mk(2) + _, body := call(t, e.app, "POST", "/api/v1/requests/"+one+"/approve", `{}`, admin) + if v := decodeReq(t, body); v.Status != "failed" || !strings.Contains(v.Error, "number this show's seasons differently") { + t.Fatalf("one season = %s", body) + } + if w := sonarr.writes(); len(w) != 0 { + t.Fatalf("wrong seasons sent: %v", w) + } + call(t, e.app, "DELETE", "/api/v1/requests/"+one, "", admin) + + all := mk(1, 2) + _, body = call(t, e.app, "POST", "/api/v1/requests/"+all+"/approve", `{}`, admin) + if v := decodeReq(t, body); v.Status != "approved" || v.DryRun { + t.Fatalf("every season = %s", body) + } + r, seasons := bleachRequest(t, e, all) + if r.SeasonNumbering != "tvdb" || len(seasons) != 17 || seasons[0] != 1 || seasons[16] != 17 { + t.Fatalf("translated = %q %v", r.SeasonNumbering, seasons) + } + sonarr.mu.Lock() + defer sonarr.mu.Unlock() + for _, s := range sonarr.added["seasons"].([]any) { + m := s.(map[string]any) + if n := m["seasonNumber"].(float64); (n > 0) != (m["monitored"] == true) { + t.Fatalf("added season %v monitored = %v", n, m["monitored"]) + } + } + _ = bob +} + +// Dry-run: a request for a series Sonarr has is approved without any write. +func TestBleachRequestDryRun(t *testing.T) { + e := newEnv(t, true) + _, admin, _ := setupUsers(t, e) + sonarr := newFakeBleachSonarr(t) + sonarr.tracked.Store(true) + addBleachSonarr(t, e, admin, sonarr) + resp, body := call(t, e.app, "POST", "/api/v1/requests", `{"type":"tv","tmdbId":30984,"seasons":[17]}`, admin) + if v := decodeReq(t, body); resp.StatusCode != http.StatusCreated || v.Status != "approved" || !v.DryRun { + t.Fatalf("dry-run = %d %s", resp.StatusCode, body) + } + if w := sonarr.writes(); len(w) != 0 { + t.Fatalf("DRY-RUN LEAK: %v", w) + } +} + +// Imported requests carry Sonarr's own numbers. +func TestBleachImportIsTVDB(t *testing.T) { + e := newEnv(t, true) + _, admin, _ := setupUsers(t, e) + sonarr := newFakeBleachSonarr(t) + sonarr.tracked.Store(true) + sonarr.monitored.Store(true) + addBleachSonarr(t, e, admin, sonarr) + if _, err := e.reqs.ImportFromServarr(context.Background()); err != nil { + t.Fatal(err) + } + _, body := call(t, e.app, "GET", "/api/v1/requests", "", admin) + if !strings.Contains(body, `"total":1`) || !strings.Contains(body, `"seasonNumbering":"tvdb"`) || !strings.Contains(body, `"seasons":[1]`) { + t.Fatalf("imported = %s", body) + } +} diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 56d9e483..84d1d581 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -304,6 +304,17 @@ func fakeTMDB(t *testing.T) *httptest.Server { mux.HandleFunc("/movie/77", hit(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"id":77,"title":"Gone Movie","release_date":"2019-05-05","poster_path":"/gone.jpg","vote_average":7.1,"genres":[{"id":53,"name":"Thriller"}],"credits":{"cast":[],"crew":[]},"recommendations":{"results":[]},"similar":{"results":[]}}`)) })) + // Bleach: TMDB has two regular seasons where Sonarr (TVDB) has 17 (see bleach_fixture_test.go) + mux.HandleFunc("/tv/30984", hit(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":30984,"name":"Bleach","first_air_date":"2004-10-05","original_language":"ja","genres":[{"id":16,"name":"Animation"}],"number_of_seasons":2,"number_of_episodes":416, + "seasons":[{"season_number":0,"name":"Specials","episode_count":4},{"season_number":1,"name":"Bleach","episode_count":366,"air_date":"2004-10-05","poster_path":"/b1.jpg"}, + {"season_number":2,"name":"Thousand-Year Blood War","episode_count":50,"air_date":"2022-10-11","poster_path":"/tybw.jpg"}], + "external_ids":{"tvdb_id":74796},"recommendations":{"results":[]},"similar":{"results":[]}}`)) + })) + mux.HandleFunc("/tv/30984/season/2", hit(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"season_number":2,"name":"Thousand-Year Blood War","air_date":"2022-10-11","episodes":[ + {"episode_number":1,"name":"The Blood Warfare"},{"episode_number":2,"name":"Foundation Stones"},{"episode_number":3,"name":"March of the Starcross"}]}`)) + })) mux.HandleFunc("/movie/404", hit(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) @@ -414,6 +425,7 @@ func newEnvOpts(t *testing.T, dryRun bool, setupToken string, demoMode bool) *en pusher.SetRetryDelays(10*time.Millisecond, 10*time.Millisecond) notifier.SetPusher(pusher) reqs := requests.New(st, mediaSvc, hub, notifier, dryRun) + mediaSvc.SetSeasonSource(reqs.SeasonSource()) box := boxoffice.New(st, mediaSvc, fakeBOM(t).URL) box.SetPause(0) box.SetNow(func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) }) // a Tuesday: latest weekend is 2026W40 diff --git a/backend/internal/store/migrations/032_request_season_numbering.sql b/backend/internal/store/migrations/032_request_season_numbering.sql new file mode 100644 index 00000000..a9cf53a8 --- /dev/null +++ b/backend/internal/store/migrations/032_request_season_numbering.sql @@ -0,0 +1,7 @@ +-- Numbering of a show request's seasons: 'tvdb' (Sonarr's, what Tipsarr uses now) or 'tmdb' (requests made before, +-- translated or refused when they are sent). Movies keep ''. +ALTER TABLE requests ADD COLUMN season_numbering VARCHAR(8) NOT NULL DEFAULT ''; + +-- Sent or imported requests already reached Sonarr with these numbers: they are Sonarr's. +UPDATE requests SET season_numbering = 'tvdb' WHERE media_type = 'tv' AND (sent_at > 0 OR source <> ''); +UPDATE requests SET season_numbering = 'tmdb' WHERE media_type = 'tv' AND season_numbering = ''; diff --git a/backend/internal/store/models.go b/backend/internal/store/models.go index 6896b7eb..8fdf7f3b 100644 --- a/backend/internal/store/models.go +++ b/backend/internal/store/models.go @@ -257,14 +257,16 @@ type Request struct { SentAt int64 `bun:"sent_at"` DryRun int `bun:"dry_run"` Error string `bun:"error"` - ProfileID int `bun:"profile_id"` // chosen quality profile, 0 = instance default - RootFolder string `bun:"root_folder"` // chosen root folder, "" = instance default - UHD int `bun:"uhd"` // 1 = the quality profile allows 4K (2160p) - Source string `bun:"source"` // "" = a user asked; "radarr"/"sonarr" = imported from what they monitor - AvailableAt int64 `bun:"available_at"` // when the request turned available - NotifiedAt int64 `bun:"notified_at"` // when its "now available" alert was handled, 0 = still due - CreatedAt int64 `bun:"created_at"` - UpdatedAt int64 `bun:"updated_at"` + ProfileID int `bun:"profile_id"` // chosen quality profile, 0 = instance default + RootFolder string `bun:"root_folder"` // chosen root folder, "" = instance default + UHD int `bun:"uhd"` // 1 = the quality profile allows 4K (2160p) + Source string `bun:"source"` // "" = a user asked; "radarr"/"sonarr" = imported from what they monitor + // SeasonNumbering of the request's seasons (shows): "tvdb" (Sonarr) or "tmdb" (translated when sent); "" for movies. + SeasonNumbering string `bun:"season_numbering"` + AvailableAt int64 `bun:"available_at"` // when the request turned available + NotifiedAt int64 `bun:"notified_at"` // when its "now available" alert was handled, 0 = still due + CreatedAt int64 `bun:"created_at"` + UpdatedAt int64 `bun:"updated_at"` } type RequestSeason struct { diff --git a/backend/internal/store/permissions.go b/backend/internal/store/permissions.go index e1914644..1e1ac936 100644 --- a/backend/internal/store/permissions.go +++ b/backend/internal/store/permissions.go @@ -43,17 +43,24 @@ func (s *Store) SettingInt(ctx context.Context, key string, def int) int { } // QuotaUsage counts what a person requested since the given time: requests for movies, seasons for shows. +// A show request counts at most perRequestCap seasons (0 = no cap), so one request never costs more than the limit. // Declined and failed requests give their share back. oldest is the creation time of the oldest counted request. -func (s *Store) QuotaUsage(ctx context.Context, userID, mediaType string, since int64) (used int, oldest int64, err error) { +func (s *Store) QuotaUsage(ctx context.Context, userID, mediaType string, since int64, perRequestCap int) (used int, oldest int64, err error) { var row struct { N int Oldest int64 } q := `SELECT COUNT(*) AS n, COALESCE(MIN(r.created_at), 0) AS oldest FROM requests r WHERE r.requested_by = ? AND r.media_type = ? AND r.created_at >= ? AND r.status NOT IN ('declined', 'failed')` + args := []any{userID, mediaType, since} if mediaType == "tv" { - q = `SELECT COUNT(*) AS n, COALESCE(MIN(r.created_at), 0) AS oldest FROM request_seasons rs JOIN requests r ON r.id = rs.request_id WHERE r.requested_by = ? AND r.media_type = ? AND r.created_at >= ? AND r.status NOT IN ('declined', 'failed')` + if perRequestCap <= 0 { + perRequestCap = 1 << 30 + } + q = `SELECT COALESCE(SUM(CASE WHEN c.cnt > ? THEN ? ELSE c.cnt END), 0) AS n, COALESCE(MIN(c.created_at), 0) AS oldest FROM (` + + `SELECT r.id, r.created_at, COUNT(*) AS cnt FROM request_seasons rs JOIN requests r ON r.id = rs.request_id WHERE r.requested_by = ? AND r.media_type = ? AND r.created_at >= ? AND r.status NOT IN ('declined', 'failed') GROUP BY r.id, r.created_at) c` + args = []any{perRequestCap, perRequestCap, userID, mediaType, since} } - if err := s.DB.NewRaw(q, userID, mediaType, since).Scan(ctx, &row); err != nil { + if err := s.DB.NewRaw(q, args...).Scan(ctx, &row); err != nil { return 0, 0, err } return row.N, row.Oldest, nil diff --git a/backend/internal/store/permissions_test.go b/backend/internal/store/permissions_test.go index 73201e19..271d55fb 100644 --- a/backend/internal/store/permissions_test.go +++ b/backend/internal/store/permissions_test.go @@ -68,13 +68,20 @@ func TestQuotaUsageCountsSeasonsAndSkipsDeclined(t *testing.T) { add("m0", "movie", StatusPending, 5) // older than the window add("t1", "tv", StatusPending, 150, 1, 2, 3) add("t2", "tv", StatusFailed, 160, 1) - if n, oldest, err := s.QuotaUsage(ctx, "u", "movie", 50); err != nil || n != 2 || oldest != 100 { + if n, oldest, err := s.QuotaUsage(ctx, "u", "movie", 50, 0); err != nil || n != 2 || oldest != 100 { t.Fatalf("movies = %d %d %v", n, oldest, err) } - if n, _, err := s.QuotaUsage(ctx, "u", "tv", 50); err != nil || n != 3 { + if n, _, err := s.QuotaUsage(ctx, "u", "tv", 50, 0); err != nil || n != 3 { t.Fatalf("shows count seasons: %d %v", n, err) } - if n, _, _ := s.QuotaUsage(ctx, "someone-else", "movie", 0); n != 0 { + if n, _, err := s.QuotaUsage(ctx, "u", "tv", 50, 2); err != nil || n != 2 { + t.Fatalf("a show request costs at most the cap: %d %v", n, err) + } + add("t3", "tv", StatusPending, 170, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) + if n, oldest, err := s.QuotaUsage(ctx, "u", "tv", 50, 10); err != nil || n != 13 || oldest != 150 { + t.Fatalf("17 seasons capped at 10 plus 3: %d %d %v", n, oldest, err) + } + if n, _, _ := s.QuotaUsage(ctx, "someone-else", "movie", 0, 0); n != 0 { t.Fatalf("other person = %d", n) } } diff --git a/backend/internal/store/requests.go b/backend/internal/store/requests.go index 4765dea6..a816b1e2 100644 --- a/backend/internal/store/requests.go +++ b/backend/internal/store/requests.go @@ -144,6 +144,24 @@ func (s *Store) CreateRequest(ctx context.Context, r *Request, seasons []int) er }) } +// ReplaceRequestSeasons swaps the seasons of a request (a TMDB-numbered request translated to Sonarr's numbering). +func (s *Store) ReplaceRequestSeasons(ctx context.Context, id string, seasons []int) error { + return s.DB.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + if _, err := tx.NewDelete().Model((*RequestSeason)(nil)).Where("request_id = ?", id).Exec(ctx); err != nil { + return err + } + if len(seasons) == 0 { + return nil + } + rows := make([]RequestSeason, len(seasons)) + for i, n := range seasons { + rows[i] = RequestSeason{RequestID: id, SeasonNumber: n} + } + _, err := tx.NewInsert().Model(&rows).Exec(ctx) + return err + }) +} + func (s *Store) GetRequest(ctx context.Context, id string) (*Request, error) { r := new(Request) err := s.DB.NewSelect().Model(r).Where("id = ?", id).Scan(ctx) diff --git a/backend/internal/store/seasonnumbering_test.go b/backend/internal/store/seasonnumbering_test.go new file mode 100644 index 00000000..2935fcac --- /dev/null +++ b/backend/internal/store/seasonnumbering_test.go @@ -0,0 +1,77 @@ +package store + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Migration 032: sent or imported show requests already reached Sonarr (tvdb), other show requests are TMDB-numbered. +func TestMigrationSeasonNumberingBackfill(t *testing.T) { + urls := map[string]string{"sqlite": "sqlite:" + filepath.Join(t.TempDir(), "sn.db")} + if v := os.Getenv("TIPSARR_TEST_PG_URL"); v != "" { + urls["postgres"] = v + } + if v := os.Getenv("TIPSARR_TEST_MYSQL_URL"); v != "" { + urls["mysql"] = v + } + for name, url := range urls { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, url) + if err != nil { + t.Fatal(err) + } + defer s.Close() + p := fmt.Sprintf("m%d", time.Now().UnixNano()) // external DBs persist between runs + rows := map[string]*Request{ + p + "sent": {MediaType: "tv", Status: StatusApproved, SentAt: 10}, + p + "imported": {MediaType: "tv", Status: StatusApproved, Source: "sonarr"}, + p + "pending": {MediaType: "tv", Status: StatusPending}, + p + "dry": {MediaType: "tv", Status: StatusApproved, DryRun: 1}, + p + "movie": {MediaType: "movie", Status: StatusPending}, + } + for id, r := range rows { + r.ID, r.TMDBID, r.Title = id, 1, id + if err := s.CreateRequest(ctx, r, nil); err != nil { + t.Fatal(err) + } + } + if _, err := s.DB.NewUpdate().Model((*Request)(nil)).Set("season_numbering = ''").Where("id LIKE ?", p+"%").Exec(ctx); err != nil { + t.Fatal(err) + } + body, err := migrationsFS.ReadFile("migrations/032_request_season_numbering.sql") + if err != nil { + t.Fatal(err) + } + for _, stmt := range splitStatements(string(body)) { + if strings.HasPrefix(stmt, "UPDATE") { // the column exists already: Open applied the whole file + if _, err := s.DB.ExecContext(ctx, stmt); err != nil { + t.Fatal(err) + } + } + } + want := map[string]string{p + "sent": "tvdb", p + "imported": "tvdb", p + "pending": "tmdb", p + "dry": "tmdb", p + "movie": ""} + for id, w := range want { + r, err := s.GetRequest(ctx, id) + if err != nil || r.SeasonNumbering != w { + t.Errorf("%s = %q, want %q (%v)", id, r.SeasonNumbering, w, err) + } + } + if err := s.ReplaceRequestSeasons(ctx, p+"pending", []int{3, 4}); err != nil { + t.Fatal(err) + } + if err := s.ReplaceRequestSeasons(ctx, p+"pending", []int{1, 2, 5}); err != nil { + t.Fatal(err) + } + got, err := s.RequestSeasons(ctx, []string{p + "pending"}) + if err != nil || fmt.Sprint(got[p+"pending"]) != "[1 2 5]" { + t.Fatalf("replaced seasons = %v %v", got, err) + } + }) + } +} diff --git a/design/generated/Localizable.xcstrings b/design/generated/Localizable.xcstrings index 9eaf2199..93ce1587 100644 --- a/design/generated/Localizable.xcstrings +++ b/design/generated/Localizable.xcstrings @@ -2358,6 +2358,22 @@ } } }, + "error.season_numbering_changed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The seasons of this show changed. Reload and pick them again." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les saisons de cette série ont changé. Recharge la page et choisis-les à nouveau." + } + } + } + }, "error.setup_done": { "localizations": { "en": { @@ -5049,6 +5065,22 @@ } } }, + "m.detail.no_episodes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The episode list appears once Sonarr tracks this show." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La liste des épisodes apparaîtra quand Sonarr suivra cette série." + } + } + } + }, "m.detail.no_overview": { "localizations": { "en": { @@ -5129,6 +5161,22 @@ } } }, + "m.detail.seasons_numbering_note": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB." + } + } + } + }, "m.detail.share": { "localizations": { "en": { @@ -11375,6 +11423,23 @@ } } }, + "req.season_range": { + "comment": "placeholders: from, to", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seasons %1$@–%2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisons %1$@ à %2$@" + } + } + } + }, "req.seasons": { "comment": "placeholders: list, list", "localizations": { @@ -12415,6 +12480,22 @@ } } }, + "seasons.no_episodes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The episode list appears once Sonarr tracks this show." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La liste des épisodes apparaîtra quand Sonarr suivra cette série." + } + } + } + }, "seasons.no_preview": { "localizations": { "en": { @@ -12431,6 +12512,22 @@ } } }, + "seasons.numbering_note": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB." + } + } + } + }, "seasons.season": { "comment": "placeholders: n", "localizations": { @@ -12815,6 +12912,39 @@ } } }, + "srv.season": { + "comment": "placeholders: n", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Season %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saison %@" + } + } + } + }, + "srv.season.specials": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Specials" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épisodes spéciaux" + } + } + } + }, "state.approved": { "localizations": { "en": { diff --git a/design/generated/android/values-fr/strings.xml b/design/generated/android/values-fr/strings.xml index fee2366c..5f4f3a76 100644 --- a/design/generated/android/values-fr/strings.xml +++ b/design/generated/android/values-fr/strings.xml @@ -148,6 +148,7 @@ Tu as atteint ta limite de demandes pour le moment. Actualisé à l’instant, réessaie dans une minute. Saisis à nouveau la clé API quand tu changes l’adresse. + Les saisons de cette série ont changé. Recharge la page et choisis-les à nouveau. La configuration est déjà terminée. Jeton d’installation requis : consulte le journal du serveur Tipsarr. Impossible de joindre un serveur Jellyfin à cette adresse. @@ -315,11 +316,13 @@ Connecte-toi à ton serveur Impossible de joindre ce serveur. Vérifie l’adresse et ta connexion. Refusé par un admin. + La liste des épisodes apparaîtra quand Sonarr suivra cette série. Aucun résumé disponible. Ouvrir dans Jellyfin Seules les notes TMDB existent pour les séries. Redemander %1$s saison | %1$s saisons + Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB. Partager Pour toi Films @@ -708,6 +711,7 @@ Réessayer Dossier Saison %1$s + Saisons %1$s à %2$s Saison %1$s | Saisons %1$s Tout sélectionner Tout désélectionner @@ -772,7 +776,9 @@ Mots-clés : %1$s épisode | %1$s épisodes Chargement des épisodes… + La liste des épisodes apparaîtra quand Sonarr suivra cette série. Pas d’aperçu + Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB. Saison %1$s Saisons %1$s classements enregistrés @@ -796,6 +802,8 @@ %1$s fichiers prêts, %2$s en attente, %3$s en échec yt-dlp est à jour (%1$s) yt-dlp mis à jour de %1$s vers %2$s + Saison %1$s + Épisodes spéciaux Approuvé Disponible Refusé diff --git a/design/generated/android/values/strings.xml b/design/generated/android/values/strings.xml index 75a2a335..385834ca 100644 --- a/design/generated/android/values/strings.xml +++ b/design/generated/android/values/strings.xml @@ -148,6 +148,7 @@ You have reached your request limit for now. Refreshed a moment ago, try again in a minute. Enter the API key again when you change the address. + The seasons of this show changed. Reload and pick them again. Setup has already been completed. Setup token required: see the Tipsarr server log. Cannot reach a Jellyfin server at that address. @@ -315,11 +316,13 @@ Connect to your server Can’t reach this server. Check the address and your connection. Declined by an admin. + The episode list appears once Sonarr tracks this show. No overview available. Open in Jellyfin Only TMDB scores exist for TV shows. Request again %1$s season | %1$s seasons + Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB. Share For you Movies @@ -708,6 +711,7 @@ Retry Folder Season %1$s + Seasons %1$s–%2$s Season %1$s | Seasons %1$s Select all Select none @@ -772,7 +776,9 @@ Tags: %1$s episode | %1$s episodes Loading episodes… + The episode list appears once Sonarr tracks this show. No preview + Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB. Season %1$s Seasons %1$s charts stored @@ -796,6 +802,8 @@ %1$s trailer files ready, %2$s waiting, %3$s failed yt-dlp is up to date (%1$s) yt-dlp updated from %1$s to %2$s + Season %1$s + Specials Approved Available Declined diff --git a/design/generated/strings.json b/design/generated/strings.json index cc83dcc1..4cf3121d 100644 --- a/design/generated/strings.json +++ b/design/generated/strings.json @@ -133,6 +133,7 @@ "error.tmdb_not_configured": "The TMDB key is not configured. An administrator can add it in Settings.", "error.tmdb_failed": "TMDB did not answer. Try again in a moment.", "error.already_available": "This title is already available in the library.", + "error.season_numbering_changed": "The seasons of this show changed. Reload and pick them again.", "error.duplicate_request": "This title already has an active request.", "error.bad_state": "The request is not in a state that allows this.", "error.no_instance": "No Radarr or Sonarr instance is configured for this type of title.", @@ -319,6 +320,9 @@ "seasons.season": "Season {n}", "seasons.episodes": "{count} episode | {count} episodes", "seasons.loading": "Loading episodes…", + "seasons.numbering_note": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB.", + "seasons.no_episodes": "The episode list appears once Sonarr tracks this show.", + "req.season_range": "Seasons {from}–{to}", "seasons.no_preview": "No preview", "req.which_seasons": "Which seasons?", "req.select_none": "Select none", @@ -745,6 +749,8 @@ "m.issue.send": "Send report", "m.detail.no_overview": "No overview available.", "m.detail.seasons_count": "%1$@ season | %1$@ seasons", + "m.detail.seasons_numbering_note": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB.", + "m.detail.no_episodes": "The episode list appears once Sonarr tracks this show.", "m.search.prompt": "Movies, TV shows, people", "m.search.recents": "Recent searches", "m.search.clear": "Clear", @@ -1023,7 +1029,9 @@ "webhook.var.comment.message": "Comment text (free text: use json)", "webhook.var.comment.userId": "Id of the comment author", "webhook.var.comment.name": "Name of the comment author", - "webhook.var.data.message": "Raw event data (the data of the JSON envelope)" + "webhook.var.data.message": "Raw event data (the data of the JSON envelope)", + "srv.season": "Season {n}", + "srv.season.specials": "Specials" }, "fr": { "users.title": "Utilisateurs", @@ -1159,6 +1167,7 @@ "error.tmdb_not_configured": "La clé TMDB n’est pas configurée. Un administrateur peut l’ajouter dans les Réglages.", "error.tmdb_failed": "TMDB n’a pas répondu. Réessaie dans un instant.", "error.already_available": "Ce titre est déjà disponible dans la bibliothèque.", + "error.season_numbering_changed": "Les saisons de cette série ont changé. Recharge la page et choisis-les à nouveau.", "error.duplicate_request": "Ce titre a déjà une demande en cours.", "error.bad_state": "La demande n’est pas dans un état qui le permet.", "error.no_instance": "Aucune instance Radarr ou Sonarr n’est configurée pour ce type de titre.", @@ -1345,6 +1354,9 @@ "seasons.season": "Saison {n}", "seasons.episodes": "{count} épisode | {count} épisodes", "seasons.loading": "Chargement des épisodes…", + "seasons.numbering_note": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB.", + "seasons.no_episodes": "La liste des épisodes apparaîtra quand Sonarr suivra cette série.", + "req.season_range": "Saisons {from} à {to}", "seasons.no_preview": "Pas d’aperçu", "req.which_seasons": "Quelles saisons ?", "req.select_none": "Tout désélectionner", @@ -1771,6 +1783,8 @@ "m.issue.send": "Envoyer le signalement", "m.detail.no_overview": "Aucun résumé disponible.", "m.detail.seasons_count": "%1$@ saison | %1$@ saisons", + "m.detail.seasons_numbering_note": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB.", + "m.detail.no_episodes": "La liste des épisodes apparaîtra quand Sonarr suivra cette série.", "m.search.prompt": "Films, séries, personnes", "m.search.recents": "Recherches récentes", "m.search.clear": "Effacer", @@ -2049,6 +2063,8 @@ "webhook.var.comment.message": "Texte du commentaire (texte libre : utilise json)", "webhook.var.comment.userId": "Identifiant de l’auteur du commentaire", "webhook.var.comment.name": "Nom de l’auteur du commentaire", - "webhook.var.data.message": "Données brutes de l’événement (le data de l’enveloppe JSON)" + "webhook.var.data.message": "Données brutes de l’événement (le data de l’enveloppe JSON)", + "srv.season": "Saison {n}", + "srv.season.specials": "Épisodes spéciaux" } } diff --git a/docs/configuration.md b/docs/configuration.md index 73950747..32606150 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,8 @@ Users → Defaults for new people. ## Request limits Users → a person → Request limits: a number of movies and of TV show seasons per number of days. Empty = the default, -`0` = no limit. People with "manage requests" have no limit. +`0` = no limit. A show request counts its seasons, but one request never costs more than the whole limit, so a +long show can always be asked for. People with "manage requests" have no limit. ## Recommendations from other accounts diff --git a/docs/faq.md b/docs/faq.md index ded15c4e..1bfc8ab6 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -8,6 +8,13 @@ Dry-run is on by default. Settings shows "Dry-run on"; the log says what would h Save a Jellyfin API key, then run `library-sync` on the Jobs page. Add the Jellyfin webhook for instant updates ([installation](installation.md#jellyfin)). +**A show has more (or fewer) seasons than on TMDB.** +Seasons follow Sonarr's numbering (TVDB), the one Sonarr and your Jellyfin folders use. Some shows are split +differently on TMDB: Bleach is 2 seasons on TMDB and 17 on TVDB. Tipsarr takes the season list from Sonarr and keeps +TMDB for posters, synopsis and cast; a season with a TMDB match keeps its TMDB name and poster. A season that +appears only on TVDB shows its episodes once Sonarr tracks the show. When Sonarr is unreachable or the show has no +TVDB id, Tipsarr falls back to the TMDB seasons. An episode count of 0 in the API means unknown. + **No posters, no search results.** The TMDB key is missing or wrong (Settings → API keys). Tipsarr must reach `api.themoviedb.org` and `image.tmdb.org`. diff --git a/docs/notifications.md b/docs/notifications.md index e3cc750b..bc376031 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -78,7 +78,7 @@ event) and in **Test** (answers 422 `template_error` with the message). | `image` | poster URL (TMDB, w500), empty when unknown | | `url` | link to the title (`/media//`) or the issue (`/issues/`) under the **Tipsarr address for links** set on the Webhooks page; empty when that address is not set | | `media.tmdbId`, `media.imdbId`, `media.tvdbId`, `media.type`, `media.title`, `media.year`, `media.status`, `media.is4k` | the title (imdbId/tvdbId are empty for now) | -| `request.*` | the request as in the API (`id`, `status`, `stage`, `seasons`, `requestedBy`, `declineReason`...) | +| `request.*` | the request as in the API (`id`, `status`, `stage`, `seasons`, `requestedBy`, `declineReason`...); `seasons` use Sonarr's numbering (TVDB), see the [FAQ](faq.md) | | `requestedBy.id`, `.name`, `.discordId`, `.telegramChatId` | the requester | | `issue.*` | the issue as in the API (`id`, `kind`, `status`, `season`, `episode`, `createdBy`...) | | `comment.message`, `comment.name`, `comment.userId` | the comment (issue.created: the report text) | diff --git a/docs/upgrading.md b/docs/upgrading.md index 65d2940a..6224d846 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -28,3 +28,22 @@ does not check the schema version and may misbehave on a database migrated by a ## Mobile apps Update the server first, then the apps. Apps keep working with an older server but hide what it does not support. + +## Version notes + +### 0.18: TV seasons follow Sonarr's numbering + +TV seasons now come from Sonarr (TVDB numbering), not TMDB (see the [FAQ](faq.md)). Existing requests are not +renumbered. TV requests already sent to Sonarr or imported are marked as TVDB-numbered. The others (pending or +failed) are marked as TMDB-numbered and checked at approval: if the two numberings match, nothing changes; a request +for every TMDB season becomes every Sonarr season; otherwise approval fails with a message asking to request every +season or pick the season in Sonarr. Nothing wrong is ever sent to Sonarr. + +To list the requests that will go through this check (read-only): + +```sql +SELECT id, title, status FROM requests +WHERE media_type = 'tv' AND season_numbering = 'tmdb' AND status IN ('pending', 'failed'); +``` + +Requests already in Sonarr under a wrong season are not changed: fix them in Sonarr. diff --git a/frontend/src/lib/api/openapi.json b/frontend/src/lib/api/openapi.json index 8bf3b7e8..35976103 100644 --- a/frontend/src/lib/api/openapi.json +++ b/frontend/src/lib/api/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"Add_blocklistRequest":{"additionalProperties":false,"properties":{"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"Add_watchlistRequest":{"additionalProperties":false,"properties":{"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"AdminTeaserHistoryResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/HistoryItem"},"type":"array"},"total":{"format":"int64","type":"integer"}},"required":["items","total"],"type":"object"},"ApproveRequestRequest":{"additionalProperties":false,"properties":{"qualityProfileId":{"format":"int64","type":"integer"},"rootFolder":{"type":"string"}},"type":"object"},"ArrLink":{"additionalProperties":false,"properties":{"kind":{"enum":["radarr","sonarr"],"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"required":["kind","name","url"],"type":"object"},"AuthMethods":{"additionalProperties":false,"properties":{"password":{"description":"The Jellyfin username/password form works","type":"boolean"},"sso":{"description":"Single sign-on (OpenID Connect) is set up","type":"boolean"},"ssoLabel":{"description":"Name of the provider for the button","type":"string"}},"required":["password","sso"],"type":"object"},"Breaker":{"additionalProperties":false,"properties":{"state":{"enum":["closed","open"],"type":"string"},"until":{"format":"int64","type":"integer"}},"required":["state","until"],"type":"object"},"CastMember":{"additionalProperties":false,"properties":{"character":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["id","name"],"type":"object"},"Chart":{"additionalProperties":false,"properties":{"end":{"type":"string"},"entries":{"items":{"$ref":"#/components/schemas/ChartEntry"},"type":"array"},"fetchedAt":{"format":"int64","type":"integer"},"hasWeekly":{"description":"Weekly charts are stored for this region","type":"boolean"},"label":{"type":"string"},"region":{"type":"string"},"regions":{"items":{"type":"string"},"type":"array"},"start":{"type":"string"},"week":{"description":"Week key, e.g. 2026W40; empty when nothing is stored yet","type":"string"},"weekly":{"type":"boolean"},"weeks":{"items":{"$ref":"#/components/schemas/WeekRef"},"type":"array"}},"required":["region","regions","week","weekly","hasWeekly","fetchedAt","weeks","entries"],"type":"object"},"ChartEntry":{"additionalProperties":false,"properties":{"hasFile":{"description":"Radarr already has the file","type":"boolean"},"inRadarr":{"description":"Radarr already tracks this movie (a request would be redundant)","type":"boolean"},"item":{"$ref":"#/components/schemas/Item","description":"Matched TMDB title; absent when no match was found"},"position":{"format":"int64","type":"integer"},"title":{"type":"string"},"totalGross":{"format":"int64","type":"integer"},"weekendGross":{"description":"USD (or the region's currency) for the weekend","format":"int64","type":"integer"},"weeksInRelease":{"format":"int64","type":"integer"}},"required":["position","title","weekendGross","totalGross","weeksInRelease","inRadarr","hasFile"],"type":"object"},"CollectionDetail":{"additionalProperties":false,"properties":{"backdropPath":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"overview":{"type":"string"},"parts":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"posterPath":{"type":"string"}},"required":["id","name","parts"],"type":"object"},"CommentIssueRequest":{"additionalProperties":false,"properties":{"message":{"maxLength":2000,"minLength":1,"type":"string"}},"required":["message"],"type":"object"},"Counts":{"additionalProperties":false,"properties":{"failed":{"format":"int64","type":"integer"},"queued":{"format":"int64","type":"integer"},"ready":{"format":"int64","type":"integer"},"running":{"format":"int64","type":"integer"}},"required":["queued","running","ready","failed"],"type":"object"},"CreateIssueRequest":{"additionalProperties":false,"properties":{"episode":{"description":"TV only, 0 = whole season","format":"int64","minimum":0,"type":"integer"},"kind":{"enum":["video","audio","subtitles","other"],"type":"string"},"message":{"maxLength":2000,"minLength":1,"type":"string"},"season":{"description":"TV only, 0 = whole show","format":"int64","minimum":0,"type":"integer"},"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId","kind","message"],"type":"object"},"CreateRequestRequest":{"additionalProperties":false,"properties":{"qualityProfileId":{"description":"Radarr/Sonarr quality profile to use (omit for the default); see /requests/options","format":"int64","type":"integer"},"rootFolder":{"description":"Root folder to use (omit for the default)","type":"string"},"seasons":{"description":"TV only; empty means every season","items":{"format":"int64","type":"integer"},"type":"array"},"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"CrewMember":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"job":{"description":"Director, Writer, Screenplay, Story, Novel, Editor, Producer, Composer, Cinematography, Creator","type":"string"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["id","name","job"],"type":"object"},"DeclineRequestRequest":{"additionalProperties":false,"properties":{"reason":{"maxLength":500,"type":"string"}},"type":"object"},"DemoLoginInputBody":{"additionalProperties":false,"properties":{"role":{"description":"admin: every right; user: a plain account","enum":["admin","user"],"type":"string"}},"required":["role"],"type":"object"},"Detail":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"budget":{"format":"int64","type":"integer"},"cast":{"items":{"$ref":"#/components/schemas/CastMember"},"type":"array"},"collectionBackdropPath":{"type":"string"},"collectionId":{"format":"int64","type":"integer"},"collectionName":{"type":"string"},"countries":{"description":"Production countries","items":{"type":"string"},"type":"array"},"crew":{"description":"Key crew (writers, editor, producers, composer...); creators for TV","items":{"$ref":"#/components/schemas/CrewMember"},"type":"array"},"directors":{"items":{"$ref":"#/components/schemas/Person"},"type":"array"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"genres":{"items":{"$ref":"#/components/schemas/Genre"},"type":"array"},"homepage":{"type":"string"},"imdbId":{"type":"string"},"jellyfinItemId":{"description":"Jellyfin id of the title when it is in the library (for links into the Jellyfin apps)","type":"string"},"jellyfinServerId":{"description":"Id of the Jellyfin server, with the item id enough to build a link into the Swiftfin app","type":"string"},"keywords":{"description":"TMDB tags such as \"superhero\"; searchable","items":{"$ref":"#/components/schemas/Keyword"},"type":"array"},"languages":{"description":"Spoken languages, English names","items":{"type":"string"},"type":"array"},"monitored":{"description":"Radarr (movie) already monitors this title and looks for it","type":"boolean"},"numberOfEpisodes":{"format":"int64","type":"integer"},"numberOfSeasons":{"format":"int64","type":"integer"},"originalLanguage":{"type":"string"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"recommendations":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"revenue":{"format":"int64","type":"integer"},"reviews":{"description":"A few TMDB user reviews","items":{"$ref":"#/components/schemas/Review"},"type":"array"},"runtimeMinutes":{"format":"int64","type":"integer"},"seasons":{"items":{"$ref":"#/components/schemas/Season"},"type":"array"},"similar":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"status":{"type":"string"},"studios":{"description":"Production companies (movies) or networks (TV)","items":{"type":"string"},"type":"array"},"tagline":{"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"trailerKey":{"description":"YouTube video key of the official trailer","type":"string"},"tvdbId":{"format":"int64","type":"integer"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"voteAverage":{"format":"double","type":"number"},"voteCount":{"format":"int64","type":"integer"},"watchUrl":{"description":"Deep link to the title in Jellyfin when it is in the library","type":"string"}},"required":["genres","cast","directors","crew","reviews","keywords","recommendations","similar","type","tmdbId","title","voteAverage","availability"],"type":"object"},"DeviceSession":{"additionalProperties":false,"properties":{"appVersion":{"type":"string"},"createdAt":{"format":"int64","type":"integer"},"current":{"description":"The session making this request","type":"boolean"},"deviceName":{"type":"string"},"expiresAt":{"format":"int64","type":"integer"},"id":{"description":"Opaque id, usable in DELETE /me/sessions/{id}","type":"string"},"lastSeenAt":{"format":"int64","type":"integer"},"platform":{"enum":["web","ios","android"],"type":"string"},"userAgent":{"type":"string"}},"required":["id","platform","deviceName","appVersion","userAgent","createdAt","lastSeenAt","expiresAt","current"],"type":"object"},"Episode":{"additionalProperties":false,"properties":{"airDate":{"type":"string"},"name":{"type":"string"},"number":{"format":"int64","type":"integer"},"overview":{"type":"string"},"runtimeMinutes":{"format":"int64","type":"integer"},"state":{"enum":["available","monitored","missing"],"type":"string"},"stillPath":{"type":"string"},"voteAverage":{"format":"double","type":"number"}},"required":["number","name","voteAverage"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":"array"},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Event":{"additionalProperties":false,"properties":{"at":{"description":"Unix time of the step","format":"int64","type":"integer"},"by":{"$ref":"#/components/schemas/UserRef","description":"Who did it; absent when the server did"},"kind":{"enum":["requested","approved","searching","downloading","available","declined","failed"],"type":"string"},"note":{"description":"The decline reason, or the error of a failed step (admins only)","type":"string"}},"required":["kind","at"],"type":"object"},"Feed":{"additionalProperties":false,"properties":{"done":{"description":"True when there is nothing new left to show. No items and not done: trailers are still being prepared, ask again in a few seconds","type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/Teaser"},"type":"array"}},"required":["items","done"],"type":"object"},"Flag":{"additionalProperties":false,"properties":{"group":{"enum":["admin","requests","auto-approve","issues","reserved"],"type":"string"},"implies":{"description":"Flags that come with this one and cannot be switched off while it is on","items":{"type":"string"},"type":"array"},"name":{"type":"string"},"reserved":{"description":"Defined so the numbers match Seerr; no feature uses it yet, hide it","type":"boolean"},"value":{"format":"int64","type":"integer"}},"required":["name","value","group","reserved"],"type":"object"},"Flags":{"additionalProperties":false,"properties":{"blocklisted":{"type":"boolean"},"watchlisted":{"type":"boolean"}},"required":["watchlisted","blocklisted"],"type":"object"},"Genre":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"GenreCount":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"name":{"type":"string"}},"required":["name","count"],"type":"object"},"GetPermissionsResponse":{"additionalProperties":false,"properties":{"defaults":{"$ref":"#/components/schemas/PermissionDefaults"},"flags":{"items":{"$ref":"#/components/schemas/Flag"},"type":"array"}},"required":["flags","defaults"],"type":"object"},"Header":{"additionalProperties":false,"properties":{"name":{"maxLength":100,"type":"string"},"value":{"maxLength":2000,"type":"string"}},"required":["name","value"],"type":"object"},"Health":{"additionalProperties":false,"properties":{"breaker":{"$ref":"#/components/schemas/Breaker"},"cacheBytes":{"format":"int64","type":"integer"},"cacheLimitBytes":{"format":"int64","type":"integer"},"counts":{"$ref":"#/components/schemas/Counts"},"denoVersion":{"type":"string"},"ffmpegVersion":{"type":"string"},"lastError":{"type":"string"},"ok":{"type":"boolean"},"ytdlpVersion":{"type":"string"}},"required":["ok","ytdlpVersion","denoVersion","ffmpegVersion","cacheBytes","cacheLimitBytes","counts","breaker"],"type":"object"},"HealthOutputBody":{"additionalProperties":false,"properties":{"ok":{"type":"boolean"}},"required":["ok"],"type":"object"},"HistoryItem":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"seenAt":{"description":"Unix time of the swipe","format":"int64","type":"integer"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"voteAverage":{"format":"double","type":"number"}},"required":["seenAt","type","tmdbId","title","voteAverage","availability"],"type":"object"},"HistorySource":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"HistorySourcesBody":{"additionalProperties":false,"properties":{"sources":{"description":"Accounts whose watch history this person's recommendations also use, with their own. Titles they watched are hidden from the recommendations too","items":{"$ref":"#/components/schemas/HistorySource"},"type":"array"}},"required":["sources"],"type":"object"},"ImportUsersResponse":{"additionalProperties":false,"properties":{"created":{"format":"int64","type":"integer"},"total":{"format":"int64","type":"integer"}},"required":["created","total"],"type":"object"},"InstanceBody":{"additionalProperties":false,"properties":{"animeRoot":{"description":"Sonarr only. Omit to keep, empty string to clear","type":"string"},"apiKey":{"description":"Required when creating; omit on update to keep the saved key","type":"string"},"genreRoots":{"additionalProperties":{"type":"string"},"description":"Radarr only. Omit to keep the saved map, send {} to clear it","type":"object"},"isDefault":{"type":"boolean"},"kind":{"enum":["radarr","sonarr"],"type":"string"},"name":{"maxLength":100,"minLength":1,"type":"string"},"publicUrl":{"description":"Omit to keep, empty string to clear. http(s)://host[:port]","type":"string"},"qualityProfileId":{"format":"int64","minimum":1,"type":"integer"},"rootFolder":{"minLength":1,"type":"string"},"url":{"format":"uri","type":"string"}},"required":["kind","name","url","qualityProfileId","rootFolder","isDefault"],"type":"object"},"InstanceView":{"additionalProperties":false,"properties":{"animeRoot":{"description":"Sonarr only: root folder for anime (Animation + Japanese original language); such shows are also added with series type anime","type":"string"},"apiKeyConfigured":{"type":"boolean"},"genreRoots":{"additionalProperties":{"type":"string"},"description":"Radarr only: TMDB genre id -\u003e root folder; the first matching genre of a movie wins","type":"object"},"id":{"type":"string"},"isDefault":{"type":"boolean"},"kind":{"enum":["radarr","sonarr"],"type":"string"},"name":{"type":"string"},"publicUrl":{"description":"Address people open the app at in their browser, used by links to it; empty = url","type":"string"},"qualityProfileId":{"format":"int64","type":"integer"},"rootFolder":{"type":"string"},"url":{"type":"string"}},"required":["id","kind","name","url","apiKeyConfigured","qualityProfileId","rootFolder","isDefault","publicUrl","animeRoot","genreRoots"],"type":"object"},"IssueComment":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"id":{"type":"string"},"message":{"type":"string"},"user":{"$ref":"#/components/schemas/IssueUser"}},"required":["id","user","message","createdAt"],"type":"object"},"IssueCounts":{"additionalProperties":false,"properties":{"open":{"format":"int64","type":"integer"},"resolved":{"format":"int64","type":"integer"}},"required":["open","resolved"],"type":"object"},"IssueServarrSearchResponse":{"additionalProperties":false,"properties":{"dryRun":{"description":"Dry-run is on: nothing was sent to Radarr/Sonarr","type":"boolean"}},"required":["dryRun"],"type":"object"},"IssueThread":{"additionalProperties":false,"properties":{"commentCount":{"format":"int64","type":"integer"},"comments":{"items":{"$ref":"#/components/schemas/IssueComment"},"type":"array"},"createdAt":{"format":"int64","type":"integer"},"createdBy":{"$ref":"#/components/schemas/IssueUser"},"episode":{"format":"int64","type":"integer"},"id":{"type":"string"},"kind":{"enum":["video","audio","subtitles","other"],"type":"string"},"posterPath":{"type":"string"},"resolvedBy":{"$ref":"#/components/schemas/IssueUser"},"season":{"format":"int64","type":"integer"},"status":{"enum":["open","resolved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"updatedAt":{"format":"int64","type":"integer"}},"required":["comments","id","type","tmdbId","title","kind","status","createdBy","commentCount","createdAt","updatedAt"],"type":"object"},"IssueUser":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"IssueView":{"additionalProperties":false,"properties":{"commentCount":{"format":"int64","type":"integer"},"createdAt":{"format":"int64","type":"integer"},"createdBy":{"$ref":"#/components/schemas/IssueUser"},"episode":{"format":"int64","type":"integer"},"id":{"type":"string"},"kind":{"enum":["video","audio","subtitles","other"],"type":"string"},"posterPath":{"type":"string"},"resolvedBy":{"$ref":"#/components/schemas/IssueUser"},"season":{"format":"int64","type":"integer"},"status":{"enum":["open","resolved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"updatedAt":{"format":"int64","type":"integer"}},"required":["id","type","tmdbId","title","kind","status","createdBy","commentCount","createdAt","updatedAt"],"type":"object"},"Item":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"voteAverage":{"format":"double","type":"number"}},"required":["type","tmdbId","title","voteAverage","availability"],"type":"object"},"Keyword":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"LdapImportBody":{"additionalProperties":false,"properties":{"connected":{"description":"True when Tipsarr could sign in to the LDAP server with the imported settings","type":"boolean"},"error":{"description":"Why the connection test failed (the settings are saved anyway so they can be corrected)","type":"string"},"ldapBaseDn":{"type":"string"},"ldapBindDn":{"type":"string"},"ldapUrl":{"type":"string"}},"required":["ldapUrl","ldapBindDn","ldapBaseDn","connected"],"type":"object"},"LibraryFacets":{"additionalProperties":false,"properties":{"genres":{"description":"Most common first","items":{"$ref":"#/components/schemas/GenreCount"},"type":"array"},"maxRuntimeMinutes":{"format":"int64","type":"integer"},"movies":{"format":"int64","type":"integer"},"shows":{"format":"int64","type":"integer"},"yearMax":{"format":"int64","type":"integer"},"yearMin":{"format":"int64","type":"integer"}},"required":["movies","shows","genres","yearMin","yearMax","maxRuntimeMinutes"],"type":"object"},"LibraryItem":{"additionalProperties":false,"properties":{"addedAt":{"description":"When Jellyfin added it (unix seconds)","format":"int64","type":"integer"},"genres":{"items":{"type":"string"},"type":"array"},"plays":{"description":"Total plays by everyone","format":"int64","type":"integer"},"posterUrl":{"description":"Poster served from Jellyfin through Tipsarr","type":"string"},"rating":{"description":"Jellyfin community rating out of 10","format":"double","type":"number"},"runtimeMinutes":{"description":"Movie length, or typical episode length for a show","format":"int64","type":"integer"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"tmdbPosterPath":{"description":"TMDB poster in the person's language; apps prefer it to posterUrl so every screen shows the same poster","type":"string"},"type":{"enum":["movie","tv"],"type":"string"},"viewers":{"description":"People who watched it","format":"int64","type":"integer"},"watched":{"description":"The logged-in user watched it","type":"boolean"},"year":{"format":"int64","type":"integer"}},"required":["type","tmdbId","title","genres","addedAt","plays","viewers","watched"],"type":"object"},"LibraryList":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/LibraryItem"},"type":"array"},"page":{"format":"int64","type":"integer"},"total":{"format":"int64","type":"integer"},"totalPages":{"format":"int64","type":"integer"}},"required":["total","page","totalPages","items"],"type":"object"},"List":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"page":{"format":"int64","type":"integer"},"totalPages":{"format":"int64","type":"integer"}},"required":["page","totalPages","items"],"type":"object"},"ListIssuesResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/IssueView"},"type":"array"},"total":{"format":"int64","type":"integer"}},"required":["total","items"],"type":"object"},"ListWebhookPresetsResponse":{"additionalProperties":false,"properties":{"events":{"description":"Events a webhook may subscribe to","items":{"type":"string"},"type":"array"},"presets":{"items":{"$ref":"#/components/schemas/Preset"},"type":"array"}},"required":["presets","events"],"type":"object"},"LoginInputBody":{"additionalProperties":false,"properties":{"password":{"maxLength":512,"type":"string"},"username":{"maxLength":256,"minLength":1,"type":"string"}},"required":["username","password"],"type":"object"},"MovieScores":{"additionalProperties":false,"properties":{"imdb":{"$ref":"#/components/schemas/Score"},"imdbUrl":{"type":"string"},"metacritic":{"$ref":"#/components/schemas/Score"},"rottenTomatoes":{"$ref":"#/components/schemas/Score","description":"Critics score in percent"},"rottenTomatoesAudience":{"$ref":"#/components/schemas/Score","description":"Audience score in percent (only from Rotten Tomatoes' own search)"},"rottenTomatoesUrl":{"type":"string"}},"type":"object"},"Options":{"additionalProperties":false,"properties":{"instanceName":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/QualityProfile"},"type":"array"},"qualityProfileId":{"description":"The instance default","format":"int64","type":"integer"},"rootFolder":{"description":"The instance default","type":"string"},"rootFolders":{"items":{"$ref":"#/components/schemas/RootFolder"},"type":"array"}},"required":["instanceName","qualityProfileId","rootFolder","profiles","rootFolders"],"type":"object"},"Overview":{"additionalProperties":false,"properties":{"available":{"description":"The resolver answered its health check","type":"boolean"},"configured":{"description":"An address and a key are saved","type":"boolean"},"enabled":{"type":"boolean"},"error":{"description":"Why the resolver is not available","type":"string"},"files":{"additionalProperties":{"format":"int64","type":"integer"},"description":"Rows per state in Tipsarr's own table: queued, running, ready, failed","type":"object"},"health":{"$ref":"#/components/schemas/Health"}},"required":["enabled","configured","available","files"],"type":"object"},"PermissionDefaults":{"additionalProperties":false,"properties":{"movieQuotaDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"movieQuotaLimit":{"description":"Movie requests allowed per period, 0 = no limit","format":"int64","maximum":100000,"minimum":0,"type":"integer"},"permissions":{"description":"Mask new people start with (see flags)","format":"int64","type":"integer"},"tvQuotaDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"tvQuotaLimit":{"description":"Seasons of shows allowed per period, 0 = no limit","format":"int64","maximum":100000,"minimum":0,"type":"integer"}},"required":["permissions","movieQuotaLimit","movieQuotaDays","tvQuotaLimit","tvQuotaDays"],"type":"object"},"Person":{"additionalProperties":false,"properties":{"department":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["id","name"],"type":"object"},"PersonDetail":{"additionalProperties":false,"properties":{"biography":{"type":"string"},"birthday":{"type":"string"},"birthplace":{"type":"string"},"credits":{"description":"Cast credits, most popular first","items":{"$ref":"#/components/schemas/Item"},"type":"array"},"deathday":{"type":"string"},"department":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["credits","id","name"],"type":"object"},"PrefsBody":{"additionalProperties":false,"properties":{"discordId":{"description":"Discord user id (digits, empty clears it), used to mention the person","pattern":"^[0-9]{0,20}$","type":"string"},"language":{"description":"TMDB language, e.g. fr-FR or fr (empty = English)","type":"string"},"ratingSource":{"description":"Score shown on posters (empty = tmdb)","enum":["tmdb","imdb","metacritic","rottenTomatoes",""],"type":"string"},"region":{"description":"ISO 3166 country, e.g. FR (empty = none). Used for release dates and the box-office region","type":"string"},"telegramChatId":{"description":"Telegram chat id (empty clears it)","pattern":"^-?[0-9]{0,20}$","type":"string"}},"type":"object"},"Preset":{"additionalProperties":false,"properties":{"authType":{"enum":["none","bearer","basic"],"type":"string"},"headers":{"items":{"$ref":"#/components/schemas/Header"},"type":"array"},"id":{"type":"string"},"name":{"type":"string"},"notes":{"type":"string"},"template":{"description":"Liquid body; empty = the raw JSON envelope","type":"string"},"urlExample":{"type":"string"}},"required":["id","name","urlExample","template","headers","authType","notes"],"type":"object"},"ProbeBody":{"additionalProperties":false,"properties":{"apiKey":{"description":"Leave empty to use the saved key of the instance given by id","type":"string"},"id":{"type":"string"},"kind":{"enum":["radarr","sonarr"],"type":"string"},"url":{"format":"uri","type":"string"}},"required":["kind","url"],"type":"object"},"ProbeResult":{"additionalProperties":false,"properties":{"appName":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/QualityProfile"},"type":"array"},"rootFolders":{"items":{"$ref":"#/components/schemas/RootFolder"},"type":"array"},"version":{"type":"string"}},"required":["appName","version","profiles","rootFolders"],"type":"object"},"ProfileStats":{"additionalProperties":false,"properties":{"approved":{"format":"int64","type":"integer"},"available":{"format":"int64","type":"integer"},"declined":{"format":"int64","type":"integer"},"failed":{"format":"int64","type":"integer"},"movies":{"description":"Requested movies","format":"int64","type":"integer"},"pending":{"format":"int64","type":"integer"},"requests":{"format":"int64","type":"integer"},"shows":{"description":"Requested shows","format":"int64","type":"integer"},"watched":{"description":"Titles watched according to the Jellyfin history","format":"int64","type":"integer"},"watchlist":{"format":"int64","type":"integer"}},"required":["requests","movies","shows","pending","approved","available","declined","failed","watchlist","watched"],"type":"object"},"ProfileView":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"effectivePermissions":{"format":"int64","type":"integer"},"hasUploadedAvatar":{"description":"The person uploaded their own picture","type":"boolean"},"id":{"type":"string"},"language":{"type":"string"},"lastLoginAt":{"format":"int64","type":"integer"},"movieQuotaDays":{"format":"int64","type":"integer"},"movieQuotaLimit":{"format":"int64","type":"integer"},"name":{"type":"string"},"permissions":{"format":"int64","type":"integer"},"ratingSource":{"type":"string"},"region":{"type":"string"},"role":{"enum":["admin","user"],"type":"string"},"stats":{"$ref":"#/components/schemas/ProfileStats"},"tvQuotaDays":{"format":"int64","type":"integer"},"tvQuotaLimit":{"format":"int64","type":"integer"}},"required":["id","name","role","region","language","ratingSource","effectivePermissions","permissions","createdAt","lastLoginAt","stats","hasUploadedAvatar"],"type":"object"},"Progress":{"additionalProperties":false,"properties":{"etaSeconds":{"format":"int64","type":"integer"},"percent":{"format":"int64","type":"integer"}},"required":["percent","etaSeconds"],"type":"object"},"PushDevice":{"additionalProperties":false,"properties":{"categories":{"description":"Bitmask: 1 my requests, 2 new requests to approve (admins), 4 issues","format":"int64","type":"integer"},"language":{"type":"string"},"platform":{"enum":["ios","android"],"type":"string"},"sandbox":{"type":"boolean"},"server":{"type":"string"},"updatedAt":{"format":"int64","type":"integer"}},"required":["platform","sandbox","language","categories","updatedAt"],"type":"object"},"QualityProfile":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"maxResolution":{"description":"Highest resolution the profile allows (2160 = 4K), 0 when unknown","format":"int64","type":"integer"},"name":{"type":"string"},"uhd":{"description":"The profile allows 4K (2160p): only people with the 4K right may pick it","type":"boolean"}},"required":["id","name"],"type":"object"},"Quota":{"additionalProperties":false,"properties":{"movie":{"$ref":"#/components/schemas/QuotaWindow"},"tv":{"$ref":"#/components/schemas/QuotaWindow"}},"required":["movie","tv"],"type":"object"},"QuotaWindow":{"additionalProperties":false,"properties":{"days":{"format":"int64","type":"integer"},"limit":{"format":"int64","type":"integer"},"remaining":{"description":"Absent when there is no limit","format":"int64","type":"integer"},"resetsAt":{"description":"Unix time when the oldest counted request leaves the window","format":"int64","type":"integer"},"restricted":{"description":"The limit is reached: new requests are refused until ResetsAt","type":"boolean"},"used":{"format":"int64","type":"integer"}},"required":["days","limit","used","restricted"],"type":"object"},"RegisterDeviceInputBody":{"additionalProperties":false,"properties":{"categories":{"description":"Bitmask of the notifications wanted; default all (7), unchanged on refresh when omitted","format":"int64","maximum":7,"minimum":0,"type":"integer"},"language":{"description":"Language of the alert text (en or fr); default en","maxLength":16,"type":"string"},"pushToken":{"description":"APNs device token (hex) or FCM registration token","maxLength":1024,"minLength":20,"type":"string"},"sandbox":{"description":"iOS only: the token comes from a development build (APNs sandbox)","type":"boolean"},"server":{"description":"Host (and port) the app uses to reach this server; sent back in every alert so a phone with several accounts opens the right one","maxLength":255,"type":"string"}},"required":["pushToken"],"type":"object"},"RegisterPushRequest":{"additionalProperties":false,"properties":{"relayUrl":{"description":"Relay address; empty = the publisher's relay (pushDefaultRelayUrl)","type":"string"}},"type":"object"},"RequestCountsResponse":{"additionalProperties":false,"properties":{"approved":{"format":"int64","type":"integer"},"available":{"format":"int64","type":"integer"},"declined":{"format":"int64","type":"integer"},"failed":{"format":"int64","type":"integer"},"pending":{"format":"int64","type":"integer"}},"required":["pending","approved","available","declined","failed"],"type":"object"},"RequestList":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/View"},"type":"array"},"total":{"format":"int64","type":"integer"}},"required":["total","items"],"type":"object"},"Result":{"additionalProperties":false,"properties":{"generating":{"description":"A refresh is running in the background; a suggestions.updated event follows","type":"boolean"},"rows":{"items":{"$ref":"#/components/schemas/Row"},"type":"array"}},"required":["rows","generating"],"type":"object"},"Review":{"additionalProperties":false,"properties":{"author":{"type":"string"},"content":{"description":"Shortened to a few hundred characters","type":"string"},"createdAt":{"type":"string"},"rating":{"description":"The reviewer's own score out of 10, when given","format":"double","type":"number"},"url":{"type":"string"}},"required":["author","content"],"type":"object"},"RootFolder":{"additionalProperties":false,"properties":{"freeSpace":{"format":"int64","type":"integer"},"id":{"format":"int64","type":"integer"},"path":{"type":"string"}},"required":["id","path","freeSpace"],"type":"object"},"Row":{"additionalProperties":false,"properties":{"generatedAt":{"format":"int64","type":"integer"},"id":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"kind":{"enum":["account","because"],"type":"string"},"personal":{"description":"Built from this user's own watch history","type":"boolean"},"seed":{"$ref":"#/components/schemas/Seed"},"title":{"description":"English display title (clients should translate from variant + seed)","type":"string"},"variant":{"description":"What the row is based on, so clients can show it in the user's language","enum":["personal","server","trending","because"],"type":"string"}},"required":["id","kind","title","variant","personal","generatedAt","items"],"type":"object"},"Score":{"additionalProperties":false,"properties":{"value":{"description":"IMDb: out of 10. Metacritic and Rotten Tomatoes: out of 100","format":"double","type":"number"},"votes":{"format":"int64","type":"integer"}},"required":["value"],"type":"object"},"SearchResult":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"keywords":{"description":"Tags matching the query (only when tags=true)","items":{"$ref":"#/components/schemas/Keyword"},"type":"array"},"page":{"format":"int64","type":"integer"},"people":{"items":{"$ref":"#/components/schemas/Person"},"type":"array"},"tagged":{"description":"Titles carrying one of those tags that the text search did not return","items":{"$ref":"#/components/schemas/Item"},"type":"array"},"totalPages":{"format":"int64","type":"integer"}},"required":["page","totalPages","items","people","keywords","tagged"],"type":"object"},"Season":{"additionalProperties":false,"properties":{"airDate":{"type":"string"},"episodeCount":{"format":"int64","type":"integer"},"libraryEpisodes":{"description":"Episodes of this season already in the library","format":"int64","type":"integer"},"monitored":{"description":"Sonarr already monitors this season","type":"boolean"},"name":{"type":"string"},"number":{"format":"int64","type":"integer"},"posterPath":{"type":"string"}},"required":["number","name","episodeCount","libraryEpisodes","monitored"],"type":"object"},"SeasonDetail":{"additionalProperties":false,"properties":{"airDate":{"type":"string"},"episodes":{"items":{"$ref":"#/components/schemas/Episode"},"type":"array"},"name":{"type":"string"},"number":{"format":"int64","type":"integer"},"overview":{"type":"string"},"posterPath":{"type":"string"}},"required":["number","name","episodes"],"type":"object"},"SeasonProgress":{"additionalProperties":false,"properties":{"percent":{"description":"Episodes Sonarr has plus what is being downloaded, as a percentage of the season","format":"int64","type":"integer"},"season":{"format":"int64","type":"integer"}},"required":["season","percent"],"type":"object"},"SecretOutputBody":{"additionalProperties":false,"properties":{"value":{"description":"The secret; empty when it is not set (the dashboard key is off)","type":"string"}},"required":["value"],"type":"object"},"Seed":{"additionalProperties":false,"properties":{"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId","title"],"type":"object"},"SetBoxOfficeAliasRequest":{"additionalProperties":false,"properties":{"title":{"maxLength":255,"minLength":1,"type":"string"},"tmdbId":{"format":"int64","minimum":1,"type":"integer"}},"required":["title","tmdbId"],"type":"object"},"SetHistorySourcesRequest":{"additionalProperties":false,"properties":{"sources":{"description":"Jellyfin user ids","items":{"type":"string"},"maxItems":5,"type":"array"}},"required":["sources"],"type":"object"},"SettingsBody":{"additionalProperties":false,"properties":{"apiKeyConfigured":{"description":"The read-only dashboard key (X-Api-Key) is on; the key itself is read with POST /admin/secrets/api_key/reveal","type":"boolean"},"appPublicUrl":{"description":"Address people open Tipsarr at, e.g. https://tipsarr.example.org; webhook links ({{ url }}) start with it. Empty = no links","type":"string"},"boxofficeRegions":{"description":"Comma-separated box-office region codes, e.g. US,GB,FR","type":"string"},"defaultLanguage":{"description":"App-wide default language, e.g. fr-FR; empty = the browser decides. A person's own language wins","type":"string"},"dryRun":{"description":"When true nothing is ever sent to Radarr/Sonarr","type":"boolean"},"jellyfinApiKeyConfigured":{"description":"Needed for library and history sync","type":"boolean"},"jellyfinPublicUrl":{"description":"Address browsers use to open Jellyfin (Play buttons); empty = same as jellyfinUrl","type":"string"},"jellyfinUrl":{"type":"string"},"ldapBaseDn":{"description":"Where users live, e.g. ou=people,dc=example,dc=com","type":"string"},"ldapBindDn":{"type":"string"},"ldapBindPasswordConfigured":{"type":"boolean"},"ldapUrl":{"description":"LLDAP/LDAP address for profile pictures, e.g. ldap://lldap:3890","type":"string"},"oidcAdminGroup":{"description":"Members of this group become admins (optional)","type":"string"},"oidcClientId":{"type":"string"},"oidcClientSecretConfigured":{"type":"boolean"},"oidcGroupsClaim":{"description":"Claim that lists the groups, default groups","type":"string"},"oidcIssuer":{"description":"OpenID Connect provider (Authelia), e.g. https://auth.example.org","type":"string"},"oidcUsernameClaim":{"description":"Claim matched exactly (case included) to the Jellyfin username, default preferred_username","type":"string"},"pushDailyQuota":{"description":"Pushes per day the relay allows a public key; 0 = unlimited or unknown","format":"int64","type":"integer"},"pushDefaultRelayUrl":{"description":"The relay run by the app publisher, offered for registration","type":"string"},"pushEnabled":{"description":"Mobile push notifications through the relay (off by default)","type":"boolean"},"pushKeyPublic":{"description":"The key came from the relay's open registration: generic alerts only (pushText has no effect) and a daily quota","type":"boolean"},"pushPausedUntil":{"description":"Unix time when sending resumes after the relay's daily quota was reached; 0 = not paused","format":"int64","type":"integer"},"pushRelayKeyConfigured":{"description":"The relay key is write-only; this only says whether one is saved","type":"boolean"},"pushRelayUrl":{"description":"Address of the push relay, e.g. https://push.example.org","type":"string"},"pushText":{"description":"Alerts name the media (needs a relay that allows free text for this app); off: generic alerts","type":"boolean"},"servarrAutoImport":{"description":"Mirror what Radarr/Sonarr monitor but have not downloaded as approved requests (reads only)","type":"boolean"},"teasersFilesEnabled":{"description":"Teasers feed with trailer files from the optional trailer-resolver sidecar (off by default)","type":"boolean"},"teasersFilesKeyConfigured":{"description":"The resolver key is write-only; this only says whether one is saved","type":"boolean"},"teasersFilesMaxHeight":{"description":"Largest video height the resolver downloads: 360, 480, 720 or 1080","format":"int64","type":"integer"},"teasersFilesUrl":{"description":"Address of the trailer resolver on the Docker network, e.g. http://trailer-resolver:8080","type":"string"},"tmdbConfigured":{"description":"Secrets are write-only; this only says whether a key is saved","type":"boolean"},"webhookPath":{"description":"Path for the Jellyfin webhook plugin to call, with the secret in the X-Tipsarr-Token header (read it with POST /admin/secrets/jellyfin_webhook/reveal)","type":"string"},"webhookTokenConfigured":{"description":"The Jellyfin webhook secret exists; it is never returned here","type":"boolean"}},"required":["jellyfinUrl","jellyfinPublicUrl","tmdbConfigured","jellyfinApiKeyConfigured","boxofficeRegions","webhookPath","webhookTokenConfigured","dryRun","oidcIssuer","oidcClientId","oidcClientSecretConfigured","oidcAdminGroup","oidcGroupsClaim","oidcUsernameClaim","ldapUrl","ldapBindDn","ldapBindPasswordConfigured","ldapBaseDn","appPublicUrl","defaultLanguage","pushEnabled","pushRelayUrl","pushText","pushRelayKeyConfigured","pushKeyPublic","pushDailyQuota","pushPausedUntil","pushDefaultRelayUrl","servarrAutoImport","teasersFilesEnabled","teasersFilesUrl","teasersFilesKeyConfigured","teasersFilesMaxHeight","apiKeyConfigured"],"type":"object"},"SetupInputBody":{"additionalProperties":false,"properties":{"jellyfinUrl":{"description":"Base URL of the Jellyfin server","format":"uri","type":"string"},"setupToken":{"description":"One-time token printed in the server log at startup (required on a fresh install)","type":"string"},"tmdbApiKey":{"description":"TMDB API key or read token (can be added later)","type":"string"}},"required":["jellyfinUrl"],"type":"object"},"SetupOutputBody":{"additionalProperties":false,"properties":{"serverName":{"type":"string"}},"required":["serverName"],"type":"object"},"SetupStatusOutputBody":{"additionalProperties":false,"properties":{"configured":{"description":"True once a Jellyfin URL is saved","type":"boolean"},"tokenRequired":{"description":"The setup call needs the token printed in the server log (TIPSARR_SETUP_TOKEN=true)","type":"boolean"}},"required":["configured","tokenRequired"],"type":"object"},"StatsBucket":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"name":{"type":"string"},"titles":{"format":"int64","type":"integer"}},"required":["name","hours","titles"],"type":"object"},"StatsMonth":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"month":{"description":"YYYY-MM","type":"string"},"plays":{"format":"int64","type":"integer"}},"required":["month","hours","plays"],"type":"object"},"StatsPlugin":{"additionalProperties":false,"properties":{"hint":{"description":"Admins only: the Playback Reporting plugin is missing and would make these numbers exact","type":"boolean"}},"required":["hint"],"type":"object"},"StatsReport":{"additionalProperties":false,"properties":{"decades":{"items":{"$ref":"#/components/schemas/StatsBucket"},"type":"array"},"genres":{"items":{"$ref":"#/components/schemas/StatsBucket"},"type":"array"},"hoursOfDay":{"description":"Hours per hour of the day (plugin source only)","items":{"format":"double","type":"number"},"type":"array"},"months":{"description":"Last 12 months, oldest first (plugin source only)","items":{"$ref":"#/components/schemas/StatsMonth"},"type":"array"},"period":{"enum":["30d","12m","all"],"type":"string"},"plugin":{"$ref":"#/components/schemas/StatsPlugin"},"requests":{"$ref":"#/components/schemas/StatsRequests"},"source":{"description":"plugin = exact plays from Jellyfin's Playback Reporting; estimate = watch history x runtime","enum":["plugin","estimate"],"type":"string"},"top":{"description":"Everything watched at least once, most time spent first (movies and shows together)","items":{"$ref":"#/components/schemas/StatsTop"},"type":"array"},"topMovies":{"description":"Most watched movies","items":{"$ref":"#/components/schemas/StatsTop"},"type":"array"},"topShows":{"description":"Most watched shows","items":{"$ref":"#/components/schemas/StatsTop"},"type":"array"},"totals":{"$ref":"#/components/schemas/StatsTotals"},"user":{"description":"A user id, or all","type":"string"},"weekdays":{"description":"Hours per weekday, Monday first (plugin source only)","items":{"format":"double","type":"number"},"type":"array"}},"required":["source","user","period","totals","genres","decades","months","weekdays","hoursOfDay","top","topMovies","topShows","requests","plugin"],"type":"object"},"StatsRequests":{"additionalProperties":false,"properties":{"approved":{"format":"int64","type":"integer"},"available":{"format":"int64","type":"integer"},"declined":{"format":"int64","type":"integer"},"failed":{"format":"int64","type":"integer"},"made":{"format":"int64","type":"integer"},"pending":{"format":"int64","type":"integer"}},"required":["made","pending","approved","available","declined","failed"],"type":"object"},"StatsTop":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"plays":{"description":"Play sessions; for a show every episode watched counts as one","format":"int64","type":"integer"},"posterUrl":{"type":"string"},"rating":{"format":"double","type":"number"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"year":{"format":"int64","type":"integer"}},"required":["type","tmdbId","title","plays","hours"],"type":"object"},"StatsTotals":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"movies":{"format":"int64","type":"integer"},"plays":{"format":"int64","type":"integer"},"shows":{"format":"int64","type":"integer"},"titles":{"format":"int64","type":"integer"}},"required":["titles","movies","shows","plays","hours"],"type":"object"},"Status":{"additionalProperties":false,"properties":{"everySeconds":{"format":"int64","type":"integer"},"lastFinishedAt":{"format":"int64","type":"integer"},"lastStartedAt":{"format":"int64","type":"integer"},"message":{"type":"string"},"messageArgs":{"items":{"type":"string"},"type":"array"},"messageCode":{"type":"string"},"messageParams":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"type":"string"},"running":{"type":"boolean"},"status":{"type":"string"}},"required":["everySeconds","running","name","lastStartedAt","lastFinishedAt","status","message"],"type":"object"},"StatusFeatures":{"additionalProperties":false,"properties":{"push":{"description":"Push notifications are set up on this server","type":"boolean"},"teasers":{"description":"The Teasers feed is available: trailer files are on and the resolver answers. Apps hide the Teasers tab otherwise","type":"boolean"}},"required":["push","teasers"],"type":"object"},"StatusOutputBody":{"additionalProperties":false,"properties":{"apiVersion":{"format":"int64","type":"integer"},"db":{"description":"Database engine: sqlite, postgres or mysql","type":"string"},"defaultLanguage":{"type":"string"},"demo":{"description":"This server is a throwaway public demo sandbox: one-click sign-in, actions that reach other services are refused (demo_disabled)","type":"boolean"},"dryRun":{"description":"When true nothing is ever sent to Radarr/Sonarr","type":"boolean"},"features":{"$ref":"#/components/schemas/StatusFeatures"},"minAppVersion":{"type":"string"},"version":{"type":"string"}},"required":["version","db","dryRun","demo","defaultLanguage","apiVersion","minAppVersion","features"],"type":"object"},"SyncStatusBody":{"additionalProperties":false,"properties":{"canSync":{"description":"True when a Jellyfin API key is saved","type":"boolean"},"jobs":{"items":{"$ref":"#/components/schemas/Status"},"type":"array"},"movies":{"description":"Movies currently known in the Jellyfin library","format":"int64","type":"integer"},"shows":{"description":"Shows currently known in the Jellyfin library","format":"int64","type":"integer"}},"required":["canSync","movies","shows","jobs"],"type":"object"},"Teaser":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"trailerKey":{"description":"YouTube video key of the trailer, in the person's language when there is one","type":"string"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"videoHeight":{"format":"int64","type":"integer"},"videoUrl":{"description":"Trailer file served by this server (needs the same login as the API)","type":"string"},"voteAverage":{"format":"double","type":"number"},"watchlisted":{"type":"boolean"}},"required":["trailerKey","watchlisted","videoUrl","type","tmdbId","title","voteAverage","availability"],"type":"object"},"TeaserFilesTestRequest":{"additionalProperties":false,"properties":{"key":{"description":"YouTube video key; default is a short public video","type":"string"}},"type":"object"},"TeaserHistoryResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/HistoryItem"},"type":"array"},"total":{"description":"How many titles the history holds in all","format":"int64","type":"integer"}},"required":["items","total"],"type":"object"},"TeaserSeenRequest":{"additionalProperties":false,"properties":{"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"TestResult":{"additionalProperties":false,"properties":{"bytes":{"format":"int64","type":"integer"},"duration":{"format":"int64","type":"integer"},"error":{"type":"string"},"height":{"format":"int64","type":"integer"},"key":{"type":"string"},"seconds":{"description":"How long the resolver took (or how long we waited)","format":"double","type":"number"},"status":{"enum":["queued","running","ready","failed"],"type":"string"}},"required":["key","status","seconds","bytes","height","duration"],"type":"object"},"TokenBody":{"additionalProperties":false,"properties":{"expiresAt":{"description":"Unix seconds; every use renews it, see sessions","format":"int64","type":"integer"},"token":{"description":"Send as Authorization: Bearer \u003ctoken\u003e","type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","expiresAt","user"],"type":"object"},"TokenInputBody":{"additionalProperties":false,"properties":{"appVersion":{"maxLength":32,"type":"string"},"deviceName":{"description":"Shown in the signed-in devices list","maxLength":64,"type":"string"},"password":{"maxLength":512,"type":"string"},"platform":{"description":"Which app is signing in","enum":["ios","android"],"type":"string"},"username":{"maxLength":256,"minLength":1,"type":"string"}},"required":["username","password","platform"],"type":"object"},"UpdateRequestOptionsRequest":{"additionalProperties":false,"properties":{"qualityProfileId":{"description":"Omit for the instance default","format":"int64","type":"integer"},"rootFolder":{"description":"Empty for the instance default","type":"string"}},"type":"object"},"UpdateSettingsInputBody":{"additionalProperties":false,"properties":{"apiKeyEnabled":{"description":"Turn the read-only dashboard key on (a key is created if there is none) or off (the key is deleted)","type":"boolean"},"appPublicUrl":{"description":"http(s)://host[/path]; empty clears it","type":"string"},"boxofficeRegions":{"description":"Comma-separated region codes; empty resets to US","type":"string"},"defaultLanguage":{"description":"e.g. fr-FR; empty clears it","type":"string"},"jellyfinApiKey":{"description":"Jellyfin API key (Dashboard \u003e API Keys); empty string clears it","type":"string"},"jellyfinPublicUrl":{"description":"Empty string clears it","type":"string"},"ldapBaseDn":{"type":"string"},"ldapBindDn":{"type":"string"},"ldapBindPassword":{"description":"Write-only","type":"string"},"ldapUrl":{"description":"Empty string turns LDAP pictures off; the connection is tested when saving","type":"string"},"oidcAdminGroup":{"type":"string"},"oidcClientId":{"type":"string"},"oidcClientSecret":{"description":"Write-only; empty string clears it","type":"string"},"oidcGroupsClaim":{"type":"string"},"oidcIssuer":{"description":"Empty string turns single sign-on off. The provider is contacted when saving","type":"string"},"oidcUsernameClaim":{"description":"Empty string = preferred_username","type":"string"},"pushEnabled":{"description":"Needs the relay address and key; turning it on with either missing is refused","type":"boolean"},"pushRelayKey":{"description":"Write-only; empty string clears it (and turns push off)","type":"string"},"pushRelayUrl":{"description":"Empty string clears it (and turns push off)","type":"string"},"pushText":{"type":"boolean"},"regenerateApiKey":{"description":"True: replace the dashboard key with a new one (the old one stops working)","type":"boolean"},"servarrAutoImport":{"type":"boolean"},"teasersFilesEnabled":{"description":"Needs the resolver address and key; turning it on with either missing is refused","type":"boolean"},"teasersFilesKey":{"description":"Write-only; empty string clears it (and turns trailer files off)","type":"string"},"teasersFilesMaxHeight":{"description":"360, 480, 720 or 1080","format":"int64","type":"integer"},"teasersFilesUrl":{"description":"Empty string clears it (and turns trailer files off)","type":"string"},"tmdbApiKey":{"description":"Set or replace the TMDB key (empty string clears it)","type":"string"}},"type":"object"},"UpdateUserRequest":{"additionalProperties":false,"properties":{"discordId":{"description":"Discord user id, empty to clear","pattern":"^[0-9]{0,20}$","type":"string"},"language":{"description":"e.g. fr or fr-FR, empty to clear","type":"string"},"permissions":{"format":"int64","type":"integer"},"quota":{"$ref":"#/components/schemas/UserQuota"},"region":{"description":"2-letter country code, empty to clear","type":"string"},"role":{"enum":["admin","user"],"type":"string"},"telegramChatId":{"description":"Telegram chat id, empty to clear","pattern":"^-?[0-9]{0,20}$","type":"string"}},"type":"object"},"UpdateWebhookEgressRequest":{"additionalProperties":false,"properties":{"allowedHosts":{"description":"host, host:port or CIDR, e.g. ntfy.lan, 192.168.1.20:8123, 10.0.0.0/24","items":{"type":"string"},"maxItems":50,"type":"array"}},"required":["allowedHosts"],"type":"object"},"User":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"discordId":{"type":"string"},"effectivePermissions":{"format":"int64","type":"integer"},"id":{"type":"string"},"language":{"type":"string"},"lastLoginAt":{"format":"int64","type":"integer"},"movieQuotaDays":{"format":"int64","type":"integer"},"movieQuotaLimit":{"format":"int64","type":"integer"},"name":{"type":"string"},"permissions":{"format":"int64","type":"integer"},"ratingSource":{"type":"string"},"region":{"type":"string"},"role":{"type":"string"},"telegramChatId":{"type":"string"},"tvQuotaDays":{"format":"int64","type":"integer"},"tvQuotaLimit":{"format":"int64","type":"integer"}},"required":["id","name","role","region","language","ratingSource","discordId","telegramChatId","createdAt","lastLoginAt","permissions","effectivePermissions"],"type":"object"},"UserQuota":{"additionalProperties":false,"properties":{"movieDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"movieLimit":{"format":"int64","maximum":100000,"minimum":0,"type":"integer"},"tvDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"tvLimit":{"format":"int64","maximum":100000,"minimum":0,"type":"integer"}},"type":"object"},"UserRef":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"Variable":{"additionalProperties":false,"properties":{"description":{"description":"Localized description","type":"string"},"empty":{"description":"Not filled for this event","type":"boolean"},"example":{"description":"Value with the sample data (empty when Empty)","type":"string"},"path":{"description":"Liquid path, e.g. media.title","type":"string"},"perRecipient":{"description":"Only filled with one delivery per recipient","type":"boolean"},"type":{"description":"Value type","enum":["string","number","boolean"],"type":"string"}},"required":["path","type","example","description","empty","perRecipient"],"type":"object"},"View":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"decidedBy":{"$ref":"#/components/schemas/UserRef"},"declineReason":{"type":"string"},"dryRun":{"description":"Approved in dry-run mode: nothing was sent to Radarr/Sonarr","type":"boolean"},"error":{"type":"string"},"events":{"description":"What happened to the request and when, oldest first. Requests older than this history show an approximate one","items":{"$ref":"#/components/schemas/Event"},"type":"array"},"id":{"type":"string"},"posterPath":{"type":"string"},"progress":{"$ref":"#/components/schemas/Progress"},"qualityProfileId":{"description":"Chosen quality profile (absent = the instance default)","format":"int64","type":"integer"},"releaseDate":{"type":"string"},"requestedBy":{"$ref":"#/components/schemas/UserRef"},"rootFolder":{"description":"Chosen root folder (absent = the instance default)","type":"string"},"seasonProgress":{"description":"Shows only: completion per season","items":{"$ref":"#/components/schemas/SeasonProgress"},"type":"array"},"seasons":{"items":{"format":"int64","type":"integer"},"type":"array"},"source":{"description":"Set when the request was imported from what Radarr/Sonarr already monitor","type":"string"},"stage":{"description":"User-facing lifecycle step","enum":["requested","approved","searching","downloading","available","declined","failed"],"type":"string"},"status":{"enum":["pending","approved","declined","failed","available"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"uhd":{"description":"The quality profile allows 4K (2160p); absent when not (and from servers older than 4K requests)","type":"boolean"},"updatedAt":{"format":"int64","type":"integer"}},"required":["id","type","tmdbId","title","status","stage","requestedBy","dryRun","createdAt","updatedAt"],"type":"object"},"WebhookBody":{"additionalProperties":false,"properties":{"authSecret":{"description":"Bearer token or basic password (write-only). Empty string clears it; omit to keep it","maxLength":512,"type":"string"},"authType":{"enum":["none","bearer","basic",""],"type":"string"},"authUsername":{"maxLength":191,"type":"string"},"enabled":{"type":"boolean"},"events":{"description":"Any of request.created, request.approved, request.auto_approved, request.declined, request.failed, media.available, issue.created, issue.commented, issue.resolved, issue.reopened; empty means all. request.approved also receives request.auto_approved","items":{"type":"string"},"type":"array"},"headers":{"items":{"$ref":"#/components/schemas/Header"},"maxItems":20,"type":"array"},"name":{"maxLength":100,"minLength":1,"type":"string"},"perRecipient":{"type":"boolean"},"preset":{"maxLength":32,"type":"string"},"secret":{"description":"Signing secret (write-only). Empty string clears it; omit to keep it","type":"string"},"template":{"description":"Liquid body template; empty = the raw JSON envelope","maxLength":20000,"type":"string"},"url":{"description":"http(s) URL; may contain Liquid, rendered per delivery","maxLength":512,"minLength":1,"type":"string"}},"required":["name","url","enabled"],"type":"object"},"WebhookEgress":{"additionalProperties":false,"properties":{"allowedHosts":{"description":"Private targets webhooks may reach: host, host:port or CIDR","items":{"type":"string"},"type":"array"},"blocked":{"description":"Saved webhooks whose URL the policy refuses now","items":{"$ref":"#/components/schemas/WebhookRef"},"type":"array"}},"required":["allowedHosts","blocked"],"type":"object"},"WebhookInputBody":{"additionalProperties":false,"properties":{"ItemType":{"type":"string"},"NotificationType":{"description":"Jellyfin webhook plugin event, e.g. PlaybackStop or ItemAdded","type":"string"},"UserId":{"type":"string"}},"type":"object"},"WebhookPreview":{"additionalProperties":false,"properties":{"body":{"type":"string"},"delivered":{"description":"Test only: whether the receiver accepted it","type":"boolean"},"deliveryError":{"description":"Test only: why the delivery failed","type":"string"},"error":{"description":"Template or URL problem; nothing else is set then","type":"string"},"headers":{"description":"Secrets are masked","items":{"$ref":"#/components/schemas/Header"},"type":"array"},"url":{"type":"string"},"validJson":{"type":"boolean"}},"required":["url","headers","body","validJson"],"type":"object"},"WebhookRef":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"WebhookView":{"additionalProperties":false,"properties":{"authSecretConfigured":{"description":"A bearer token or basic password is saved (write-only)","type":"boolean"},"authType":{"enum":["none","bearer","basic"],"type":"string"},"authUsername":{"type":"string"},"blockedByPolicy":{"description":"The URL points at a private or local address the outbound policy refuses (see /admin/webhooks/egress)","type":"boolean"},"enabled":{"type":"boolean"},"events":{"description":"Subscribed events; empty means all","items":{"type":"string"},"type":"array"},"headers":{"items":{"$ref":"#/components/schemas/Header"},"type":"array"},"id":{"type":"string"},"name":{"type":"string"},"perRecipient":{"description":"One delivery per concerned person, with notifyUser filled","type":"boolean"},"preset":{"description":"Preset the webhook was made from (informative)","type":"string"},"secretConfigured":{"description":"A signing secret is saved (HMAC-SHA256 in X-Tipsarr-Signature)","type":"boolean"},"template":{"description":"Liquid body template; empty = the raw JSON envelope","type":"string"},"url":{"description":"May contain Liquid ({{ ... }})","type":"string"}},"required":["id","name","url","events","enabled","secretConfigured","preset","template","headers","authType","authUsername","authSecretConfigured","perRecipient","blockedByPolicy"],"type":"object"},"WeekRef":{"additionalProperties":false,"properties":{"end":{"description":"Last day covered, YYYY-MM-DD","type":"string"},"key":{"description":"Week key; weekly charts end with a \"w\"","type":"string"},"label":{"type":"string"},"start":{"description":"First day covered, YYYY-MM-DD","type":"string"},"weekly":{"description":"Full Monday-Sunday week instead of the Friday-Sunday weekend","type":"boolean"}},"required":["key","label","weekly"],"type":"object"}},"securitySchemes":{"bearer":{"scheme":"bearer","type":"http"},"session":{"in":"cookie","name":"tipsarr_session","type":"apiKey"}}},"info":{"title":"Tipsarr","version":"0.1.0"},"openapi":"3.1.0","paths":{"/admin/boxoffice/alias":{"delete":{"operationId":"deleteBoxOfficeAlias","parameters":[{"explode":false,"in":"query","name":"title","required":true,"schema":{"maxLength":255,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a manual match and match automatically again (admin)","tags":["admin"]},"put":{"description":"Fixes titles the automatic search matched wrongly or not at all. Applies to every stored week and to future charts.","operationId":"setBoxOfficeAlias","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetBoxOfficeAliasRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Pin a box-office title to a TMDB movie (admin)","tags":["admin"]}},"/admin/ldap/import-jellyfin":{"post":{"description":"Needs the Jellyfin API key. The password is copied server-side and never sent to the browser.","operationId":"importLdapFromJellyfin","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LdapImportBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Copy the LDAP settings of Jellyfin's LDAP Authentication plugin (admin)","tags":["admin"]}},"/admin/permissions":{"get":{"description":"Each person has a mask: the sum of the values of the flags they have. Admins can do everything whatever their mask says. Flags marked reserved exist so the numbers match Seerr but no feature uses them yet: do not show them.","operationId":"getPermissions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetPermissionsResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"The permission flags and the defaults for new people (needs the manage-users right)","tags":["admin"]}},"/admin/permissions/defaults":{"put":{"description":"Only changes people who sign in or are imported from now on.","operationId":"setPermissionDefaults","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PermissionDefaults"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PermissionDefaults"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Set the permissions and quotas new people start with (admin)","tags":["admin"]}},"/admin/push/register":{"post":{"description":"Asks the relay for a key of its own (POST /v1/register with this server's install id), saves it with the address and turns push on. Registering again replaces the key.","operationId":"registerPush","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterPushRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SettingsBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Turn mobile push on with the relay's open registration (admin)","tags":["admin"]}},"/admin/secrets/{name}/regenerate":{"post":{"description":"The old value stops working at once: update the Jellyfin webhook plugin or the dashboards. For api_key this also turns the key on.","operationId":"regenerateSecret","parameters":[{"in":"path","name":"name","required":true,"schema":{"enum":["jellyfin_webhook","api_key"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretOutputBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Replace a secret with a new one and show it (admin role only)","tags":["admin"]}},"/admin/secrets/{name}/reveal":{"post":{"description":"The Jellyfin webhook secret (X-Tipsarr-Token) or the dashboard key. POST so it is never cached nor read with the read-only key.","operationId":"revealSecret","parameters":[{"in":"path","name":"name","required":true,"schema":{"enum":["jellyfin_webhook","api_key"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretOutputBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Show a secret (admin role only)","tags":["admin"]}},"/admin/servarr":{"get":{"operationId":"listServarr","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/InstanceView"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Radarr/Sonarr instances (admin)","tags":["admin"]},"post":{"operationId":"createServarr","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceView"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a Radarr/Sonarr instance (admin)","tags":["admin"]}},"/admin/servarr/probe":{"post":{"description":"Read-only: uses a dry-run client, so it can never change anything in Radarr/Sonarr.","operationId":"probeServarr","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeResult"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Test a Radarr/Sonarr connection and list its quality profiles and root folders (admin)","tags":["admin"]}},"/admin/servarr/{id}":{"delete":{"operationId":"deleteServarr","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a Radarr/Sonarr instance (admin)","tags":["admin"]},"put":{"operationId":"updateServarr","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceView"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Update a Radarr/Sonarr instance (admin)","tags":["admin"]}},"/admin/settings":{"get":{"operationId":"getSettings","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SettingsBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Read settings (admin)","tags":["admin"]},"put":{"operationId":"updateSettings","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSettingsInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SettingsBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Update settings (admin)","tags":["admin"]}},"/admin/sync":{"get":{"operationId":"syncStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SyncStatusBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Library / history sync status (admin)","tags":["admin"]}},"/admin/sync/{job}":{"post":{"operationId":"runSyncJob","parameters":[{"in":"path","name":"job","required":true,"schema":{"enum":["library-sync","history-sync","boxoffice-refresh","servarr-import","playback-sync","ytdlp-update"],"type":"string"}}],"responses":{"202":{"description":"Accepted"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Run a sync job now, in the background (admin)","tags":["admin"]}},"/admin/teasers/files":{"delete":{"operationId":"teaserFilesClear","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Empty the resolver's cache and forget every file (admin)","tags":["admin"]},"get":{"description":"The Teasers feed plays trailers from files fetched by the optional trailer-resolver sidecar. The resolver is asked for its health now.","operationId":"teaserFilesOverview","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Overview"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Trailer files: settings state, resolver health and file counts (admin)","tags":["admin"]}},"/admin/teasers/files/test":{"post":{"description":"Uses the saved resolver address and key even while the feature is off. Waits up to 90 seconds; if the video is not ready by then the state is queued or running and the test can be repeated.","operationId":"teaserFilesTest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeaserFilesTestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestResult"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Resolve one video through the resolver and report time and size (admin)","tags":["admin"]}},"/admin/users":{"get":{"operationId":"listUsers","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/User"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"All Tipsarr users (admin)","tags":["admin"]}},"/admin/users/import":{"post":{"description":"Adds Jellyfin users who have not logged in to Tipsarr yet (needs the Jellyfin API key). Existing users keep their role.","operationId":"importUsers","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportUsersResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Import users from Jellyfin (admin)","tags":["admin"]}},"/admin/users/{id}":{"patch":{"operationId":"updateUser","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Change a user's role, region, language, permissions or quotas (needs the manage-users right; roles are for admins)","tags":["admin"]}},"/admin/users/{id}/history-sources":{"get":{"operationId":"adminHistorySources","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistorySourcesBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Whose watch history a person's recommendations also use (admin only)","tags":["admin"]},"put":{"description":"Admins only, not even the manage-users right: it exposes what one account watched to another person. Replaces the list. An empty list goes back to the person's own history. The sources must be Tipsarr users (import the shared account from Jellyfin first); a person cannot be their own source; at most 5. Only one level is followed: a source's own sources are not used. The person's recommendations are rebuilt in the background.","operationId":"setHistorySources","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetHistorySourcesRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistorySourcesBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Set whose watch history a person's recommendations also use (admin only)","tags":["admin"]}},"/admin/users/{id}/teasers/history":{"get":{"operationId":"adminTeaserHistory","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}},{"explode":false,"in":"query","name":"limit","schema":{"default":40,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"offset","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTeaserHistoryResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"A person's Teasers history, newest first (needs the manage-users right)","tags":["admin"]}},"/admin/users/{id}/teasers/seen":{"delete":{"operationId":"adminTeaserForgetAll","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Wipe a person's whole Teasers history (needs the manage-users right)","tags":["admin"]}},"/admin/users/{id}/teasers/seen/{type}/{tmdbId}":{"delete":{"operationId":"adminTeaserForget","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}},{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"tmdbId","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Forget one title of a person's Teasers history (needs the manage-users right)","tags":["admin"]}},"/admin/webhooks":{"get":{"operationId":"listWebhooks","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/WebhookView"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Outgoing webhooks (admin)","tags":["admin"]},"post":{"operationId":"createWebhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookView"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add an outgoing webhook (admin)","tags":["admin"]}},"/admin/webhooks/egress":{"get":{"description":"Webhooks may only reach public addresses, plus the private ones listed here. Loopback and link-local addresses are always refused.","operationId":"getWebhookEgress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEgress"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Outbound policy of webhooks (admin)","tags":["admin"]},"put":{"operationId":"updateWebhookEgress","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEgressRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEgress"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Set the private hosts webhooks may reach (admin)","tags":["admin"]}},"/admin/webhooks/presets":{"get":{"operationId":"listWebhookPresets","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebhookPresetsResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Ready-made webhook templates and the events (admin)","tags":["admin"]}},"/admin/webhooks/preview":{"post":{"description":"Renders the URL, headers and body for a made-up event. With id, saved secrets are used for what the form leaves out.","operationId":"previewWebhook","parameters":[{"description":"Saved webhook whose secrets fill what the form omits","explode":false,"in":"query","name":"id","schema":{"description":"Saved webhook whose secrets fill what the form omits","maxLength":32,"type":"string"}},{"description":"Event to simulate (default test)","explode":false,"in":"query","name":"event","schema":{"description":"Event to simulate (default test)","maxLength":40,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPreview"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Render a webhook form with sample values, without sending (admin)","tags":["admin"]}},"/admin/webhooks/variables":{"get":{"description":"Built from the same variables as real deliveries, with the sample data of the preview. empty marks what the event does not fill; perRecipient what only one delivery per recipient fills.","operationId":"listWebhookVariables","parameters":[{"description":"Event (default test)","explode":false,"in":"query","name":"event","schema":{"description":"Event (default test)","maxLength":40,"type":"string"}},{"description":"Language of the descriptions (default: the caller's)","explode":false,"in":"query","name":"lang","schema":{"description":"Language of the descriptions (default: the caller's)","maxLength":16,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Variable"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Template variables of an event, with sample values (admin)","tags":["admin"]}},"/admin/webhooks/{id}":{"delete":{"operationId":"deleteWebhook","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Delete an outgoing webhook (admin)","tags":["admin"]},"put":{"operationId":"updateWebhook","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookView"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Update an outgoing webhook (admin)","tags":["admin"]}},"/admin/webhooks/{id}/test":{"post":{"description":"204 when delivered. 422 template_error when the template does not render (the message says where), 502 when the receiver fails.","operationId":"testWebhook","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Send a test event to a webhook (admin)","tags":["admin"]}},"/auth/demo":{"post":{"description":"Only on a demo sandbox (status.demo): signs in as the seeded admin or as a plain user, without a password.","operationId":"demoLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoLoginInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"summary":"One-click sign-in of the public demo","tags":["auth"]}},"/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"summary":"Log in with a Jellyfin account","tags":["auth"]}},"/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"End the current session","tags":["auth"]}},"/auth/methods":{"get":{"description":"The login page hides the password form when single sign-on is set up; it comes back if single sign-on refuses someone.","operationId":"authMethods","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthMethods"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"How people can sign in","tags":["auth"]}},"/auth/token":{"post":{"operationId":"loginToken","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"summary":"Log in from an app and get a bearer token","tags":["auth"]}},"/blocklist":{"get":{"operationId":"list_blocklist","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"My blocklist (titles hidden from my suggestions)","tags":["blocklist"]},"post":{"operationId":"add_blocklist","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Add_blocklistRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a title to my blocklist (titles hidden from my suggestions)","tags":["blocklist"]}},"/blocklist/{type}/{id}":{"delete":{"operationId":"remove_blocklist","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a title from my blocklist (titles hidden from my suggestions)","tags":["blocklist"]}},"/boxoffice":{"get":{"description":"Global, not personalised: one chart per region and week. Defaults to your region (if configured) and the latest stored week. Display only: nothing is added automatically.","operationId":"boxOffice","parameters":[{"description":"Region code, e.g. US, GB, FR","explode":false,"in":"query","name":"region","schema":{"description":"Region code, e.g. US, GB, FR","maxLength":3,"type":"string"}},{"description":"Week key, e.g. 2026W40 (a trailing w means the full week)","explode":false,"in":"query","name":"week","schema":{"description":"Week key, e.g. 2026W40 (a trailing w means the full week)","maxLength":12,"type":"string"}},{"description":"List full Monday-Sunday weeks instead of weekends","explode":false,"in":"query","name":"weekly","schema":{"description":"List full Monday-Sunday weeks instead of weekends","type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Chart"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Box-office chart (top 10): weekend or full week with availability and Radarr status","tags":["boxoffice"]}},"/collection/{id}":{"get":{"operationId":"collection","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionDetail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Movie collection","tags":["media"]}},"/discover/genres/{type}":{"get":{"operationId":"discoverGenres","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Genre"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Genre list","tags":["discover"]}},"/discover/keywords/{id}":{"get":{"operationId":"keyword","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Keyword"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"One tag (keyword) by id","tags":["discover"]}},"/discover/movies":{"get":{"operationId":"discover_movie","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}},{"description":"TMDB genre id","explode":false,"in":"query","name":"genre","schema":{"description":"TMDB genre id","format":"int64","maximum":1000000,"minimum":0,"type":"integer"}},{"description":"TMDB keyword (tag) id","explode":false,"in":"query","name":"keyword","schema":{"description":"TMDB keyword (tag) id","format":"int64","maximum":100000000,"minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Popular movie titles, optionally by genre","tags":["discover"]}},"/discover/trending":{"get":{"operationId":"discoverTrending","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Trending movies and shows this week","tags":["discover"]}},"/discover/tv":{"get":{"operationId":"discover_tv","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}},{"description":"TMDB genre id","explode":false,"in":"query","name":"genre","schema":{"description":"TMDB genre id","format":"int64","maximum":1000000,"minimum":0,"type":"integer"}},{"description":"TMDB keyword (tag) id","explode":false,"in":"query","name":"keyword","schema":{"description":"TMDB keyword (tag) id","format":"int64","maximum":100000000,"minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Popular tv titles, optionally by genre","tags":["discover"]}},"/discover/upcoming":{"get":{"operationId":"discoverUpcoming","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Upcoming movies","tags":["discover"]}},"/health":{"get":{"operationId":"health","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthOutputBody"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Liveness probe","tags":["system"]}},"/hooks/jellyfin":{"post":{"description":"Send the secret in the X-Tipsarr-Token header (the Webhook plugin's Generic destination can add request headers); the token query parameter still works. Configure the Jellyfin Webhook plugin with a Generic destination pointing here and a template like {\"NotificationType\":\"{{NotificationType}}\",\"UserId\":\"{{UserId}}\",\"ItemType\":\"{{ItemType}}\"}. Library events refresh the library; everything else refreshes that user's watch history (debounced).","operationId":"jellyfinWebhook","parameters":[{"description":"The shared secret (preferred: a header does not end up in proxy and access logs)","in":"header","name":"X-Tipsarr-Token","schema":{"description":"The shared secret (preferred: a header does not end up in proxy and access logs)","type":"string"}},{"description":"Deprecated (will be removed in a future major version): the same secret in the address, still accepted so existing set-ups keep working, but addresses end up in logs","explode":false,"in":"query","name":"token","schema":{"description":"Deprecated (will be removed in a future major version): the same secret in the address, still accepted so existing set-ups keep working, but addresses end up in logs","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookInputBody"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Receives Jellyfin webhook events (shared-secret token)","tags":["hooks"]}},"/issues":{"get":{"operationId":"listIssues","parameters":[{"explode":false,"in":"query","name":"filter","schema":{"default":"all","enum":["all","open","resolved"],"type":"string"}},{"description":"With tmdbId: only the issues of one title","explode":false,"in":"query","name":"type","schema":{"description":"With tmdbId: only the issues of one title","maxLength":5,"type":"string"}},{"explode":false,"in":"query","name":"tmdbId","schema":{"format":"int64","minimum":0,"type":"integer"}},{"explode":false,"in":"query","name":"take","schema":{"default":20,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"skip","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListIssuesResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"List issues (admins see everyone's, users their own)","tags":["issues"]},"post":{"description":"Admins are notified through the webhooks (issue.created) and the live event stream.","operationId":"createIssue","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateIssueRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Report a problem with a movie or show","tags":["issues"]}},"/issues/counts":{"get":{"operationId":"issueCounts","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueCounts"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Open and resolved issue totals","tags":["issues"]}},"/issues/{id}":{"delete":{"operationId":"deleteIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Delete an issue (admin)","tags":["issues"]},"get":{"operationId":"getIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"One issue with its comments","tags":["issues"]}},"/issues/{id}/comments":{"post":{"operationId":"commentIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentIssueRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a comment (reporter or admin)","tags":["issues"]}},"/issues/{id}/reopen":{"post":{"operationId":"reopenIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Reopen an issue (reporter or admin)","tags":["issues"]}},"/issues/{id}/resolve":{"post":{"operationId":"resolveIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Resolve an issue (reporter or admin)","tags":["issues"]}},"/issues/{id}/servarr":{"get":{"description":"The address of its page, built from the instance's public URL (or its URL when none is set). Read-only.","operationId":"issueServarrLink","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArrLink"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Where the title of an issue lives in Radarr/Sonarr (admin)","tags":["issues"]}},"/issues/{id}/servarr/search":{"post":{"description":"Not an interactive search: Radarr/Sonarr picks the release itself and ignores one that is no upgrade. In dry-run mode nothing is sent and dryRun is true.","operationId":"issueServarrSearch","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueServarrSearchResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Start an automatic Radarr/Sonarr search for the title of an issue (admin)","tags":["issues"]}},"/library":{"get":{"description":"Data comes from the library sync (genres, year, rating, runtime, date added from Jellyfin). `genre` may be repeated: with `genreMode=all` (default) a title must have all of them, with `any` at least one. `watched` is about the logged-in user.","operationId":"listLibrary","parameters":[{"explode":false,"in":"query","name":"type","schema":{"default":"all","enum":["all","movie","tv"],"type":"string"}},{"description":"Part of the title","explode":false,"in":"query","name":"q","schema":{"description":"Part of the title","maxLength":100,"type":"string"}},{"description":"Repeat for several; see genreMode","explode":true,"in":"query","name":"genre","schema":{"description":"Repeat for several; see genreMode","items":{"type":"string"},"maxItems":6,"type":"array"}},{"description":"Titles must have all the genres, or at least one","explode":false,"in":"query","name":"genreMode","schema":{"default":"all","description":"Titles must have all the genres, or at least one","enum":["all","any"],"type":"string"}},{"explode":false,"in":"query","name":"yearFrom","schema":{"format":"int64","maximum":2200,"minimum":0,"type":"integer"}},{"explode":false,"in":"query","name":"yearTo","schema":{"format":"int64","maximum":2200,"minimum":0,"type":"integer"}},{"explode":false,"in":"query","name":"minRating","schema":{"format":"double","maximum":10,"minimum":0,"type":"number"}},{"description":"Minutes","explode":false,"in":"query","name":"maxRuntime","schema":{"description":"Minutes","format":"int64","maximum":1000,"minimum":0,"type":"integer"}},{"description":"By the logged-in user","explode":false,"in":"query","name":"watched","schema":{"default":"any","description":"By the logged-in user","enum":["any","yes","no"],"type":"string"}},{"description":"Admins only: titles nobody ever watched (library clean-up)","explode":false,"in":"query","name":"neverWatched","schema":{"description":"Admins only: titles nobody ever watched (library clean-up)","type":"boolean"}},{"explode":false,"in":"query","name":"sort","schema":{"default":"added","enum":["added","title","year","rating","runtime","popular"],"type":"string"}},{"explode":false,"in":"query","name":"dir","schema":{"default":"desc","enum":["asc","desc"],"type":"string"}},{"explode":false,"in":"query","name":"page","schema":{"default":1,"format":"int64","minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"pageSize","schema":{"default":48,"format":"int64","maximum":100,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LibraryList"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Browse what is in Jellyfin, with filters and sorting","tags":["library"]}},"/library/facets":{"get":{"operationId":"libraryFacets","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LibraryFacets"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Genres (with counts), years and counts to build the Library filters","tags":["library"]}},"/me":{"get":{"operationId":"me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Current user","tags":["auth"]},"patch":{"operationId":"updateMe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PrefsBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Change my region, language, rating source and chat ids","tags":["auth"]}},"/me/devices/current":{"delete":{"operationId":"unregisterDevice","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Stop push notifications on this device","tags":["push"]},"get":{"operationId":"getDevice","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushDevice"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"This app's push registration","tags":["push"]},"put":{"description":"Call it at every launch and whenever the OS hands out a new token or the user changes the notification toggles. The registration belongs to the current session and disappears when it ends.","operationId":"registerDevice","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterDeviceInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushDevice"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Register or refresh this app's push token","tags":["push"]}},"/me/history-sources":{"get":{"description":"Set by an admin (a shared TV account, say). Read-only here: it explains why titles that someone else watched appear in Discover.","operationId":"myHistorySources","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistorySourcesBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Whose watch history your recommendations also use","tags":["me"]}},"/me/quota":{"get":{"description":"Movies count one per request, shows one per season, over the last N days. Admins and people who manage requests have no limit.","operationId":"myQuota","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quota"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"How many requests the signed-in person may still make","tags":["requests"]}},"/me/sessions":{"delete":{"operationId":"revokeOtherSessions","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Sign out everywhere except here","tags":["auth"]},"get":{"operationId":"listSessions","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/DeviceSession"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Where I am signed in","tags":["auth"]}},"/me/sessions/{id}":{"delete":{"operationId":"revokeSession","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":16,"minLength":16,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Sign one of my devices out","tags":["auth"]}},"/media/movie/{id}/ratings":{"get":{"description":"From Radarr; Rotten Tomatoes' own search fills in when Radarr has no Rotten Tomatoes score. Empty when nothing is found.","operationId":"movieRatings","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MovieScores"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"IMDb, Metacritic and Rotten Tomatoes scores of a movie","tags":["media"]}},"/media/tv/{id}/ratings":{"get":{"description":"Radarr only knows movies, so shows get Rotten Tomatoes' own search. Empty when nothing is found.","operationId":"showRatings","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MovieScores"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Rotten Tomatoes scores of a show","tags":["media"]}},"/media/tv/{id}/seasons/{season}":{"get":{"operationId":"tvSeason","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}},{"in":"path","name":"season","required":true,"schema":{"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeasonDetail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Episodes of a season","tags":["media"]}},"/media/{type}/{id}":{"get":{"operationId":"mediaDetail","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Detail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Movie or show details with cast, recommendations and similar titles","tags":["media"]}},"/media/{type}/{id}/flags":{"get":{"operationId":"mediaFlags","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Flags"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Is this title on my watchlist / blocklist?","tags":["media"]}},"/person/{id}":{"get":{"operationId":"person","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDetail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Person with credits","tags":["media"]}},"/ratings/movies":{"get":{"description":"`ids` is a comma-separated list of TMDB ids (at most 40). Movies whose scores are not ready within a few seconds are left out; ask again later.","operationId":"moviesRatings","parameters":[{"explode":false,"in":"query","name":"ids","required":true,"schema":{"maxLength":400,"type":"string"}},{"description":"The score the posters show; Rotten Tomatoes' own search is only asked for rottenTomatoes","explode":false,"in":"query","name":"source","schema":{"description":"The score the posters show; Rotten Tomatoes' own search is only asked for rottenTomatoes","enum":["tmdb","imdb","metacritic","rottenTomatoes",""],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/MovieScores"},"type":"object"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Scores of several movies at once, for the posters of a page","tags":["media"]}},"/requests":{"get":{"operationId":"listRequests","parameters":[{"explode":false,"in":"query","name":"filter","schema":{"default":"all","enum":["all","mine","pending","approved","available","declined","failed","unwatched"],"type":"string"}},{"description":"Admins only: only this user's requests","explode":false,"in":"query","name":"user","schema":{"description":"Admins only: only this user's requests","maxLength":64,"type":"string"}},{"explode":false,"in":"query","name":"take","schema":{"default":20,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"skip","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestList"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"List requests (admins see everyone's, users their own)","tags":["requests"]},"post":{"description":"Never automatic: this is always a user action. Admin requests are approved immediately. In dry-run mode an approved request is recorded but nothing is sent to Radarr/Sonarr.","operationId":"createRequest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRequestRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Request a movie or show","tags":["requests"]}},"/requests/counts":{"get":{"operationId":"requestCounts","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestCountsResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Requests per status","tags":["requests"]}},"/requests/options":{"get":{"description":"Read-only calls to the default Radarr (movie) or Sonarr (tv) instance.","operationId":"requestOptions","parameters":[{"explode":false,"in":"query","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Options"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Quality profiles (and, when allowed, root folders) you can pick when requesting","tags":["requests"]}},"/requests/{id}":{"delete":{"operationId":"deleteRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Delete a request (admin: any; owner: unfinished ones). A request still waiting in Radarr/Sonarr is removed there too (files are kept)","tags":["requests"]},"get":{"operationId":"getRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"One request","tags":["requests"]},"patch":{"operationId":"updateRequestOptions","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRequestOptionsRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Change the quality profile / root folder of a pending or failed request (requester or admin)","tags":["requests"]}},"/requests/{id}/approve":{"post":{"operationId":"approveRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApproveRequestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Approve and send to Radarr/Sonarr (admin; also retries a failed request)","tags":["requests"]}},"/requests/{id}/decline":{"post":{"operationId":"declineRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeclineRequestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Decline a request (admin)","tags":["requests"]}},"/requests/{id}/progress":{"get":{"operationId":"requestProgress","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Live stage and download progress","tags":["requests"]}},"/requests/{id}/retry":{"post":{"operationId":"retryRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Retry a failed request (admin)","tags":["requests"]}},"/search":{"get":{"operationId":"search","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"q","required":true,"schema":{"maxLength":200,"minLength":1,"type":"string"}},{"description":"Also look for titles carrying a matching tag (slower; for the results page)","explode":false,"in":"query","name":"tags","schema":{"description":"Also look for titles carrying a matching tag (slower; for the results page)","type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Search movies, shows and people","tags":["discover"]}},"/setup":{"post":{"description":"Only allowed until a Jellyfin URL is saved, and, when TIPSARR_SETUP_TOKEN=true, only with the setup token printed in the server log. Afterwards the first Jellyfin administrator to log in becomes the Tipsarr admin.","operationId":"setup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupOutputBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"First-run setup: point Tipsarr at Jellyfin","tags":["setup"]}},"/setup/status":{"get":{"operationId":"setupStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupStatusOutputBody"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Has first-run setup been completed?","tags":["setup"]}},"/stats":{"get":{"description":"Exact when Jellyfin's Playback Reporting plugin is installed (its plays are copied into Tipsarr), otherwise estimated from the watch history. People see their own numbers; `user` and `all` need the manage-users right.","operationId":"getStats","parameters":[{"description":"A user id, or all (admins). Default: yourself","explode":false,"in":"query","name":"user","schema":{"description":"A user id, or all (admins). Default: yourself","maxLength":64,"type":"string"}},{"explode":false,"in":"query","name":"period","schema":{"default":"all","enum":["30d","12m","all"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsReport"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Watching and request statistics","tags":["stats"]}},"/status":{"get":{"operationId":"status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusOutputBody"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Version and runtime info","tags":["system"]}},"/suggestions":{"get":{"description":"Generated the first time it is called, then recomputed only when your watch history changed. Display only: nothing is ever requested automatically.","operationId":"suggestions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Result"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Your suggestion rows: \"Recommended for you\" and \"Because you watched …\"","tags":["suggestions"]}},"/suggestions/refresh":{"post":{"operationId":"refreshSuggestions","responses":{"202":{"description":"Accepted"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Recompute my suggestions in the background (once a minute at most)","tags":["suggestions"]}},"/teasers":{"get":{"description":"Answers 404 (code teasers_disabled) when trailer files are not set up on this server: apps then hide the tab. Titles you have not swiped past yet, mixed from your suggestions, trending and upcoming, each with a trailer in your language. Skips titles you watched, blocked, requested or already have in the library. Display only: nothing is requested automatically.","operationId":"teasers","parameters":[{"explode":false,"in":"query","name":"limit","schema":{"default":8,"format":"int64","maximum":20,"minimum":1,"type":"integer"}},{"description":"Comma-separated type:id keys the app already holds, for example movie:603,tv:1399","explode":false,"in":"query","name":"exclude","schema":{"description":"Comma-separated type:id keys the app already holds, for example movie:603,tv:1399","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Feed"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"The next trailers of the Teasers feed","tags":["teasers"]}},"/teasers/history":{"get":{"description":"Open to every signed-in person (their own history). Titles are looked up in your language.","operationId":"teaserHistory","parameters":[{"explode":false,"in":"query","name":"limit","schema":{"default":40,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"offset","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeaserHistoryResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Titles you swiped past in the Teasers feed, newest first","tags":["teasers"]}},"/teasers/seen":{"delete":{"operationId":"teaserForgetAll","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Wipe your whole Teasers history: every title can come back in the feed","tags":["teasers"]},"post":{"operationId":"teaserSeen","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeaserSeenRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Mark a title as swiped past, so the feed never shows it again","tags":["teasers"]}},"/teasers/seen/{type}/{id}":{"delete":{"operationId":"teaserForget","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Forget one title of your Teasers history","tags":["teasers"]}},"/users/{id}":{"get":{"description":"Everyone can read their own profile; admins can read anyone's.","operationId":"userProfile","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileView"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"A user's profile and request statistics","tags":["users"]}},"/watchlist":{"get":{"operationId":"list_watchlist","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"My watchlist","tags":["watchlist"]},"post":{"operationId":"add_watchlist","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Add_watchlistRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a title to my watchlist","tags":["watchlist"]}},"/watchlist/{type}/{id}":{"delete":{"operationId":"remove_watchlist","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a title from my watchlist","tags":["watchlist"]}}},"servers":[{"url":"/api/v1"}]} +{"components":{"schemas":{"Add_blocklistRequest":{"additionalProperties":false,"properties":{"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"Add_watchlistRequest":{"additionalProperties":false,"properties":{"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"AdminTeaserHistoryResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/HistoryItem"},"type":"array"},"total":{"format":"int64","type":"integer"}},"required":["items","total"],"type":"object"},"ApproveRequestRequest":{"additionalProperties":false,"properties":{"qualityProfileId":{"format":"int64","type":"integer"},"rootFolder":{"type":"string"}},"type":"object"},"ArrLink":{"additionalProperties":false,"properties":{"kind":{"enum":["radarr","sonarr"],"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"required":["kind","name","url"],"type":"object"},"AuthMethods":{"additionalProperties":false,"properties":{"password":{"description":"The Jellyfin username/password form works","type":"boolean"},"sso":{"description":"Single sign-on (OpenID Connect) is set up","type":"boolean"},"ssoLabel":{"description":"Name of the provider for the button","type":"string"}},"required":["password","sso"],"type":"object"},"Breaker":{"additionalProperties":false,"properties":{"state":{"enum":["closed","open"],"type":"string"},"until":{"format":"int64","type":"integer"}},"required":["state","until"],"type":"object"},"CastMember":{"additionalProperties":false,"properties":{"character":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["id","name"],"type":"object"},"Chart":{"additionalProperties":false,"properties":{"end":{"type":"string"},"entries":{"items":{"$ref":"#/components/schemas/ChartEntry"},"type":"array"},"fetchedAt":{"format":"int64","type":"integer"},"hasWeekly":{"description":"Weekly charts are stored for this region","type":"boolean"},"label":{"type":"string"},"region":{"type":"string"},"regions":{"items":{"type":"string"},"type":"array"},"start":{"type":"string"},"week":{"description":"Week key, e.g. 2026W40; empty when nothing is stored yet","type":"string"},"weekly":{"type":"boolean"},"weeks":{"items":{"$ref":"#/components/schemas/WeekRef"},"type":"array"}},"required":["region","regions","week","weekly","hasWeekly","fetchedAt","weeks","entries"],"type":"object"},"ChartEntry":{"additionalProperties":false,"properties":{"hasFile":{"description":"Radarr already has the file","type":"boolean"},"inRadarr":{"description":"Radarr already tracks this movie (a request would be redundant)","type":"boolean"},"item":{"$ref":"#/components/schemas/Item","description":"Matched TMDB title; absent when no match was found"},"position":{"format":"int64","type":"integer"},"title":{"type":"string"},"totalGross":{"format":"int64","type":"integer"},"weekendGross":{"description":"USD (or the region's currency) for the weekend","format":"int64","type":"integer"},"weeksInRelease":{"format":"int64","type":"integer"}},"required":["position","title","weekendGross","totalGross","weeksInRelease","inRadarr","hasFile"],"type":"object"},"CollectionDetail":{"additionalProperties":false,"properties":{"backdropPath":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"overview":{"type":"string"},"parts":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"posterPath":{"type":"string"}},"required":["id","name","parts"],"type":"object"},"CommentIssueRequest":{"additionalProperties":false,"properties":{"message":{"maxLength":2000,"minLength":1,"type":"string"}},"required":["message"],"type":"object"},"Counts":{"additionalProperties":false,"properties":{"failed":{"format":"int64","type":"integer"},"queued":{"format":"int64","type":"integer"},"ready":{"format":"int64","type":"integer"},"running":{"format":"int64","type":"integer"}},"required":["queued","running","ready","failed"],"type":"object"},"CreateIssueRequest":{"additionalProperties":false,"properties":{"episode":{"description":"TV only, 0 = whole season","format":"int64","minimum":0,"type":"integer"},"kind":{"enum":["video","audio","subtitles","other"],"type":"string"},"message":{"maxLength":2000,"minLength":1,"type":"string"},"season":{"description":"TV only, 0 = whole show","format":"int64","minimum":0,"type":"integer"},"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId","kind","message"],"type":"object"},"CreateRequestRequest":{"additionalProperties":false,"properties":{"qualityProfileId":{"description":"Radarr/Sonarr quality profile to use (omit for the default); see /requests/options","format":"int64","type":"integer"},"rootFolder":{"description":"Root folder to use (omit for the default)","type":"string"},"seasonNumbering":{"description":"TV only: the detail's seasonNumbering the seasons were picked in; a different one than the server's now is 409 season_numbering_changed","enum":["tvdb","tmdb"],"type":"string"},"seasons":{"description":"TV only; empty means every season","items":{"format":"int64","type":"integer"},"type":"array"},"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"CrewMember":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"job":{"description":"Director, Writer, Screenplay, Story, Novel, Editor, Producer, Composer, Cinematography, Creator","type":"string"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["id","name","job"],"type":"object"},"DeclineRequestRequest":{"additionalProperties":false,"properties":{"reason":{"maxLength":500,"type":"string"}},"type":"object"},"DemoLoginInputBody":{"additionalProperties":false,"properties":{"role":{"description":"admin: every right; user: a plain account","enum":["admin","user"],"type":"string"}},"required":["role"],"type":"object"},"Detail":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"budget":{"format":"int64","type":"integer"},"cast":{"items":{"$ref":"#/components/schemas/CastMember"},"type":"array"},"collectionBackdropPath":{"type":"string"},"collectionId":{"format":"int64","type":"integer"},"collectionName":{"type":"string"},"countries":{"description":"Production countries","items":{"type":"string"},"type":"array"},"crew":{"description":"Key crew (writers, editor, producers, composer...); creators for TV","items":{"$ref":"#/components/schemas/CrewMember"},"type":"array"},"directors":{"items":{"$ref":"#/components/schemas/Person"},"type":"array"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"genres":{"items":{"$ref":"#/components/schemas/Genre"},"type":"array"},"homepage":{"type":"string"},"imdbId":{"type":"string"},"jellyfinItemId":{"description":"Jellyfin id of the title when it is in the library (for links into the Jellyfin apps)","type":"string"},"jellyfinServerId":{"description":"Id of the Jellyfin server, with the item id enough to build a link into the Swiftfin app","type":"string"},"keywords":{"description":"TMDB tags such as \"superhero\"; searchable","items":{"$ref":"#/components/schemas/Keyword"},"type":"array"},"languages":{"description":"Spoken languages, English names","items":{"type":"string"},"type":"array"},"monitored":{"description":"Radarr (movie) already monitors this title and looks for it","type":"boolean"},"numberOfEpisodes":{"format":"int64","type":"integer"},"numberOfSeasons":{"format":"int64","type":"integer"},"originalLanguage":{"type":"string"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"recommendations":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"revenue":{"format":"int64","type":"integer"},"reviews":{"description":"A few TMDB user reviews","items":{"$ref":"#/components/schemas/Review"},"type":"array"},"runtimeMinutes":{"format":"int64","type":"integer"},"seasonNumbering":{"description":"Numbering of seasons: tvdb (Sonarr) or tmdb (fallback when Sonarr cannot tell)","enum":["tvdb","tmdb"],"type":"string"},"seasonNumberingDiffers":{"description":"TMDB splits this show's seasons differently from Sonarr (TVDB)","type":"boolean"},"seasons":{"items":{"$ref":"#/components/schemas/Season"},"type":"array"},"similar":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"status":{"type":"string"},"studios":{"description":"Production companies (movies) or networks (TV)","items":{"type":"string"},"type":"array"},"tagline":{"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"trailerKey":{"description":"YouTube video key of the official trailer","type":"string"},"tvdbId":{"format":"int64","type":"integer"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"voteAverage":{"format":"double","type":"number"},"voteCount":{"format":"int64","type":"integer"},"watchUrl":{"description":"Deep link to the title in Jellyfin when it is in the library","type":"string"}},"required":["genres","cast","directors","crew","reviews","keywords","recommendations","similar","type","tmdbId","title","voteAverage","availability"],"type":"object"},"DeviceSession":{"additionalProperties":false,"properties":{"appVersion":{"type":"string"},"createdAt":{"format":"int64","type":"integer"},"current":{"description":"The session making this request","type":"boolean"},"deviceName":{"type":"string"},"expiresAt":{"format":"int64","type":"integer"},"id":{"description":"Opaque id, usable in DELETE /me/sessions/{id}","type":"string"},"lastSeenAt":{"format":"int64","type":"integer"},"platform":{"enum":["web","ios","android"],"type":"string"},"userAgent":{"type":"string"}},"required":["id","platform","deviceName","appVersion","userAgent","createdAt","lastSeenAt","expiresAt","current"],"type":"object"},"Episode":{"additionalProperties":false,"properties":{"airDate":{"type":"string"},"name":{"type":"string"},"number":{"format":"int64","type":"integer"},"overview":{"type":"string"},"runtimeMinutes":{"format":"int64","type":"integer"},"state":{"enum":["available","monitored","missing"],"type":"string"},"stillPath":{"type":"string"},"voteAverage":{"format":"double","type":"number"}},"required":["number","name","voteAverage"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":"array"},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Event":{"additionalProperties":false,"properties":{"at":{"description":"Unix time of the step","format":"int64","type":"integer"},"by":{"$ref":"#/components/schemas/UserRef","description":"Who did it; absent when the server did"},"kind":{"enum":["requested","approved","searching","downloading","available","declined","failed"],"type":"string"},"note":{"description":"The decline reason, or the error of a failed step (admins only)","type":"string"}},"required":["kind","at"],"type":"object"},"Feed":{"additionalProperties":false,"properties":{"done":{"description":"True when there is nothing new left to show. No items and not done: trailers are still being prepared, ask again in a few seconds","type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/Teaser"},"type":"array"}},"required":["items","done"],"type":"object"},"Flag":{"additionalProperties":false,"properties":{"group":{"enum":["admin","requests","auto-approve","issues","reserved"],"type":"string"},"implies":{"description":"Flags that come with this one and cannot be switched off while it is on","items":{"type":"string"},"type":"array"},"name":{"type":"string"},"reserved":{"description":"Defined so the numbers match Seerr; no feature uses it yet, hide it","type":"boolean"},"value":{"format":"int64","type":"integer"}},"required":["name","value","group","reserved"],"type":"object"},"Flags":{"additionalProperties":false,"properties":{"blocklisted":{"type":"boolean"},"watchlisted":{"type":"boolean"}},"required":["watchlisted","blocklisted"],"type":"object"},"Genre":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"GenreCount":{"additionalProperties":false,"properties":{"count":{"format":"int64","type":"integer"},"name":{"type":"string"}},"required":["name","count"],"type":"object"},"GetPermissionsResponse":{"additionalProperties":false,"properties":{"defaults":{"$ref":"#/components/schemas/PermissionDefaults"},"flags":{"items":{"$ref":"#/components/schemas/Flag"},"type":"array"}},"required":["flags","defaults"],"type":"object"},"Header":{"additionalProperties":false,"properties":{"name":{"maxLength":100,"type":"string"},"value":{"maxLength":2000,"type":"string"}},"required":["name","value"],"type":"object"},"Health":{"additionalProperties":false,"properties":{"breaker":{"$ref":"#/components/schemas/Breaker"},"cacheBytes":{"format":"int64","type":"integer"},"cacheLimitBytes":{"format":"int64","type":"integer"},"counts":{"$ref":"#/components/schemas/Counts"},"denoVersion":{"type":"string"},"ffmpegVersion":{"type":"string"},"lastError":{"type":"string"},"ok":{"type":"boolean"},"ytdlpVersion":{"type":"string"}},"required":["ok","ytdlpVersion","denoVersion","ffmpegVersion","cacheBytes","cacheLimitBytes","counts","breaker"],"type":"object"},"HealthOutputBody":{"additionalProperties":false,"properties":{"ok":{"type":"boolean"}},"required":["ok"],"type":"object"},"HistoryItem":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"seenAt":{"description":"Unix time of the swipe","format":"int64","type":"integer"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"voteAverage":{"format":"double","type":"number"}},"required":["seenAt","type","tmdbId","title","voteAverage","availability"],"type":"object"},"HistorySource":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"HistorySourcesBody":{"additionalProperties":false,"properties":{"sources":{"description":"Accounts whose watch history this person's recommendations also use, with their own. Titles they watched are hidden from the recommendations too","items":{"$ref":"#/components/schemas/HistorySource"},"type":"array"}},"required":["sources"],"type":"object"},"ImportUsersResponse":{"additionalProperties":false,"properties":{"created":{"format":"int64","type":"integer"},"total":{"format":"int64","type":"integer"}},"required":["created","total"],"type":"object"},"InstanceBody":{"additionalProperties":false,"properties":{"animeRoot":{"description":"Sonarr only. Omit to keep, empty string to clear","type":"string"},"apiKey":{"description":"Required when creating; omit on update to keep the saved key","type":"string"},"genreRoots":{"additionalProperties":{"type":"string"},"description":"Radarr only. Omit to keep the saved map, send {} to clear it","type":"object"},"isDefault":{"type":"boolean"},"kind":{"enum":["radarr","sonarr"],"type":"string"},"name":{"maxLength":100,"minLength":1,"type":"string"},"publicUrl":{"description":"Omit to keep, empty string to clear. http(s)://host[:port]","type":"string"},"qualityProfileId":{"format":"int64","minimum":1,"type":"integer"},"rootFolder":{"minLength":1,"type":"string"},"url":{"format":"uri","type":"string"}},"required":["kind","name","url","qualityProfileId","rootFolder","isDefault"],"type":"object"},"InstanceView":{"additionalProperties":false,"properties":{"animeRoot":{"description":"Sonarr only: root folder for anime (Animation + Japanese original language); such shows are also added with series type anime","type":"string"},"apiKeyConfigured":{"type":"boolean"},"genreRoots":{"additionalProperties":{"type":"string"},"description":"Radarr only: TMDB genre id -\u003e root folder; the first matching genre of a movie wins","type":"object"},"id":{"type":"string"},"isDefault":{"type":"boolean"},"kind":{"enum":["radarr","sonarr"],"type":"string"},"name":{"type":"string"},"publicUrl":{"description":"Address people open the app at in their browser, used by links to it; empty = url","type":"string"},"qualityProfileId":{"format":"int64","type":"integer"},"rootFolder":{"type":"string"},"url":{"type":"string"}},"required":["id","kind","name","url","apiKeyConfigured","qualityProfileId","rootFolder","isDefault","publicUrl","animeRoot","genreRoots"],"type":"object"},"IssueComment":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"id":{"type":"string"},"message":{"type":"string"},"user":{"$ref":"#/components/schemas/IssueUser"}},"required":["id","user","message","createdAt"],"type":"object"},"IssueCounts":{"additionalProperties":false,"properties":{"open":{"format":"int64","type":"integer"},"resolved":{"format":"int64","type":"integer"}},"required":["open","resolved"],"type":"object"},"IssueServarrSearchResponse":{"additionalProperties":false,"properties":{"dryRun":{"description":"Dry-run is on: nothing was sent to Radarr/Sonarr","type":"boolean"}},"required":["dryRun"],"type":"object"},"IssueThread":{"additionalProperties":false,"properties":{"commentCount":{"format":"int64","type":"integer"},"comments":{"items":{"$ref":"#/components/schemas/IssueComment"},"type":"array"},"createdAt":{"format":"int64","type":"integer"},"createdBy":{"$ref":"#/components/schemas/IssueUser"},"episode":{"format":"int64","type":"integer"},"id":{"type":"string"},"kind":{"enum":["video","audio","subtitles","other"],"type":"string"},"posterPath":{"type":"string"},"resolvedBy":{"$ref":"#/components/schemas/IssueUser"},"season":{"format":"int64","type":"integer"},"status":{"enum":["open","resolved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"updatedAt":{"format":"int64","type":"integer"}},"required":["comments","id","type","tmdbId","title","kind","status","createdBy","commentCount","createdAt","updatedAt"],"type":"object"},"IssueUser":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"IssueView":{"additionalProperties":false,"properties":{"commentCount":{"format":"int64","type":"integer"},"createdAt":{"format":"int64","type":"integer"},"createdBy":{"$ref":"#/components/schemas/IssueUser"},"episode":{"format":"int64","type":"integer"},"id":{"type":"string"},"kind":{"enum":["video","audio","subtitles","other"],"type":"string"},"posterPath":{"type":"string"},"resolvedBy":{"$ref":"#/components/schemas/IssueUser"},"season":{"format":"int64","type":"integer"},"status":{"enum":["open","resolved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"updatedAt":{"format":"int64","type":"integer"}},"required":["id","type","tmdbId","title","kind","status","createdBy","commentCount","createdAt","updatedAt"],"type":"object"},"Item":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"voteAverage":{"format":"double","type":"number"}},"required":["type","tmdbId","title","voteAverage","availability"],"type":"object"},"Keyword":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"LdapImportBody":{"additionalProperties":false,"properties":{"connected":{"description":"True when Tipsarr could sign in to the LDAP server with the imported settings","type":"boolean"},"error":{"description":"Why the connection test failed (the settings are saved anyway so they can be corrected)","type":"string"},"ldapBaseDn":{"type":"string"},"ldapBindDn":{"type":"string"},"ldapUrl":{"type":"string"}},"required":["ldapUrl","ldapBindDn","ldapBaseDn","connected"],"type":"object"},"LibraryFacets":{"additionalProperties":false,"properties":{"genres":{"description":"Most common first","items":{"$ref":"#/components/schemas/GenreCount"},"type":"array"},"maxRuntimeMinutes":{"format":"int64","type":"integer"},"movies":{"format":"int64","type":"integer"},"shows":{"format":"int64","type":"integer"},"yearMax":{"format":"int64","type":"integer"},"yearMin":{"format":"int64","type":"integer"}},"required":["movies","shows","genres","yearMin","yearMax","maxRuntimeMinutes"],"type":"object"},"LibraryItem":{"additionalProperties":false,"properties":{"addedAt":{"description":"When Jellyfin added it (unix seconds)","format":"int64","type":"integer"},"genres":{"items":{"type":"string"},"type":"array"},"plays":{"description":"Total plays by everyone","format":"int64","type":"integer"},"posterUrl":{"description":"Poster served from Jellyfin through Tipsarr","type":"string"},"rating":{"description":"Jellyfin community rating out of 10","format":"double","type":"number"},"runtimeMinutes":{"description":"Movie length, or typical episode length for a show","format":"int64","type":"integer"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"tmdbPosterPath":{"description":"TMDB poster in the person's language; apps prefer it to posterUrl so every screen shows the same poster","type":"string"},"type":{"enum":["movie","tv"],"type":"string"},"viewers":{"description":"People who watched it","format":"int64","type":"integer"},"watched":{"description":"The logged-in user watched it","type":"boolean"},"year":{"format":"int64","type":"integer"}},"required":["type","tmdbId","title","genres","addedAt","plays","viewers","watched"],"type":"object"},"LibraryList":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/LibraryItem"},"type":"array"},"page":{"format":"int64","type":"integer"},"total":{"format":"int64","type":"integer"},"totalPages":{"format":"int64","type":"integer"}},"required":["total","page","totalPages","items"],"type":"object"},"List":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"page":{"format":"int64","type":"integer"},"totalPages":{"format":"int64","type":"integer"}},"required":["page","totalPages","items"],"type":"object"},"ListIssuesResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/IssueView"},"type":"array"},"total":{"format":"int64","type":"integer"}},"required":["total","items"],"type":"object"},"ListWebhookPresetsResponse":{"additionalProperties":false,"properties":{"events":{"description":"Events a webhook may subscribe to","items":{"type":"string"},"type":"array"},"presets":{"items":{"$ref":"#/components/schemas/Preset"},"type":"array"}},"required":["presets","events"],"type":"object"},"LoginInputBody":{"additionalProperties":false,"properties":{"password":{"maxLength":512,"type":"string"},"username":{"maxLength":256,"minLength":1,"type":"string"}},"required":["username","password"],"type":"object"},"MovieScores":{"additionalProperties":false,"properties":{"imdb":{"$ref":"#/components/schemas/Score"},"imdbUrl":{"type":"string"},"metacritic":{"$ref":"#/components/schemas/Score"},"rottenTomatoes":{"$ref":"#/components/schemas/Score","description":"Critics score in percent"},"rottenTomatoesAudience":{"$ref":"#/components/schemas/Score","description":"Audience score in percent (only from Rotten Tomatoes' own search)"},"rottenTomatoesUrl":{"type":"string"}},"type":"object"},"Options":{"additionalProperties":false,"properties":{"instanceName":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/QualityProfile"},"type":"array"},"qualityProfileId":{"description":"The instance default","format":"int64","type":"integer"},"rootFolder":{"description":"The instance default","type":"string"},"rootFolders":{"items":{"$ref":"#/components/schemas/RootFolder"},"type":"array"}},"required":["instanceName","qualityProfileId","rootFolder","profiles","rootFolders"],"type":"object"},"Overview":{"additionalProperties":false,"properties":{"available":{"description":"The resolver answered its health check","type":"boolean"},"configured":{"description":"An address and a key are saved","type":"boolean"},"enabled":{"type":"boolean"},"error":{"description":"Why the resolver is not available","type":"string"},"files":{"additionalProperties":{"format":"int64","type":"integer"},"description":"Rows per state in Tipsarr's own table: queued, running, ready, failed","type":"object"},"health":{"$ref":"#/components/schemas/Health"}},"required":["enabled","configured","available","files"],"type":"object"},"PermissionDefaults":{"additionalProperties":false,"properties":{"movieQuotaDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"movieQuotaLimit":{"description":"Movie requests allowed per period, 0 = no limit","format":"int64","maximum":100000,"minimum":0,"type":"integer"},"permissions":{"description":"Mask new people start with (see flags)","format":"int64","type":"integer"},"tvQuotaDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"tvQuotaLimit":{"description":"Seasons of shows allowed per period (one request costs at most this), 0 = no limit","format":"int64","maximum":100000,"minimum":0,"type":"integer"}},"required":["permissions","movieQuotaLimit","movieQuotaDays","tvQuotaLimit","tvQuotaDays"],"type":"object"},"Person":{"additionalProperties":false,"properties":{"department":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["id","name"],"type":"object"},"PersonDetail":{"additionalProperties":false,"properties":{"biography":{"type":"string"},"birthday":{"type":"string"},"birthplace":{"type":"string"},"credits":{"description":"Cast credits, most popular first","items":{"$ref":"#/components/schemas/Item"},"type":"array"},"deathday":{"type":"string"},"department":{"type":"string"},"id":{"format":"int64","type":"integer"},"name":{"type":"string"},"profilePath":{"type":"string"}},"required":["credits","id","name"],"type":"object"},"PrefsBody":{"additionalProperties":false,"properties":{"discordId":{"description":"Discord user id (digits, empty clears it), used to mention the person","pattern":"^[0-9]{0,20}$","type":"string"},"language":{"description":"TMDB language, e.g. fr-FR or fr (empty = English)","type":"string"},"ratingSource":{"description":"Score shown on posters (empty = tmdb)","enum":["tmdb","imdb","metacritic","rottenTomatoes",""],"type":"string"},"region":{"description":"ISO 3166 country, e.g. FR (empty = none). Used for release dates and the box-office region","type":"string"},"telegramChatId":{"description":"Telegram chat id (empty clears it)","pattern":"^-?[0-9]{0,20}$","type":"string"}},"type":"object"},"Preset":{"additionalProperties":false,"properties":{"authType":{"enum":["none","bearer","basic"],"type":"string"},"headers":{"items":{"$ref":"#/components/schemas/Header"},"type":"array"},"id":{"type":"string"},"name":{"type":"string"},"notes":{"type":"string"},"template":{"description":"Liquid body; empty = the raw JSON envelope","type":"string"},"urlExample":{"type":"string"}},"required":["id","name","urlExample","template","headers","authType","notes"],"type":"object"},"ProbeBody":{"additionalProperties":false,"properties":{"apiKey":{"description":"Leave empty to use the saved key of the instance given by id","type":"string"},"id":{"type":"string"},"kind":{"enum":["radarr","sonarr"],"type":"string"},"url":{"format":"uri","type":"string"}},"required":["kind","url"],"type":"object"},"ProbeResult":{"additionalProperties":false,"properties":{"appName":{"type":"string"},"profiles":{"items":{"$ref":"#/components/schemas/QualityProfile"},"type":"array"},"rootFolders":{"items":{"$ref":"#/components/schemas/RootFolder"},"type":"array"},"version":{"type":"string"}},"required":["appName","version","profiles","rootFolders"],"type":"object"},"ProfileStats":{"additionalProperties":false,"properties":{"approved":{"format":"int64","type":"integer"},"available":{"format":"int64","type":"integer"},"declined":{"format":"int64","type":"integer"},"failed":{"format":"int64","type":"integer"},"movies":{"description":"Requested movies","format":"int64","type":"integer"},"pending":{"format":"int64","type":"integer"},"requests":{"format":"int64","type":"integer"},"shows":{"description":"Requested shows","format":"int64","type":"integer"},"watched":{"description":"Titles watched according to the Jellyfin history","format":"int64","type":"integer"},"watchlist":{"format":"int64","type":"integer"}},"required":["requests","movies","shows","pending","approved","available","declined","failed","watchlist","watched"],"type":"object"},"ProfileView":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"effectivePermissions":{"format":"int64","type":"integer"},"hasUploadedAvatar":{"description":"The person uploaded their own picture","type":"boolean"},"id":{"type":"string"},"language":{"type":"string"},"lastLoginAt":{"format":"int64","type":"integer"},"movieQuotaDays":{"format":"int64","type":"integer"},"movieQuotaLimit":{"format":"int64","type":"integer"},"name":{"type":"string"},"permissions":{"format":"int64","type":"integer"},"ratingSource":{"type":"string"},"region":{"type":"string"},"role":{"enum":["admin","user"],"type":"string"},"stats":{"$ref":"#/components/schemas/ProfileStats"},"tvQuotaDays":{"format":"int64","type":"integer"},"tvQuotaLimit":{"format":"int64","type":"integer"}},"required":["id","name","role","region","language","ratingSource","effectivePermissions","permissions","createdAt","lastLoginAt","stats","hasUploadedAvatar"],"type":"object"},"Progress":{"additionalProperties":false,"properties":{"etaSeconds":{"format":"int64","type":"integer"},"percent":{"format":"int64","type":"integer"}},"required":["percent","etaSeconds"],"type":"object"},"PushDevice":{"additionalProperties":false,"properties":{"categories":{"description":"Bitmask: 1 my requests, 2 new requests to approve (admins), 4 issues","format":"int64","type":"integer"},"language":{"type":"string"},"platform":{"enum":["ios","android"],"type":"string"},"sandbox":{"type":"boolean"},"server":{"type":"string"},"updatedAt":{"format":"int64","type":"integer"}},"required":["platform","sandbox","language","categories","updatedAt"],"type":"object"},"QualityProfile":{"additionalProperties":false,"properties":{"id":{"format":"int64","type":"integer"},"maxResolution":{"description":"Highest resolution the profile allows (2160 = 4K), 0 when unknown","format":"int64","type":"integer"},"name":{"type":"string"},"uhd":{"description":"The profile allows 4K (2160p): only people with the 4K right may pick it","type":"boolean"}},"required":["id","name"],"type":"object"},"Quota":{"additionalProperties":false,"properties":{"movie":{"$ref":"#/components/schemas/QuotaWindow"},"tv":{"$ref":"#/components/schemas/QuotaWindow"}},"required":["movie","tv"],"type":"object"},"QuotaWindow":{"additionalProperties":false,"properties":{"days":{"format":"int64","type":"integer"},"limit":{"format":"int64","type":"integer"},"remaining":{"description":"Absent when there is no limit","format":"int64","type":"integer"},"resetsAt":{"description":"Unix time when the oldest counted request leaves the window","format":"int64","type":"integer"},"restricted":{"description":"The limit is reached: new requests are refused until ResetsAt","type":"boolean"},"used":{"format":"int64","type":"integer"}},"required":["days","limit","used","restricted"],"type":"object"},"RegisterDeviceInputBody":{"additionalProperties":false,"properties":{"categories":{"description":"Bitmask of the notifications wanted; default all (7), unchanged on refresh when omitted","format":"int64","maximum":7,"minimum":0,"type":"integer"},"language":{"description":"Language of the alert text (en or fr); default en","maxLength":16,"type":"string"},"pushToken":{"description":"APNs device token (hex) or FCM registration token","maxLength":1024,"minLength":20,"type":"string"},"sandbox":{"description":"iOS only: the token comes from a development build (APNs sandbox)","type":"boolean"},"server":{"description":"Host (and port) the app uses to reach this server; sent back in every alert so a phone with several accounts opens the right one","maxLength":255,"type":"string"}},"required":["pushToken"],"type":"object"},"RegisterPushRequest":{"additionalProperties":false,"properties":{"relayUrl":{"description":"Relay address; empty = the publisher's relay (pushDefaultRelayUrl)","type":"string"}},"type":"object"},"RequestCountsResponse":{"additionalProperties":false,"properties":{"approved":{"format":"int64","type":"integer"},"available":{"format":"int64","type":"integer"},"declined":{"format":"int64","type":"integer"},"failed":{"format":"int64","type":"integer"},"pending":{"format":"int64","type":"integer"}},"required":["pending","approved","available","declined","failed"],"type":"object"},"RequestList":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/View"},"type":"array"},"total":{"format":"int64","type":"integer"}},"required":["total","items"],"type":"object"},"Result":{"additionalProperties":false,"properties":{"generating":{"description":"A refresh is running in the background; a suggestions.updated event follows","type":"boolean"},"rows":{"items":{"$ref":"#/components/schemas/Row"},"type":"array"}},"required":["rows","generating"],"type":"object"},"Review":{"additionalProperties":false,"properties":{"author":{"type":"string"},"content":{"description":"Shortened to a few hundred characters","type":"string"},"createdAt":{"type":"string"},"rating":{"description":"The reviewer's own score out of 10, when given","format":"double","type":"number"},"url":{"type":"string"}},"required":["author","content"],"type":"object"},"RootFolder":{"additionalProperties":false,"properties":{"freeSpace":{"format":"int64","type":"integer"},"id":{"format":"int64","type":"integer"},"path":{"type":"string"}},"required":["id","path","freeSpace"],"type":"object"},"Row":{"additionalProperties":false,"properties":{"generatedAt":{"format":"int64","type":"integer"},"id":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"kind":{"enum":["account","because"],"type":"string"},"personal":{"description":"Built from this user's own watch history","type":"boolean"},"seed":{"$ref":"#/components/schemas/Seed"},"title":{"description":"English display title (clients should translate from variant + seed)","type":"string"},"variant":{"description":"What the row is based on, so clients can show it in the user's language","enum":["personal","server","trending","because"],"type":"string"}},"required":["id","kind","title","variant","personal","generatedAt","items"],"type":"object"},"Score":{"additionalProperties":false,"properties":{"value":{"description":"IMDb: out of 10. Metacritic and Rotten Tomatoes: out of 100","format":"double","type":"number"},"votes":{"format":"int64","type":"integer"}},"required":["value"],"type":"object"},"SearchResult":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"},"keywords":{"description":"Tags matching the query (only when tags=true)","items":{"$ref":"#/components/schemas/Keyword"},"type":"array"},"page":{"format":"int64","type":"integer"},"people":{"items":{"$ref":"#/components/schemas/Person"},"type":"array"},"tagged":{"description":"Titles carrying one of those tags that the text search did not return","items":{"$ref":"#/components/schemas/Item"},"type":"array"},"totalPages":{"format":"int64","type":"integer"}},"required":["page","totalPages","items","people","keywords","tagged"],"type":"object"},"Season":{"additionalProperties":false,"properties":{"airDate":{"type":"string"},"episodeCount":{"description":"Episodes in the season; 0 = unknown","format":"int64","type":"integer"},"libraryEpisodes":{"description":"Episodes of this season already in the library","format":"int64","type":"integer"},"monitored":{"description":"Sonarr already monitors this season","type":"boolean"},"name":{"type":"string"},"number":{"format":"int64","type":"integer"},"posterPath":{"type":"string"}},"required":["number","name","episodeCount","libraryEpisodes","monitored"],"type":"object"},"SeasonDetail":{"additionalProperties":false,"properties":{"airDate":{"type":"string"},"episodes":{"items":{"$ref":"#/components/schemas/Episode"},"type":"array"},"name":{"type":"string"},"number":{"format":"int64","type":"integer"},"overview":{"type":"string"},"posterPath":{"type":"string"}},"required":["number","name","episodes"],"type":"object"},"SeasonProgress":{"additionalProperties":false,"properties":{"percent":{"description":"Episodes Sonarr has plus what is being downloaded, as a percentage of the season","format":"int64","type":"integer"},"season":{"format":"int64","type":"integer"}},"required":["season","percent"],"type":"object"},"SecretOutputBody":{"additionalProperties":false,"properties":{"value":{"description":"The secret; empty when it is not set (the dashboard key is off)","type":"string"}},"required":["value"],"type":"object"},"Seed":{"additionalProperties":false,"properties":{"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId","title"],"type":"object"},"SetBoxOfficeAliasRequest":{"additionalProperties":false,"properties":{"title":{"maxLength":255,"minLength":1,"type":"string"},"tmdbId":{"format":"int64","minimum":1,"type":"integer"}},"required":["title","tmdbId"],"type":"object"},"SetHistorySourcesRequest":{"additionalProperties":false,"properties":{"sources":{"description":"Jellyfin user ids","items":{"type":"string"},"maxItems":5,"type":"array"}},"required":["sources"],"type":"object"},"SettingsBody":{"additionalProperties":false,"properties":{"apiKeyConfigured":{"description":"The read-only dashboard key (X-Api-Key) is on; the key itself is read with POST /admin/secrets/api_key/reveal","type":"boolean"},"appPublicUrl":{"description":"Address people open Tipsarr at, e.g. https://tipsarr.example.org; webhook links ({{ url }}) start with it. Empty = no links","type":"string"},"boxofficeRegions":{"description":"Comma-separated box-office region codes, e.g. US,GB,FR","type":"string"},"defaultLanguage":{"description":"App-wide default language, e.g. fr-FR; empty = the browser decides. A person's own language wins","type":"string"},"dryRun":{"description":"When true nothing is ever sent to Radarr/Sonarr","type":"boolean"},"jellyfinApiKeyConfigured":{"description":"Needed for library and history sync","type":"boolean"},"jellyfinPublicUrl":{"description":"Address browsers use to open Jellyfin (Play buttons); empty = same as jellyfinUrl","type":"string"},"jellyfinUrl":{"type":"string"},"ldapBaseDn":{"description":"Where users live, e.g. ou=people,dc=example,dc=com","type":"string"},"ldapBindDn":{"type":"string"},"ldapBindPasswordConfigured":{"type":"boolean"},"ldapUrl":{"description":"LLDAP/LDAP address for profile pictures, e.g. ldap://lldap:3890","type":"string"},"oidcAdminGroup":{"description":"Members of this group become admins (optional)","type":"string"},"oidcClientId":{"type":"string"},"oidcClientSecretConfigured":{"type":"boolean"},"oidcGroupsClaim":{"description":"Claim that lists the groups, default groups","type":"string"},"oidcIssuer":{"description":"OpenID Connect provider (Authelia), e.g. https://auth.example.org","type":"string"},"oidcUsernameClaim":{"description":"Claim matched exactly (case included) to the Jellyfin username, default preferred_username","type":"string"},"pushDailyQuota":{"description":"Pushes per day the relay allows a public key; 0 = unlimited or unknown","format":"int64","type":"integer"},"pushDefaultRelayUrl":{"description":"The relay run by the app publisher, offered for registration","type":"string"},"pushEnabled":{"description":"Mobile push notifications through the relay (off by default)","type":"boolean"},"pushKeyPublic":{"description":"The key came from the relay's open registration: generic alerts only (pushText has no effect) and a daily quota","type":"boolean"},"pushPausedUntil":{"description":"Unix time when sending resumes after the relay's daily quota was reached; 0 = not paused","format":"int64","type":"integer"},"pushRelayKeyConfigured":{"description":"The relay key is write-only; this only says whether one is saved","type":"boolean"},"pushRelayUrl":{"description":"Address of the push relay, e.g. https://push.example.org","type":"string"},"pushText":{"description":"Alerts name the media (needs a relay that allows free text for this app); off: generic alerts","type":"boolean"},"servarrAutoImport":{"description":"Mirror what Radarr/Sonarr monitor but have not downloaded as approved requests (reads only)","type":"boolean"},"teasersFilesEnabled":{"description":"Teasers feed with trailer files from the optional trailer-resolver sidecar (off by default)","type":"boolean"},"teasersFilesKeyConfigured":{"description":"The resolver key is write-only; this only says whether one is saved","type":"boolean"},"teasersFilesMaxHeight":{"description":"Largest video height the resolver downloads: 360, 480, 720 or 1080","format":"int64","type":"integer"},"teasersFilesUrl":{"description":"Address of the trailer resolver on the Docker network, e.g. http://trailer-resolver:8080","type":"string"},"tmdbConfigured":{"description":"Secrets are write-only; this only says whether a key is saved","type":"boolean"},"webhookPath":{"description":"Path for the Jellyfin webhook plugin to call, with the secret in the X-Tipsarr-Token header (read it with POST /admin/secrets/jellyfin_webhook/reveal)","type":"string"},"webhookTokenConfigured":{"description":"The Jellyfin webhook secret exists; it is never returned here","type":"boolean"}},"required":["jellyfinUrl","jellyfinPublicUrl","tmdbConfigured","jellyfinApiKeyConfigured","boxofficeRegions","webhookPath","webhookTokenConfigured","dryRun","oidcIssuer","oidcClientId","oidcClientSecretConfigured","oidcAdminGroup","oidcGroupsClaim","oidcUsernameClaim","ldapUrl","ldapBindDn","ldapBindPasswordConfigured","ldapBaseDn","appPublicUrl","defaultLanguage","pushEnabled","pushRelayUrl","pushText","pushRelayKeyConfigured","pushKeyPublic","pushDailyQuota","pushPausedUntil","pushDefaultRelayUrl","servarrAutoImport","teasersFilesEnabled","teasersFilesUrl","teasersFilesKeyConfigured","teasersFilesMaxHeight","apiKeyConfigured"],"type":"object"},"SetupInputBody":{"additionalProperties":false,"properties":{"jellyfinUrl":{"description":"Base URL of the Jellyfin server","format":"uri","type":"string"},"setupToken":{"description":"One-time token printed in the server log at startup (required on a fresh install)","type":"string"},"tmdbApiKey":{"description":"TMDB API key or read token (can be added later)","type":"string"}},"required":["jellyfinUrl"],"type":"object"},"SetupOutputBody":{"additionalProperties":false,"properties":{"serverName":{"type":"string"}},"required":["serverName"],"type":"object"},"SetupStatusOutputBody":{"additionalProperties":false,"properties":{"configured":{"description":"True once a Jellyfin URL is saved","type":"boolean"},"tokenRequired":{"description":"The setup call needs the token printed in the server log (TIPSARR_SETUP_TOKEN=true)","type":"boolean"}},"required":["configured","tokenRequired"],"type":"object"},"StatsBucket":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"name":{"type":"string"},"titles":{"format":"int64","type":"integer"}},"required":["name","hours","titles"],"type":"object"},"StatsMonth":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"month":{"description":"YYYY-MM","type":"string"},"plays":{"format":"int64","type":"integer"}},"required":["month","hours","plays"],"type":"object"},"StatsPlugin":{"additionalProperties":false,"properties":{"hint":{"description":"Admins only: the Playback Reporting plugin is missing and would make these numbers exact","type":"boolean"}},"required":["hint"],"type":"object"},"StatsReport":{"additionalProperties":false,"properties":{"decades":{"items":{"$ref":"#/components/schemas/StatsBucket"},"type":"array"},"genres":{"items":{"$ref":"#/components/schemas/StatsBucket"},"type":"array"},"hoursOfDay":{"description":"Hours per hour of the day (plugin source only)","items":{"format":"double","type":"number"},"type":"array"},"months":{"description":"Last 12 months, oldest first (plugin source only)","items":{"$ref":"#/components/schemas/StatsMonth"},"type":"array"},"period":{"enum":["30d","12m","all"],"type":"string"},"plugin":{"$ref":"#/components/schemas/StatsPlugin"},"requests":{"$ref":"#/components/schemas/StatsRequests"},"source":{"description":"plugin = exact plays from Jellyfin's Playback Reporting; estimate = watch history x runtime","enum":["plugin","estimate"],"type":"string"},"top":{"description":"Everything watched at least once, most time spent first (movies and shows together)","items":{"$ref":"#/components/schemas/StatsTop"},"type":"array"},"topMovies":{"description":"Most watched movies","items":{"$ref":"#/components/schemas/StatsTop"},"type":"array"},"topShows":{"description":"Most watched shows","items":{"$ref":"#/components/schemas/StatsTop"},"type":"array"},"totals":{"$ref":"#/components/schemas/StatsTotals"},"user":{"description":"A user id, or all","type":"string"},"weekdays":{"description":"Hours per weekday, Monday first (plugin source only)","items":{"format":"double","type":"number"},"type":"array"}},"required":["source","user","period","totals","genres","decades","months","weekdays","hoursOfDay","top","topMovies","topShows","requests","plugin"],"type":"object"},"StatsRequests":{"additionalProperties":false,"properties":{"approved":{"format":"int64","type":"integer"},"available":{"format":"int64","type":"integer"},"declined":{"format":"int64","type":"integer"},"failed":{"format":"int64","type":"integer"},"made":{"format":"int64","type":"integer"},"pending":{"format":"int64","type":"integer"}},"required":["made","pending","approved","available","declined","failed"],"type":"object"},"StatsTop":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"plays":{"description":"Play sessions; for a show every episode watched counts as one","format":"int64","type":"integer"},"posterUrl":{"type":"string"},"rating":{"format":"double","type":"number"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"year":{"format":"int64","type":"integer"}},"required":["type","tmdbId","title","plays","hours"],"type":"object"},"StatsTotals":{"additionalProperties":false,"properties":{"hours":{"format":"double","type":"number"},"movies":{"format":"int64","type":"integer"},"plays":{"format":"int64","type":"integer"},"shows":{"format":"int64","type":"integer"},"titles":{"format":"int64","type":"integer"}},"required":["titles","movies","shows","plays","hours"],"type":"object"},"Status":{"additionalProperties":false,"properties":{"everySeconds":{"format":"int64","type":"integer"},"lastFinishedAt":{"format":"int64","type":"integer"},"lastStartedAt":{"format":"int64","type":"integer"},"message":{"type":"string"},"messageArgs":{"items":{"type":"string"},"type":"array"},"messageCode":{"type":"string"},"messageParams":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"type":"string"},"running":{"type":"boolean"},"status":{"type":"string"}},"required":["everySeconds","running","name","lastStartedAt","lastFinishedAt","status","message"],"type":"object"},"StatusFeatures":{"additionalProperties":false,"properties":{"push":{"description":"Push notifications are set up on this server","type":"boolean"},"teasers":{"description":"The Teasers feed is available: trailer files are on and the resolver answers. Apps hide the Teasers tab otherwise","type":"boolean"}},"required":["push","teasers"],"type":"object"},"StatusOutputBody":{"additionalProperties":false,"properties":{"apiVersion":{"format":"int64","type":"integer"},"db":{"description":"Database engine: sqlite, postgres or mysql","type":"string"},"defaultLanguage":{"type":"string"},"demo":{"description":"This server is a throwaway public demo sandbox: one-click sign-in, actions that reach other services are refused (demo_disabled)","type":"boolean"},"dryRun":{"description":"When true nothing is ever sent to Radarr/Sonarr","type":"boolean"},"features":{"$ref":"#/components/schemas/StatusFeatures"},"minAppVersion":{"type":"string"},"version":{"type":"string"}},"required":["version","db","dryRun","demo","defaultLanguage","apiVersion","minAppVersion","features"],"type":"object"},"SyncStatusBody":{"additionalProperties":false,"properties":{"canSync":{"description":"True when a Jellyfin API key is saved","type":"boolean"},"jobs":{"items":{"$ref":"#/components/schemas/Status"},"type":"array"},"movies":{"description":"Movies currently known in the Jellyfin library","format":"int64","type":"integer"},"shows":{"description":"Shows currently known in the Jellyfin library","format":"int64","type":"integer"}},"required":["canSync","movies","shows","jobs"],"type":"object"},"Teaser":{"additionalProperties":false,"properties":{"availability":{"description":"Library availability (filled by library sync)","enum":["none","partial","available"],"type":"string"},"backdropPath":{"type":"string"},"genreIds":{"items":{"format":"int64","type":"integer"},"type":"array"},"overview":{"type":"string"},"posterPath":{"description":"TMDB path, load via /images/tmdb/{size}/{file}","type":"string"},"releaseDate":{"type":"string"},"requestStatus":{"description":"Set while an active request exists for this title","enum":["pending","approved"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"trailerKey":{"description":"YouTube video key of the trailer, in the person's language when there is one","type":"string"},"type":{"description":"movie or tv","enum":["movie","tv"],"type":"string"},"videoHeight":{"format":"int64","type":"integer"},"videoUrl":{"description":"Trailer file served by this server (needs the same login as the API)","type":"string"},"voteAverage":{"format":"double","type":"number"},"watchlisted":{"type":"boolean"}},"required":["trailerKey","watchlisted","videoUrl","type","tmdbId","title","voteAverage","availability"],"type":"object"},"TeaserFilesTestRequest":{"additionalProperties":false,"properties":{"key":{"description":"YouTube video key; default is a short public video","type":"string"}},"type":"object"},"TeaserHistoryResponse":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/HistoryItem"},"type":"array"},"total":{"description":"How many titles the history holds in all","format":"int64","type":"integer"}},"required":["items","total"],"type":"object"},"TeaserSeenRequest":{"additionalProperties":false,"properties":{"tmdbId":{"format":"int64","minimum":1,"type":"integer"},"type":{"enum":["movie","tv"],"type":"string"}},"required":["type","tmdbId"],"type":"object"},"TestResult":{"additionalProperties":false,"properties":{"bytes":{"format":"int64","type":"integer"},"duration":{"format":"int64","type":"integer"},"error":{"type":"string"},"height":{"format":"int64","type":"integer"},"key":{"type":"string"},"seconds":{"description":"How long the resolver took (or how long we waited)","format":"double","type":"number"},"status":{"enum":["queued","running","ready","failed"],"type":"string"}},"required":["key","status","seconds","bytes","height","duration"],"type":"object"},"TokenBody":{"additionalProperties":false,"properties":{"expiresAt":{"description":"Unix seconds; every use renews it, see sessions","format":"int64","type":"integer"},"token":{"description":"Send as Authorization: Bearer \u003ctoken\u003e","type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","expiresAt","user"],"type":"object"},"TokenInputBody":{"additionalProperties":false,"properties":{"appVersion":{"maxLength":32,"type":"string"},"deviceName":{"description":"Shown in the signed-in devices list","maxLength":64,"type":"string"},"password":{"maxLength":512,"type":"string"},"platform":{"description":"Which app is signing in","enum":["ios","android"],"type":"string"},"username":{"maxLength":256,"minLength":1,"type":"string"}},"required":["username","password","platform"],"type":"object"},"UpdateRequestOptionsRequest":{"additionalProperties":false,"properties":{"qualityProfileId":{"description":"Omit for the instance default","format":"int64","type":"integer"},"rootFolder":{"description":"Empty for the instance default","type":"string"}},"type":"object"},"UpdateSettingsInputBody":{"additionalProperties":false,"properties":{"apiKeyEnabled":{"description":"Turn the read-only dashboard key on (a key is created if there is none) or off (the key is deleted)","type":"boolean"},"appPublicUrl":{"description":"http(s)://host[/path]; empty clears it","type":"string"},"boxofficeRegions":{"description":"Comma-separated region codes; empty resets to US","type":"string"},"defaultLanguage":{"description":"e.g. fr-FR; empty clears it","type":"string"},"jellyfinApiKey":{"description":"Jellyfin API key (Dashboard \u003e API Keys); empty string clears it","type":"string"},"jellyfinPublicUrl":{"description":"Empty string clears it","type":"string"},"ldapBaseDn":{"type":"string"},"ldapBindDn":{"type":"string"},"ldapBindPassword":{"description":"Write-only","type":"string"},"ldapUrl":{"description":"Empty string turns LDAP pictures off; the connection is tested when saving","type":"string"},"oidcAdminGroup":{"type":"string"},"oidcClientId":{"type":"string"},"oidcClientSecret":{"description":"Write-only; empty string clears it","type":"string"},"oidcGroupsClaim":{"type":"string"},"oidcIssuer":{"description":"Empty string turns single sign-on off. The provider is contacted when saving","type":"string"},"oidcUsernameClaim":{"description":"Empty string = preferred_username","type":"string"},"pushEnabled":{"description":"Needs the relay address and key; turning it on with either missing is refused","type":"boolean"},"pushRelayKey":{"description":"Write-only; empty string clears it (and turns push off)","type":"string"},"pushRelayUrl":{"description":"Empty string clears it (and turns push off)","type":"string"},"pushText":{"type":"boolean"},"regenerateApiKey":{"description":"True: replace the dashboard key with a new one (the old one stops working)","type":"boolean"},"servarrAutoImport":{"type":"boolean"},"teasersFilesEnabled":{"description":"Needs the resolver address and key; turning it on with either missing is refused","type":"boolean"},"teasersFilesKey":{"description":"Write-only; empty string clears it (and turns trailer files off)","type":"string"},"teasersFilesMaxHeight":{"description":"360, 480, 720 or 1080","format":"int64","type":"integer"},"teasersFilesUrl":{"description":"Empty string clears it (and turns trailer files off)","type":"string"},"tmdbApiKey":{"description":"Set or replace the TMDB key (empty string clears it)","type":"string"}},"type":"object"},"UpdateUserRequest":{"additionalProperties":false,"properties":{"discordId":{"description":"Discord user id, empty to clear","pattern":"^[0-9]{0,20}$","type":"string"},"language":{"description":"e.g. fr or fr-FR, empty to clear","type":"string"},"permissions":{"format":"int64","type":"integer"},"quota":{"$ref":"#/components/schemas/UserQuota"},"region":{"description":"2-letter country code, empty to clear","type":"string"},"role":{"enum":["admin","user"],"type":"string"},"telegramChatId":{"description":"Telegram chat id, empty to clear","pattern":"^-?[0-9]{0,20}$","type":"string"}},"type":"object"},"UpdateWebhookEgressRequest":{"additionalProperties":false,"properties":{"allowedHosts":{"description":"host, host:port or CIDR, e.g. ntfy.lan, 192.168.1.20:8123, 10.0.0.0/24","items":{"type":"string"},"maxItems":50,"type":"array"}},"required":["allowedHosts"],"type":"object"},"User":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"discordId":{"type":"string"},"effectivePermissions":{"format":"int64","type":"integer"},"id":{"type":"string"},"language":{"type":"string"},"lastLoginAt":{"format":"int64","type":"integer"},"movieQuotaDays":{"format":"int64","type":"integer"},"movieQuotaLimit":{"format":"int64","type":"integer"},"name":{"type":"string"},"permissions":{"format":"int64","type":"integer"},"ratingSource":{"type":"string"},"region":{"type":"string"},"role":{"type":"string"},"telegramChatId":{"type":"string"},"tvQuotaDays":{"format":"int64","type":"integer"},"tvQuotaLimit":{"format":"int64","type":"integer"}},"required":["id","name","role","region","language","ratingSource","discordId","telegramChatId","createdAt","lastLoginAt","permissions","effectivePermissions"],"type":"object"},"UserQuota":{"additionalProperties":false,"properties":{"movieDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"movieLimit":{"format":"int64","maximum":100000,"minimum":0,"type":"integer"},"tvDays":{"format":"int64","maximum":3650,"minimum":0,"type":"integer"},"tvLimit":{"format":"int64","maximum":100000,"minimum":0,"type":"integer"}},"type":"object"},"UserRef":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"Variable":{"additionalProperties":false,"properties":{"description":{"description":"Localized description","type":"string"},"empty":{"description":"Not filled for this event","type":"boolean"},"example":{"description":"Value with the sample data (empty when Empty)","type":"string"},"path":{"description":"Liquid path, e.g. media.title","type":"string"},"perRecipient":{"description":"Only filled with one delivery per recipient","type":"boolean"},"type":{"description":"Value type","enum":["string","number","boolean"],"type":"string"}},"required":["path","type","example","description","empty","perRecipient"],"type":"object"},"View":{"additionalProperties":false,"properties":{"createdAt":{"format":"int64","type":"integer"},"decidedBy":{"$ref":"#/components/schemas/UserRef"},"declineReason":{"type":"string"},"dryRun":{"description":"Approved in dry-run mode: nothing was sent to Radarr/Sonarr","type":"boolean"},"error":{"type":"string"},"events":{"description":"What happened to the request and when, oldest first. Requests older than this history show an approximate one","items":{"$ref":"#/components/schemas/Event"},"type":"array"},"id":{"type":"string"},"posterPath":{"type":"string"},"progress":{"$ref":"#/components/schemas/Progress"},"qualityProfileId":{"description":"Chosen quality profile (absent = the instance default)","format":"int64","type":"integer"},"releaseDate":{"type":"string"},"requestedBy":{"$ref":"#/components/schemas/UserRef"},"rootFolder":{"description":"Chosen root folder (absent = the instance default)","type":"string"},"seasonNumbering":{"description":"Shows only: numbering of seasons, tvdb (Sonarr) or tmdb (made while Sonarr could not tell; translated when it is sent)","enum":["tvdb","tmdb"],"type":"string"},"seasonProgress":{"description":"Shows only: completion per season","items":{"$ref":"#/components/schemas/SeasonProgress"},"type":"array"},"seasons":{"items":{"format":"int64","type":"integer"},"type":"array"},"source":{"description":"Set when the request was imported from what Radarr/Sonarr already monitor","type":"string"},"stage":{"description":"User-facing lifecycle step","enum":["requested","approved","searching","downloading","available","declined","failed"],"type":"string"},"status":{"enum":["pending","approved","declined","failed","available"],"type":"string"},"title":{"type":"string"},"tmdbId":{"format":"int64","type":"integer"},"type":{"enum":["movie","tv"],"type":"string"},"uhd":{"description":"The quality profile allows 4K (2160p); absent when not (and from servers older than 4K requests)","type":"boolean"},"updatedAt":{"format":"int64","type":"integer"}},"required":["id","type","tmdbId","title","status","stage","requestedBy","dryRun","createdAt","updatedAt"],"type":"object"},"WebhookBody":{"additionalProperties":false,"properties":{"authSecret":{"description":"Bearer token or basic password (write-only). Empty string clears it; omit to keep it","maxLength":512,"type":"string"},"authType":{"enum":["none","bearer","basic",""],"type":"string"},"authUsername":{"maxLength":191,"type":"string"},"enabled":{"type":"boolean"},"events":{"description":"Any of request.created, request.approved, request.auto_approved, request.declined, request.failed, media.available, issue.created, issue.commented, issue.resolved, issue.reopened; empty means all. request.approved also receives request.auto_approved","items":{"type":"string"},"type":"array"},"headers":{"items":{"$ref":"#/components/schemas/Header"},"maxItems":20,"type":"array"},"name":{"maxLength":100,"minLength":1,"type":"string"},"perRecipient":{"type":"boolean"},"preset":{"maxLength":32,"type":"string"},"secret":{"description":"Signing secret (write-only). Empty string clears it; omit to keep it","type":"string"},"template":{"description":"Liquid body template; empty = the raw JSON envelope","maxLength":20000,"type":"string"},"url":{"description":"http(s) URL; may contain Liquid, rendered per delivery","maxLength":512,"minLength":1,"type":"string"}},"required":["name","url","enabled"],"type":"object"},"WebhookEgress":{"additionalProperties":false,"properties":{"allowedHosts":{"description":"Private targets webhooks may reach: host, host:port or CIDR","items":{"type":"string"},"type":"array"},"blocked":{"description":"Saved webhooks whose URL the policy refuses now","items":{"$ref":"#/components/schemas/WebhookRef"},"type":"array"}},"required":["allowedHosts","blocked"],"type":"object"},"WebhookInputBody":{"additionalProperties":false,"properties":{"ItemType":{"type":"string"},"NotificationType":{"description":"Jellyfin webhook plugin event, e.g. PlaybackStop or ItemAdded","type":"string"},"UserId":{"type":"string"}},"type":"object"},"WebhookPreview":{"additionalProperties":false,"properties":{"body":{"type":"string"},"delivered":{"description":"Test only: whether the receiver accepted it","type":"boolean"},"deliveryError":{"description":"Test only: why the delivery failed","type":"string"},"error":{"description":"Template or URL problem; nothing else is set then","type":"string"},"headers":{"description":"Secrets are masked","items":{"$ref":"#/components/schemas/Header"},"type":"array"},"url":{"type":"string"},"validJson":{"type":"boolean"}},"required":["url","headers","body","validJson"],"type":"object"},"WebhookRef":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"WebhookView":{"additionalProperties":false,"properties":{"authSecretConfigured":{"description":"A bearer token or basic password is saved (write-only)","type":"boolean"},"authType":{"enum":["none","bearer","basic"],"type":"string"},"authUsername":{"type":"string"},"blockedByPolicy":{"description":"The URL points at a private or local address the outbound policy refuses (see /admin/webhooks/egress)","type":"boolean"},"enabled":{"type":"boolean"},"events":{"description":"Subscribed events; empty means all","items":{"type":"string"},"type":"array"},"headers":{"items":{"$ref":"#/components/schemas/Header"},"type":"array"},"id":{"type":"string"},"name":{"type":"string"},"perRecipient":{"description":"One delivery per concerned person, with notifyUser filled","type":"boolean"},"preset":{"description":"Preset the webhook was made from (informative)","type":"string"},"secretConfigured":{"description":"A signing secret is saved (HMAC-SHA256 in X-Tipsarr-Signature)","type":"boolean"},"template":{"description":"Liquid body template; empty = the raw JSON envelope","type":"string"},"url":{"description":"May contain Liquid ({{ ... }})","type":"string"}},"required":["id","name","url","events","enabled","secretConfigured","preset","template","headers","authType","authUsername","authSecretConfigured","perRecipient","blockedByPolicy"],"type":"object"},"WeekRef":{"additionalProperties":false,"properties":{"end":{"description":"Last day covered, YYYY-MM-DD","type":"string"},"key":{"description":"Week key; weekly charts end with a \"w\"","type":"string"},"label":{"type":"string"},"start":{"description":"First day covered, YYYY-MM-DD","type":"string"},"weekly":{"description":"Full Monday-Sunday week instead of the Friday-Sunday weekend","type":"boolean"}},"required":["key","label","weekly"],"type":"object"}},"securitySchemes":{"bearer":{"scheme":"bearer","type":"http"},"session":{"in":"cookie","name":"tipsarr_session","type":"apiKey"}}},"info":{"title":"Tipsarr","version":"0.1.0"},"openapi":"3.1.0","paths":{"/admin/boxoffice/alias":{"delete":{"operationId":"deleteBoxOfficeAlias","parameters":[{"explode":false,"in":"query","name":"title","required":true,"schema":{"maxLength":255,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a manual match and match automatically again (admin)","tags":["admin"]},"put":{"description":"Fixes titles the automatic search matched wrongly or not at all. Applies to every stored week and to future charts.","operationId":"setBoxOfficeAlias","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetBoxOfficeAliasRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Pin a box-office title to a TMDB movie (admin)","tags":["admin"]}},"/admin/ldap/import-jellyfin":{"post":{"description":"Needs the Jellyfin API key. The password is copied server-side and never sent to the browser.","operationId":"importLdapFromJellyfin","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LdapImportBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Copy the LDAP settings of Jellyfin's LDAP Authentication plugin (admin)","tags":["admin"]}},"/admin/permissions":{"get":{"description":"Each person has a mask: the sum of the values of the flags they have. Admins can do everything whatever their mask says. Flags marked reserved exist so the numbers match Seerr but no feature uses them yet: do not show them.","operationId":"getPermissions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetPermissionsResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"The permission flags and the defaults for new people (needs the manage-users right)","tags":["admin"]}},"/admin/permissions/defaults":{"put":{"description":"Only changes people who sign in or are imported from now on.","operationId":"setPermissionDefaults","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PermissionDefaults"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PermissionDefaults"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Set the permissions and quotas new people start with (admin)","tags":["admin"]}},"/admin/push/register":{"post":{"description":"Asks the relay for a key of its own (POST /v1/register with this server's install id), saves it with the address and turns push on. Registering again replaces the key.","operationId":"registerPush","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterPushRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SettingsBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Turn mobile push on with the relay's open registration (admin)","tags":["admin"]}},"/admin/secrets/{name}/regenerate":{"post":{"description":"The old value stops working at once: update the Jellyfin webhook plugin or the dashboards. For api_key this also turns the key on.","operationId":"regenerateSecret","parameters":[{"in":"path","name":"name","required":true,"schema":{"enum":["jellyfin_webhook","api_key"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretOutputBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Replace a secret with a new one and show it (admin role only)","tags":["admin"]}},"/admin/secrets/{name}/reveal":{"post":{"description":"The Jellyfin webhook secret (X-Tipsarr-Token) or the dashboard key. POST so it is never cached nor read with the read-only key.","operationId":"revealSecret","parameters":[{"in":"path","name":"name","required":true,"schema":{"enum":["jellyfin_webhook","api_key"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SecretOutputBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Show a secret (admin role only)","tags":["admin"]}},"/admin/servarr":{"get":{"operationId":"listServarr","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/InstanceView"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Radarr/Sonarr instances (admin)","tags":["admin"]},"post":{"operationId":"createServarr","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceView"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a Radarr/Sonarr instance (admin)","tags":["admin"]}},"/admin/servarr/probe":{"post":{"description":"Read-only: uses a dry-run client, so it can never change anything in Radarr/Sonarr.","operationId":"probeServarr","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProbeResult"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Test a Radarr/Sonarr connection and list its quality profiles and root folders (admin)","tags":["admin"]}},"/admin/servarr/{id}":{"delete":{"operationId":"deleteServarr","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a Radarr/Sonarr instance (admin)","tags":["admin"]},"put":{"operationId":"updateServarr","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceView"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Update a Radarr/Sonarr instance (admin)","tags":["admin"]}},"/admin/settings":{"get":{"operationId":"getSettings","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SettingsBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Read settings (admin)","tags":["admin"]},"put":{"operationId":"updateSettings","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSettingsInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SettingsBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Update settings (admin)","tags":["admin"]}},"/admin/sync":{"get":{"operationId":"syncStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SyncStatusBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Library / history sync status (admin)","tags":["admin"]}},"/admin/sync/{job}":{"post":{"operationId":"runSyncJob","parameters":[{"in":"path","name":"job","required":true,"schema":{"enum":["library-sync","history-sync","boxoffice-refresh","servarr-import","playback-sync","ytdlp-update"],"type":"string"}}],"responses":{"202":{"description":"Accepted"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Run a sync job now, in the background (admin)","tags":["admin"]}},"/admin/teasers/files":{"delete":{"operationId":"teaserFilesClear","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Empty the resolver's cache and forget every file (admin)","tags":["admin"]},"get":{"description":"The Teasers feed plays trailers from files fetched by the optional trailer-resolver sidecar. The resolver is asked for its health now.","operationId":"teaserFilesOverview","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Overview"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Trailer files: settings state, resolver health and file counts (admin)","tags":["admin"]}},"/admin/teasers/files/test":{"post":{"description":"Uses the saved resolver address and key even while the feature is off. Waits up to 90 seconds; if the video is not ready by then the state is queued or running and the test can be repeated.","operationId":"teaserFilesTest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeaserFilesTestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestResult"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Resolve one video through the resolver and report time and size (admin)","tags":["admin"]}},"/admin/users":{"get":{"operationId":"listUsers","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/User"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"All Tipsarr users (admin)","tags":["admin"]}},"/admin/users/import":{"post":{"description":"Adds Jellyfin users who have not logged in to Tipsarr yet (needs the Jellyfin API key). Existing users keep their role.","operationId":"importUsers","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportUsersResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Import users from Jellyfin (admin)","tags":["admin"]}},"/admin/users/{id}":{"patch":{"operationId":"updateUser","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Change a user's role, region, language, permissions or quotas (needs the manage-users right; roles are for admins)","tags":["admin"]}},"/admin/users/{id}/history-sources":{"get":{"operationId":"adminHistorySources","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistorySourcesBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Whose watch history a person's recommendations also use (admin only)","tags":["admin"]},"put":{"description":"Admins only, not even the manage-users right: it exposes what one account watched to another person. Replaces the list. An empty list goes back to the person's own history. The sources must be Tipsarr users (import the shared account from Jellyfin first); a person cannot be their own source; at most 5. Only one level is followed: a source's own sources are not used. The person's recommendations are rebuilt in the background.","operationId":"setHistorySources","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetHistorySourcesRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistorySourcesBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Set whose watch history a person's recommendations also use (admin only)","tags":["admin"]}},"/admin/users/{id}/teasers/history":{"get":{"operationId":"adminTeaserHistory","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}},{"explode":false,"in":"query","name":"limit","schema":{"default":40,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"offset","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTeaserHistoryResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"A person's Teasers history, newest first (needs the manage-users right)","tags":["admin"]}},"/admin/users/{id}/teasers/seen":{"delete":{"operationId":"adminTeaserForgetAll","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Wipe a person's whole Teasers history (needs the manage-users right)","tags":["admin"]}},"/admin/users/{id}/teasers/seen/{type}/{tmdbId}":{"delete":{"operationId":"adminTeaserForget","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}},{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"tmdbId","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Forget one title of a person's Teasers history (needs the manage-users right)","tags":["admin"]}},"/admin/webhooks":{"get":{"operationId":"listWebhooks","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/WebhookView"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Outgoing webhooks (admin)","tags":["admin"]},"post":{"operationId":"createWebhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookBody"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookView"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add an outgoing webhook (admin)","tags":["admin"]}},"/admin/webhooks/egress":{"get":{"description":"Webhooks may only reach public addresses, plus the private ones listed here. Loopback and link-local addresses are always refused.","operationId":"getWebhookEgress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEgress"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Outbound policy of webhooks (admin)","tags":["admin"]},"put":{"operationId":"updateWebhookEgress","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEgressRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEgress"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Set the private hosts webhooks may reach (admin)","tags":["admin"]}},"/admin/webhooks/presets":{"get":{"operationId":"listWebhookPresets","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebhookPresetsResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Ready-made webhook templates and the events (admin)","tags":["admin"]}},"/admin/webhooks/preview":{"post":{"description":"Renders the URL, headers and body for a made-up event. With id, saved secrets are used for what the form leaves out.","operationId":"previewWebhook","parameters":[{"description":"Saved webhook whose secrets fill what the form omits","explode":false,"in":"query","name":"id","schema":{"description":"Saved webhook whose secrets fill what the form omits","maxLength":32,"type":"string"}},{"description":"Event to simulate (default test)","explode":false,"in":"query","name":"event","schema":{"description":"Event to simulate (default test)","maxLength":40,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPreview"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Render a webhook form with sample values, without sending (admin)","tags":["admin"]}},"/admin/webhooks/variables":{"get":{"description":"Built from the same variables as real deliveries, with the sample data of the preview. empty marks what the event does not fill; perRecipient what only one delivery per recipient fills.","operationId":"listWebhookVariables","parameters":[{"description":"Event (default test)","explode":false,"in":"query","name":"event","schema":{"description":"Event (default test)","maxLength":40,"type":"string"}},{"description":"Language of the descriptions (default: the caller's)","explode":false,"in":"query","name":"lang","schema":{"description":"Language of the descriptions (default: the caller's)","maxLength":16,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Variable"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Template variables of an event, with sample values (admin)","tags":["admin"]}},"/admin/webhooks/{id}":{"delete":{"operationId":"deleteWebhook","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Delete an outgoing webhook (admin)","tags":["admin"]},"put":{"operationId":"updateWebhook","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookView"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Update an outgoing webhook (admin)","tags":["admin"]}},"/admin/webhooks/{id}/test":{"post":{"description":"204 when delivered. 422 template_error when the template does not render (the message says where), 502 when the receiver fails.","operationId":"testWebhook","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Send a test event to a webhook (admin)","tags":["admin"]}},"/auth/demo":{"post":{"description":"Only on a demo sandbox (status.demo): signs in as the seeded admin or as a plain user, without a password.","operationId":"demoLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoLoginInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"summary":"One-click sign-in of the public demo","tags":["auth"]}},"/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"summary":"Log in with a Jellyfin account","tags":["auth"]}},"/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"End the current session","tags":["auth"]}},"/auth/methods":{"get":{"description":"The login page hides the password form when single sign-on is set up; it comes back if single sign-on refuses someone.","operationId":"authMethods","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthMethods"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"How people can sign in","tags":["auth"]}},"/auth/token":{"post":{"operationId":"loginToken","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"summary":"Log in from an app and get a bearer token","tags":["auth"]}},"/blocklist":{"get":{"operationId":"list_blocklist","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"My blocklist (titles hidden from my suggestions)","tags":["blocklist"]},"post":{"operationId":"add_blocklist","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Add_blocklistRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a title to my blocklist (titles hidden from my suggestions)","tags":["blocklist"]}},"/blocklist/{type}/{id}":{"delete":{"operationId":"remove_blocklist","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a title from my blocklist (titles hidden from my suggestions)","tags":["blocklist"]}},"/boxoffice":{"get":{"description":"Global, not personalised: one chart per region and week. Defaults to your region (if configured) and the latest stored week. Display only: nothing is added automatically.","operationId":"boxOffice","parameters":[{"description":"Region code, e.g. US, GB, FR","explode":false,"in":"query","name":"region","schema":{"description":"Region code, e.g. US, GB, FR","maxLength":3,"type":"string"}},{"description":"Week key, e.g. 2026W40 (a trailing w means the full week)","explode":false,"in":"query","name":"week","schema":{"description":"Week key, e.g. 2026W40 (a trailing w means the full week)","maxLength":12,"type":"string"}},{"description":"List full Monday-Sunday weeks instead of weekends","explode":false,"in":"query","name":"weekly","schema":{"description":"List full Monday-Sunday weeks instead of weekends","type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Chart"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Box-office chart (top 10): weekend or full week with availability and Radarr status","tags":["boxoffice"]}},"/collection/{id}":{"get":{"operationId":"collection","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionDetail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Movie collection","tags":["media"]}},"/discover/genres/{type}":{"get":{"operationId":"discoverGenres","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Genre"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Genre list","tags":["discover"]}},"/discover/keywords/{id}":{"get":{"operationId":"keyword","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Keyword"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"One tag (keyword) by id","tags":["discover"]}},"/discover/movies":{"get":{"operationId":"discover_movie","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}},{"description":"TMDB genre id","explode":false,"in":"query","name":"genre","schema":{"description":"TMDB genre id","format":"int64","maximum":1000000,"minimum":0,"type":"integer"}},{"description":"TMDB keyword (tag) id","explode":false,"in":"query","name":"keyword","schema":{"description":"TMDB keyword (tag) id","format":"int64","maximum":100000000,"minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Popular movie titles, optionally by genre","tags":["discover"]}},"/discover/trending":{"get":{"operationId":"discoverTrending","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Trending movies and shows this week","tags":["discover"]}},"/discover/tv":{"get":{"operationId":"discover_tv","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}},{"description":"TMDB genre id","explode":false,"in":"query","name":"genre","schema":{"description":"TMDB genre id","format":"int64","maximum":1000000,"minimum":0,"type":"integer"}},{"description":"TMDB keyword (tag) id","explode":false,"in":"query","name":"keyword","schema":{"description":"TMDB keyword (tag) id","format":"int64","maximum":100000000,"minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Popular tv titles, optionally by genre","tags":["discover"]}},"/discover/upcoming":{"get":{"operationId":"discoverUpcoming","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Upcoming movies","tags":["discover"]}},"/health":{"get":{"operationId":"health","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthOutputBody"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Liveness probe","tags":["system"]}},"/hooks/jellyfin":{"post":{"description":"Send the secret in the X-Tipsarr-Token header (the Webhook plugin's Generic destination can add request headers); the token query parameter still works. Configure the Jellyfin Webhook plugin with a Generic destination pointing here and a template like {\"NotificationType\":\"{{NotificationType}}\",\"UserId\":\"{{UserId}}\",\"ItemType\":\"{{ItemType}}\"}. Library events refresh the library; everything else refreshes that user's watch history (debounced).","operationId":"jellyfinWebhook","parameters":[{"description":"The shared secret (preferred: a header does not end up in proxy and access logs)","in":"header","name":"X-Tipsarr-Token","schema":{"description":"The shared secret (preferred: a header does not end up in proxy and access logs)","type":"string"}},{"description":"Deprecated (will be removed in a future major version): the same secret in the address, still accepted so existing set-ups keep working, but addresses end up in logs","explode":false,"in":"query","name":"token","schema":{"description":"Deprecated (will be removed in a future major version): the same secret in the address, still accepted so existing set-ups keep working, but addresses end up in logs","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookInputBody"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"Receives Jellyfin webhook events (shared-secret token)","tags":["hooks"]}},"/issues":{"get":{"operationId":"listIssues","parameters":[{"explode":false,"in":"query","name":"filter","schema":{"default":"all","enum":["all","open","resolved"],"type":"string"}},{"description":"With tmdbId: only the issues of one title","explode":false,"in":"query","name":"type","schema":{"description":"With tmdbId: only the issues of one title","maxLength":5,"type":"string"}},{"explode":false,"in":"query","name":"tmdbId","schema":{"format":"int64","minimum":0,"type":"integer"}},{"explode":false,"in":"query","name":"take","schema":{"default":20,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"skip","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListIssuesResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"List issues (admins see everyone's, users their own)","tags":["issues"]},"post":{"description":"Admins are notified through the webhooks (issue.created) and the live event stream.","operationId":"createIssue","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateIssueRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Report a problem with a movie or show","tags":["issues"]}},"/issues/counts":{"get":{"operationId":"issueCounts","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueCounts"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Open and resolved issue totals","tags":["issues"]}},"/issues/{id}":{"delete":{"operationId":"deleteIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Delete an issue (admin)","tags":["issues"]},"get":{"operationId":"getIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"One issue with its comments","tags":["issues"]}},"/issues/{id}/comments":{"post":{"operationId":"commentIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentIssueRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a comment (reporter or admin)","tags":["issues"]}},"/issues/{id}/reopen":{"post":{"operationId":"reopenIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Reopen an issue (reporter or admin)","tags":["issues"]}},"/issues/{id}/resolve":{"post":{"operationId":"resolveIssue","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueThread"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Resolve an issue (reporter or admin)","tags":["issues"]}},"/issues/{id}/servarr":{"get":{"description":"The address of its page, built from the instance's public URL (or its URL when none is set). Read-only.","operationId":"issueServarrLink","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArrLink"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Where the title of an issue lives in Radarr/Sonarr (admin)","tags":["issues"]}},"/issues/{id}/servarr/search":{"post":{"description":"Not an interactive search: Radarr/Sonarr picks the release itself and ignores one that is no upgrade. In dry-run mode nothing is sent and dryRun is true.","operationId":"issueServarrSearch","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IssueServarrSearchResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Start an automatic Radarr/Sonarr search for the title of an issue (admin)","tags":["issues"]}},"/library":{"get":{"description":"Data comes from the library sync (genres, year, rating, runtime, date added from Jellyfin). `genre` may be repeated: with `genreMode=all` (default) a title must have all of them, with `any` at least one. `watched` is about the logged-in user.","operationId":"listLibrary","parameters":[{"explode":false,"in":"query","name":"type","schema":{"default":"all","enum":["all","movie","tv"],"type":"string"}},{"description":"Part of the title","explode":false,"in":"query","name":"q","schema":{"description":"Part of the title","maxLength":100,"type":"string"}},{"description":"Repeat for several; see genreMode","explode":true,"in":"query","name":"genre","schema":{"description":"Repeat for several; see genreMode","items":{"type":"string"},"maxItems":6,"type":"array"}},{"description":"Titles must have all the genres, or at least one","explode":false,"in":"query","name":"genreMode","schema":{"default":"all","description":"Titles must have all the genres, or at least one","enum":["all","any"],"type":"string"}},{"explode":false,"in":"query","name":"yearFrom","schema":{"format":"int64","maximum":2200,"minimum":0,"type":"integer"}},{"explode":false,"in":"query","name":"yearTo","schema":{"format":"int64","maximum":2200,"minimum":0,"type":"integer"}},{"explode":false,"in":"query","name":"minRating","schema":{"format":"double","maximum":10,"minimum":0,"type":"number"}},{"description":"Minutes","explode":false,"in":"query","name":"maxRuntime","schema":{"description":"Minutes","format":"int64","maximum":1000,"minimum":0,"type":"integer"}},{"description":"By the logged-in user","explode":false,"in":"query","name":"watched","schema":{"default":"any","description":"By the logged-in user","enum":["any","yes","no"],"type":"string"}},{"description":"Admins only: titles nobody ever watched (library clean-up)","explode":false,"in":"query","name":"neverWatched","schema":{"description":"Admins only: titles nobody ever watched (library clean-up)","type":"boolean"}},{"explode":false,"in":"query","name":"sort","schema":{"default":"added","enum":["added","title","year","rating","runtime","popular"],"type":"string"}},{"explode":false,"in":"query","name":"dir","schema":{"default":"desc","enum":["asc","desc"],"type":"string"}},{"explode":false,"in":"query","name":"page","schema":{"default":1,"format":"int64","minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"pageSize","schema":{"default":48,"format":"int64","maximum":100,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LibraryList"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Browse what is in Jellyfin, with filters and sorting","tags":["library"]}},"/library/facets":{"get":{"operationId":"libraryFacets","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LibraryFacets"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Genres (with counts), years and counts to build the Library filters","tags":["library"]}},"/me":{"get":{"operationId":"me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Current user","tags":["auth"]},"patch":{"operationId":"updateMe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PrefsBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Change my region, language, rating source and chat ids","tags":["auth"]}},"/me/devices/current":{"delete":{"operationId":"unregisterDevice","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Stop push notifications on this device","tags":["push"]},"get":{"operationId":"getDevice","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushDevice"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"This app's push registration","tags":["push"]},"put":{"description":"Call it at every launch and whenever the OS hands out a new token or the user changes the notification toggles. The registration belongs to the current session and disappears when it ends.","operationId":"registerDevice","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterDeviceInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushDevice"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Register or refresh this app's push token","tags":["push"]}},"/me/history-sources":{"get":{"description":"Set by an admin (a shared TV account, say). Read-only here: it explains why titles that someone else watched appear in Discover.","operationId":"myHistorySources","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistorySourcesBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Whose watch history your recommendations also use","tags":["me"]}},"/me/quota":{"get":{"description":"Movies count one per request, shows one per season, over the last N days. Admins and people who manage requests have no limit.","operationId":"myQuota","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quota"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"How many requests the signed-in person may still make","tags":["requests"]}},"/me/sessions":{"delete":{"operationId":"revokeOtherSessions","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Sign out everywhere except here","tags":["auth"]},"get":{"operationId":"listSessions","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/DeviceSession"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Where I am signed in","tags":["auth"]}},"/me/sessions/{id}":{"delete":{"operationId":"revokeSession","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":16,"minLength":16,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Sign one of my devices out","tags":["auth"]}},"/media/movie/{id}/ratings":{"get":{"description":"From Radarr; Rotten Tomatoes' own search fills in when Radarr has no Rotten Tomatoes score. Empty when nothing is found.","operationId":"movieRatings","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MovieScores"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"IMDb, Metacritic and Rotten Tomatoes scores of a movie","tags":["media"]}},"/media/tv/{id}/ratings":{"get":{"description":"Radarr only knows movies, so shows get Rotten Tomatoes' own search. Empty when nothing is found.","operationId":"showRatings","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MovieScores"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Rotten Tomatoes scores of a show","tags":["media"]}},"/media/tv/{id}/seasons/{season}":{"get":{"description":"`season` follows the numbering of the show's detail (`seasonNumbering`): Sonarr's (TVDB) when it can tell. A season no source can list yet has no episodes.","operationId":"tvSeason","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}},{"in":"path","name":"season","required":true,"schema":{"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SeasonDetail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Episodes of a season","tags":["media"]}},"/media/{type}/{id}":{"get":{"operationId":"mediaDetail","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Detail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Movie or show details with cast, recommendations and similar titles","tags":["media"]}},"/media/{type}/{id}/flags":{"get":{"operationId":"mediaFlags","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Flags"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Is this title on my watchlist / blocklist?","tags":["media"]}},"/person/{id}":{"get":{"operationId":"person","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PersonDetail"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Person with credits","tags":["media"]}},"/ratings/movies":{"get":{"description":"`ids` is a comma-separated list of TMDB ids (at most 40). Movies whose scores are not ready within a few seconds are left out; ask again later.","operationId":"moviesRatings","parameters":[{"explode":false,"in":"query","name":"ids","required":true,"schema":{"maxLength":400,"type":"string"}},{"description":"The score the posters show; Rotten Tomatoes' own search is only asked for rottenTomatoes","explode":false,"in":"query","name":"source","schema":{"description":"The score the posters show; Rotten Tomatoes' own search is only asked for rottenTomatoes","enum":["tmdb","imdb","metacritic","rottenTomatoes",""],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{"$ref":"#/components/schemas/MovieScores"},"type":"object"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Scores of several movies at once, for the posters of a page","tags":["media"]}},"/requests":{"get":{"operationId":"listRequests","parameters":[{"explode":false,"in":"query","name":"filter","schema":{"default":"all","enum":["all","mine","pending","approved","available","declined","failed","unwatched"],"type":"string"}},{"description":"Admins only: only this user's requests","explode":false,"in":"query","name":"user","schema":{"description":"Admins only: only this user's requests","maxLength":64,"type":"string"}},{"explode":false,"in":"query","name":"take","schema":{"default":20,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"skip","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestList"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"List requests (admins see everyone's, users their own)","tags":["requests"]},"post":{"description":"Never automatic: this is always a user action. Admin requests are approved immediately. In dry-run mode an approved request is recorded but nothing is sent to Radarr/Sonarr.","operationId":"createRequest","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRequestRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"Created"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Request a movie or show","tags":["requests"]}},"/requests/counts":{"get":{"operationId":"requestCounts","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestCountsResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Requests per status","tags":["requests"]}},"/requests/options":{"get":{"description":"Read-only calls to the default Radarr (movie) or Sonarr (tv) instance.","operationId":"requestOptions","parameters":[{"explode":false,"in":"query","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Options"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Quality profiles (and, when allowed, root folders) you can pick when requesting","tags":["requests"]}},"/requests/{id}":{"delete":{"operationId":"deleteRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Delete a request (admin: any; owner: unfinished ones). A request still waiting in Radarr/Sonarr is removed there too (files are kept)","tags":["requests"]},"get":{"operationId":"getRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"One request","tags":["requests"]},"patch":{"operationId":"updateRequestOptions","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRequestOptionsRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Change the quality profile / root folder of a pending or failed request (requester or admin)","tags":["requests"]}},"/requests/{id}/approve":{"post":{"operationId":"approveRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApproveRequestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Approve and send to Radarr/Sonarr (admin; also retries a failed request)","tags":["requests"]}},"/requests/{id}/decline":{"post":{"operationId":"declineRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeclineRequestRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Decline a request (admin)","tags":["requests"]}},"/requests/{id}/progress":{"get":{"operationId":"requestProgress","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Live stage and download progress","tags":["requests"]}},"/requests/{id}/retry":{"post":{"operationId":"retryRequest","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":32,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/View"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Retry a failed request (admin)","tags":["requests"]}},"/search":{"get":{"operationId":"search","parameters":[{"description":"Page number","explode":false,"in":"query","name":"page","schema":{"default":1,"description":"Page number","format":"int64","maximum":500,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"q","required":true,"schema":{"maxLength":200,"minLength":1,"type":"string"}},{"description":"Also look for titles carrying a matching tag (slower; for the results page)","explode":false,"in":"query","name":"tags","schema":{"description":"Also look for titles carrying a matching tag (slower; for the results page)","type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Search movies, shows and people","tags":["discover"]}},"/setup":{"post":{"description":"Only allowed until a Jellyfin URL is saved, and, when TIPSARR_SETUP_TOKEN=true, only with the setup token printed in the server log. Afterwards the first Jellyfin administrator to log in becomes the Tipsarr admin.","operationId":"setup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupOutputBody"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Conflict"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"summary":"First-run setup: point Tipsarr at Jellyfin","tags":["setup"]}},"/setup/status":{"get":{"operationId":"setupStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupStatusOutputBody"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Has first-run setup been completed?","tags":["setup"]}},"/stats":{"get":{"description":"Exact when Jellyfin's Playback Reporting plugin is installed (its plays are copied into Tipsarr), otherwise estimated from the watch history. People see their own numbers; `user` and `all` need the manage-users right.","operationId":"getStats","parameters":[{"description":"A user id, or all (admins). Default: yourself","explode":false,"in":"query","name":"user","schema":{"description":"A user id, or all (admins). Default: yourself","maxLength":64,"type":"string"}},{"explode":false,"in":"query","name":"period","schema":{"default":"all","enum":["30d","12m","all"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsReport"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Watching and request statistics","tags":["stats"]}},"/status":{"get":{"operationId":"status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusOutputBody"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Version and runtime info","tags":["system"]}},"/suggestions":{"get":{"description":"Generated the first time it is called, then recomputed only when your watch history changed. Display only: nothing is ever requested automatically.","operationId":"suggestions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Result"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Your suggestion rows: \"Recommended for you\" and \"Because you watched …\"","tags":["suggestions"]}},"/suggestions/refresh":{"post":{"operationId":"refreshSuggestions","responses":{"202":{"description":"Accepted"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Too Many Requests"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Recompute my suggestions in the background (once a minute at most)","tags":["suggestions"]}},"/teasers":{"get":{"description":"Answers 404 (code teasers_disabled) when trailer files are not set up on this server: apps then hide the tab. Titles you have not swiped past yet, mixed from your suggestions, trending and upcoming, each with a trailer in your language. Skips titles you watched, blocked, requested or already have in the library. Display only: nothing is requested automatically.","operationId":"teasers","parameters":[{"explode":false,"in":"query","name":"limit","schema":{"default":8,"format":"int64","maximum":20,"minimum":1,"type":"integer"}},{"description":"Comma-separated type:id keys the app already holds, for example movie:603,tv:1399","explode":false,"in":"query","name":"exclude","schema":{"description":"Comma-separated type:id keys the app already holds, for example movie:603,tv:1399","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Feed"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"},"502":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Bad Gateway"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Service Unavailable"}},"security":[{"session":[]},{"bearer":[]}],"summary":"The next trailers of the Teasers feed","tags":["teasers"]}},"/teasers/history":{"get":{"description":"Open to every signed-in person (their own history). Titles are looked up in your language.","operationId":"teaserHistory","parameters":[{"explode":false,"in":"query","name":"limit","schema":{"default":40,"format":"int64","maximum":100,"minimum":1,"type":"integer"}},{"explode":false,"in":"query","name":"offset","schema":{"default":0,"format":"int64","minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeaserHistoryResponse"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Titles you swiped past in the Teasers feed, newest first","tags":["teasers"]}},"/teasers/seen":{"delete":{"operationId":"teaserForgetAll","responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Wipe your whole Teasers history: every title can come back in the feed","tags":["teasers"]},"post":{"operationId":"teaserSeen","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeaserSeenRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Mark a title as swiped past, so the feed never shows it again","tags":["teasers"]}},"/teasers/seen/{type}/{id}":{"delete":{"operationId":"teaserForget","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Forget one title of your Teasers history","tags":["teasers"]}},"/users/{id}":{"get":{"description":"Everyone can read their own profile; admins can read anyone's.","operationId":"userProfile","parameters":[{"in":"path","name":"id","required":true,"schema":{"maxLength":64,"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileView"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"A user's profile and request statistics","tags":["users"]}},"/watchlist":{"get":{"operationId":"list_watchlist","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Item"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"My watchlist","tags":["watchlist"]},"post":{"operationId":"add_watchlist","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Add_watchlistRequest"}}},"required":true},"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Not Found"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Add a title to my watchlist","tags":["watchlist"]}},"/watchlist/{type}/{id}":{"delete":{"operationId":"remove_watchlist","parameters":[{"in":"path","name":"type","required":true,"schema":{"enum":["movie","tv"],"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"int64","minimum":1,"type":"integer"}}],"responses":{"204":{"description":"No Content"},"401":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unauthorized"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Internal Server Error"}},"security":[{"session":[]},{"bearer":[]}],"summary":"Remove a title from my watchlist","tags":["watchlist"]}}},"servers":[{"url":"/api/v1"}]} diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index 7a15e2c9..2f96b30d 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -1202,7 +1202,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Episodes of a season */ + /** + * Episodes of a season + * @description `season` follows the numbering of the show's detail (`seasonNumbering`): Sonarr's (TVDB) when it can tell. A season no source can list yet has no episodes. + */ get: operations["tvSeason"]; put?: never; post?: never; @@ -1824,6 +1827,11 @@ export interface components { qualityProfileId?: number; /** @description Root folder to use (omit for the default) */ rootFolder?: string; + /** + * @description TV only: the detail's seasonNumbering the seasons were picked in; a different one than the server's now is 409 season_numbering_changed + * @enum {string} + */ + seasonNumbering?: "tvdb" | "tmdb"; /** @description TV only; empty means every season */ seasons?: number[]; /** Format: int64 */ @@ -1903,6 +1911,13 @@ export interface components { reviews: components["schemas"]["Review"][]; /** Format: int64 */ runtimeMinutes?: number; + /** + * @description Numbering of seasons: tvdb (Sonarr) or tmdb (fallback when Sonarr cannot tell) + * @enum {string} + */ + seasonNumbering?: "tvdb" | "tmdb"; + /** @description TMDB splits this show's seasons differently from Sonarr (TVDB) */ + seasonNumberingDiffers?: boolean; seasons?: components["schemas"]["Season"][]; similar: components["schemas"]["Item"][]; status?: string; @@ -2407,7 +2422,7 @@ export interface components { tvQuotaDays: number; /** * Format: int64 - * @description Seasons of shows allowed per period, 0 = no limit + * @description Seasons of shows allowed per period (one request costs at most this), 0 = no limit */ tvQuotaLimit: number; }; @@ -2688,7 +2703,10 @@ export interface components { }; Season: { airDate?: string; - /** Format: int64 */ + /** + * Format: int64 + * @description Episodes in the season; 0 = unknown + */ episodeCount: number; /** * Format: int64 @@ -3229,6 +3247,11 @@ export interface components { requestedBy: components["schemas"]["UserRef"]; /** @description Chosen root folder (absent = the instance default) */ rootFolder?: string; + /** + * @description Shows only: numbering of seasons, tvdb (Sonarr) or tmdb (made while Sonarr could not tell; translated when it is sent) + * @enum {string} + */ + seasonNumbering?: "tvdb" | "tmdb"; /** @description Shows only: completion per season */ seasonProgress?: components["schemas"]["SeasonProgress"][]; seasons?: number[]; diff --git a/frontend/src/lib/components/media/detail-actions.svelte b/frontend/src/lib/components/media/detail-actions.svelte index 30b7aa3e..07c84277 100644 --- a/frontend/src/lib/components/media/detail-actions.svelte +++ b/frontend/src/lib/components/media/detail-actions.svelte @@ -68,6 +68,8 @@ requestStatus={details.requestStatus} monitored={details.monitored} title={details.title} + seasonNumbering={details.seasonNumbering} + numberingDiffers={details.seasonNumberingDiffers} /> {/if} diff --git a/frontend/src/lib/components/media/season-list.svelte b/frontend/src/lib/components/media/season-list.svelte index 6286bd11..36a0f04b 100644 --- a/frontend/src/lib/components/media/season-list.svelte +++ b/frontend/src/lib/components/media/season-list.svelte @@ -6,7 +6,16 @@ import { seasonStatuses, episodeStatus } from '$lib/status'; import ChevronIcon from '@lucide/svelte/icons/chevron-down'; - let { tmdbId, seasons }: { tmdbId: number; seasons: Schemas['Season'][] } = $props(); + let { + tmdbId, + seasons, + numberingDiffers = false, + }: { + tmdbId: number; + seasons: Schemas['Season'][]; + /** Sonarr (TVDB) splits the show differently from TMDB. */ + numberingDiffers?: boolean; + } = $props(); let openSeason = $state(null); let episodes = $state>({}); @@ -35,6 +44,7 @@ const date = (d?: string) => fmtDate(d); +{#if numberingDiffers}

{t('seasons.numbering_note')}

{/if}
    {#each seasons as s (s.number)}
  • @@ -49,7 +59,7 @@ {#each seasonStatuses(s) as st (st)} {t(`state.${st}` as 'state.available')} {/each} - {t('seasons.episodes', { count: s.episodeCount })}{#if s.airDate} · {s.airDate.slice(0, 4)}{/if} + {#if s.episodeCount > 0}{t('seasons.episodes', { count: s.episodeCount })}{/if}{#if s.airDate}{#if s.episodeCount > 0} · {/if}{s.airDate.slice(0, 4)}{/if} @@ -59,6 +69,8 @@

    {t('seasons.loading')}

    {:else if error}

    {error}

    + {:else if !episodes[s.number]?.length} +

    {t('seasons.no_episodes')}

    {:else}
      {#each episodes[s.number] ?? [] as e (e.number)} diff --git a/frontend/src/lib/components/requests/request-button.svelte b/frontend/src/lib/components/requests/request-button.svelte index 3498fea2..f0551a50 100644 --- a/frontend/src/lib/components/requests/request-button.svelte +++ b/frontend/src/lib/components/requests/request-button.svelte @@ -13,6 +13,8 @@ monitored = false, onRequested, title = '', + seasonNumbering = undefined, + numberingDiffers = false, }: { type: 'movie' | 'tv'; tmdbId: number; @@ -23,6 +25,8 @@ monitored?: boolean; onRequested?: (r: Schemas['View']) => void; title?: string; + seasonNumbering?: 'tvdb' | 'tmdb'; + numberingDiffers?: boolean; } = $props(); let created = $state(null); @@ -55,6 +59,8 @@ {tmdbId} {title} {seasons} + {seasonNumbering} + {numberingDiffers} onrequested={(r) => { created = r.status; onRequested?.(r); diff --git a/frontend/src/lib/components/requests/request-dialog.svelte b/frontend/src/lib/components/requests/request-dialog.svelte index 33c6c29b..b7203d8f 100644 --- a/frontend/src/lib/components/requests/request-dialog.svelte +++ b/frontend/src/lib/components/requests/request-dialog.svelte @@ -18,6 +18,7 @@ busy = false, error = null, quotaText = '', + numberingDiffers = false, onsubmit, }: { open?: boolean; @@ -30,6 +31,8 @@ error?: string | null; /** What is left of the person's request limit, empty when there is none. */ quotaText?: string; + /** Sonarr (TVDB) splits the show differently from TMDB. */ + numberingDiffers?: boolean; onsubmit: (choice: RequestChoice) => void; } = $props(); @@ -72,6 +75,7 @@ {title ? t('req.dialog_title', { title }) : t('req.which_seasons')} {#if quotaText}{quotaText}{/if} + {#if type === 'tv' && numberingDiffers}{t('seasons.numbering_note')}{/if} {#if askSeasons} @@ -95,7 +99,7 @@ {#each seasonStatuses(s) as st (st)} {t(`state.${st}` as 'state.available')} {/each} - {#if !complete}{t('seasons.episodes', { count: s.episodeCount })}{/if} + {#if !complete && s.episodeCount > 0}{t('seasons.episodes', { count: s.episodeCount })}{/if} {/each} diff --git a/frontend/src/lib/components/requests/request-flow.svelte b/frontend/src/lib/components/requests/request-flow.svelte index 854ffc87..1fc4dd3d 100644 --- a/frontend/src/lib/components/requests/request-flow.svelte +++ b/frontend/src/lib/components/requests/request-flow.svelte @@ -16,6 +16,8 @@ tmdbId, title = '', seasons = undefined, + seasonNumbering = undefined, + numberingDiffers = false, busy = $bindable(false), onrequested, }: { @@ -24,6 +26,9 @@ title?: string; /** Known seasons; fetched on demand when omitted (cards). */ seasons?: Schemas['Season'][]; + /** Numbering the known seasons follow (MediaDetail.seasonNumbering); sent so a stale pick gets 409. */ + seasonNumbering?: 'tvdb' | 'tmdb'; + numberingDiffers?: boolean; busy?: boolean; onrequested?: (r: Schemas['View']) => void; } = $props(); @@ -31,6 +36,9 @@ let open = $state(false); let error = $state(null); let loaded = $state(null); + let loadedNumbering = $state<{ numbering?: 'tvdb' | 'tmdb'; differs?: boolean } | null>(null); + const numbering = $derived(seasons ? seasonNumbering : loadedNumbering?.numbering); + const differs = $derived(seasons ? numberingDiffers : (loadedNumbering?.differs ?? false)); let options = $state(null); let quota = $state(null); const known = $derived(seasons ?? loaded ?? []); @@ -46,7 +54,7 @@ error = null; try { const r = await unwrap( - api.POST('/requests', { body: { type, tmdbId, seasons: choice.seasons, qualityProfileId: choice.qualityProfileId, rootFolder: choice.rootFolder } }), + api.POST('/requests', { body: { type, tmdbId, seasons: choice.seasons, seasonNumbering: type === 'tv' ? numbering : undefined, qualityProfileId: choice.qualityProfileId, rootFolder: choice.rootFolder } }), ); open = false; toast.success( @@ -68,7 +76,9 @@ if (type === 'tv' && seasons === undefined && loaded === null) { busy = true; try { - loaded = (await unwrap(api.GET('/media/{type}/{id}', { params: { path: { type: 'tv', id: tmdbId } } }))).seasons ?? []; + const d = await unwrap(api.GET('/media/{type}/{id}', { params: { path: { type: 'tv', id: tmdbId } } })); + loaded = d.seasons ?? []; + loadedNumbering = { numbering: d.seasonNumbering, differs: d.seasonNumberingDiffers }; } catch (e) { toast.error(errorText(e)); busy = false; @@ -88,4 +98,4 @@ } - + diff --git a/frontend/src/routes/media/[type]/[id]/+page.svelte b/frontend/src/routes/media/[type]/[id]/+page.svelte index 78993562..2dc2db10 100644 --- a/frontend/src/routes/media/[type]/[id]/+page.svelte +++ b/frontend/src/routes/media/[type]/[id]/+page.svelte @@ -185,7 +185,7 @@ {#if mediaType === 'tv' && seasons.length}

      {t('seasons.title')}

      - +
      {/if} diff --git a/ios/Tipsarr/Tipsarr/Core/API/MediaModels.swift b/ios/Tipsarr/Tipsarr/Core/API/MediaModels.swift index 5b666a16..4af101df 100644 --- a/ios/Tipsarr/Tipsarr/Core/API/MediaModels.swift +++ b/ios/Tipsarr/Tipsarr/Core/API/MediaModels.swift @@ -68,6 +68,10 @@ struct MediaDetail: Sendable { /// Radarr monitors the movie and looks for it. var monitored: Bool = false var seasons: [SeasonInfo] + /// Numbering of `seasons`: "tvdb" (Sonarr's) or "tmdb" (fallback); sent back with a request. + var seasonNumbering: String? + /// TMDB splits the show's seasons differently from Sonarr (TVDB): the list follows Sonarr's. + var seasonNumberingDiffers: Bool = false var numberOfSeasons: Int? var numberOfEpisodes: Int? var studios: [String] diff --git a/ios/Tipsarr/Tipsarr/Core/API/TipsarrAPI+Media.swift b/ios/Tipsarr/Tipsarr/Core/API/TipsarrAPI+Media.swift index 616c7bca..d675d7a1 100644 --- a/ios/Tipsarr/Tipsarr/Core/API/TipsarrAPI+Media.swift +++ b/ios/Tipsarr/Tipsarr/Core/API/TipsarrAPI+Media.swift @@ -64,11 +64,12 @@ extension TipsarrAPI { } } - func createRequest(_ type: MediaType, tmdbId: Int, seasons: [Int]?, profileID: Int?, folder: String?) async throws -> RequestRecord { + func createRequest(_ type: MediaType, tmdbId: Int, seasons: [Int]?, profileID: Int?, folder: String?, seasonNumbering: String? = nil) async throws -> RequestRecord { try await load { client in let body = Components.Schemas.CreateRequestRequest( qualityProfileId: profileID.map(Int64.init), rootFolder: folder, + seasonNumbering: seasonNumbering.flatMap(Components.Schemas.CreateRequestRequest.seasonNumberingPayload.init(rawValue:)), seasons: seasons?.map(Int64.init), tmdbId: Int64(tmdbId), _type: type == .tv ? .tv : .movie @@ -142,6 +143,8 @@ extension MediaDetail { seasons: (d.seasons ?? []).map { SeasonInfo(number: Int($0.number), name: $0.name, episodeCount: Int($0.episodeCount), airDate: $0.airDate, libraryEpisodes: Int($0.libraryEpisodes), monitored: $0.monitored) }, + seasonNumbering: d.seasonNumbering?.rawValue, + seasonNumberingDiffers: d.seasonNumberingDiffers ?? false, numberOfSeasons: d.numberOfSeasons.map(Int.init), numberOfEpisodes: d.numberOfEpisodes.map(Int.init), studios: d.studios ?? [], diff --git a/ios/Tipsarr/Tipsarr/Core/API/openapi.yaml b/ios/Tipsarr/Tipsarr/Core/API/openapi.yaml index 6f44b678..9e0ad65a 100644 --- a/ios/Tipsarr/Tipsarr/Core/API/openapi.yaml +++ b/ios/Tipsarr/Tipsarr/Core/API/openapi.yaml @@ -287,6 +287,12 @@ components: rootFolder: description: Root folder to use (omit for the default) type: string + seasonNumbering: + description: "TV only: the detail's seasonNumbering the seasons were picked in; a different one than the server's now is 409 season_numbering_changed" + enum: + - tvdb + - tmdb + type: string seasons: description: TV only; empty means every season items: @@ -447,6 +453,15 @@ components: runtimeMinutes: format: int64 type: integer + seasonNumbering: + description: "Numbering of seasons: tvdb (Sonarr) or tmdb (fallback when Sonarr cannot tell)" + enum: + - tvdb + - tmdb + type: string + seasonNumberingDiffers: + description: TMDB splits this show's seasons differently from Sonarr (TVDB) + type: boolean seasons: items: $ref: "#/components/schemas/Season" @@ -1470,7 +1485,7 @@ components: minimum: 0 type: integer tvQuotaLimit: - description: Seasons of shows allowed per period, 0 = no limit + description: Seasons of shows allowed per period (one request costs at most this), 0 = no limit format: int64 maximum: 100000 minimum: 0 @@ -2034,6 +2049,7 @@ components: airDate: type: string episodeCount: + description: Episodes in the season; 0 = unknown format: int64 type: integer libraryEpisodes: @@ -3082,6 +3098,12 @@ components: rootFolder: description: Chosen root folder (absent = the instance default) type: string + seasonNumbering: + description: "Shows only: numbering of seasons, tvdb (Sonarr) or tmdb (made while Sonarr could not tell; translated when it is sent)" + enum: + - tvdb + - tmdb + type: string seasonProgress: description: "Shows only: completion per season" items: @@ -7246,6 +7268,7 @@ paths: - media /media/tv/{id}/seasons/{season}: get: + description: "`season` follows the numbering of the show's detail (`seasonNumbering`): Sonarr's (TVDB) when it can tell. A season no source can list yet has no episodes." operationId: tvSeason parameters: - in: path diff --git a/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailModel.swift b/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailModel.swift index ebe3ba2a..1a452ebd 100644 --- a/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailModel.swift +++ b/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailModel.swift @@ -140,7 +140,7 @@ final class MediaDetailModel { func submitRequest(seasons: [Int]?, profileID: Int?, folder: String?) async throws -> RequestRecord { busy = true defer { busy = false } - let record = try await context.api.createRequest(route.type, tmdbId: route.tmdbId, seasons: seasons, profileID: profileID, folder: folder) + let record = try await context.api.createRequest(route.type, tmdbId: route.tmdbId, seasons: seasons, profileID: profileID, folder: folder, seasonNumbering: route.type == .tv ? detail?.seasonNumbering : nil) request = record coveredSeasons.formUnion(record.seasons) detail?.requestOpen = true diff --git a/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailView.swift b/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailView.swift index 301a418b..7e8b9927 100644 --- a/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Detail/MediaDetailView.swift @@ -309,7 +309,7 @@ struct MediaDetailView: View { } private var seasons: some View { - SeasonList(tmdbId: model.route.tmdbId, seasons: model.regularSeasons, covered: model.coveredSeasons, state: model.request?.state ?? .requested) + SeasonList(tmdbId: model.route.tmdbId, seasons: model.regularSeasons, covered: model.coveredSeasons, state: model.request?.state ?? .requested, numberingDiffers: model.detail?.seasonNumberingDiffers ?? false) } private func facts(_ detail: MediaDetail) -> some View { diff --git a/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift b/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift index 97c8b203..434df74f 100644 --- a/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift +++ b/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift @@ -108,6 +108,9 @@ struct RequestSheet: View { .font(.subheadline) .disabled(selectable.isEmpty) } + if model.detail?.seasonNumberingDiffers == true { + Text("m.detail.seasons_numbering_note").font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + } ForEach(model.regularSeasons) { season in let inLibrary = season.isComplete let tracked = season.monitored && !inLibrary @@ -119,8 +122,11 @@ struct RequestSheet: View { .foregroundStyle(covered ? Tokens.palette.mutedFg : Tokens.palette.fg) VStack(alignment: .leading, spacing: 2) { Text(verbatim: season.name).font(.body.weight(.semibold)) - Text(verbatim: Plural.text("seasons.episodes", count: season.episodeCount)) - .font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + // 0 = the count is unknown: no count rather than "0 episodes". + if season.episodeCount > 0 { + Text(verbatim: Plural.text("seasons.episodes", count: season.episodeCount)) + .font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + } } Spacer() ForEach(season.badges, id: \.self) { StatusBadge(state: $0) } diff --git a/ios/Tipsarr/Tipsarr/Features/Detail/SeasonList.swift b/ios/Tipsarr/Tipsarr/Features/Detail/SeasonList.swift index 55126c46..fda9ccb2 100644 --- a/ios/Tipsarr/Tipsarr/Features/Detail/SeasonList.swift +++ b/ios/Tipsarr/Tipsarr/Features/Detail/SeasonList.swift @@ -7,6 +7,8 @@ struct SeasonList: View { /// Seasons already covered by a request, with the state to show on them. let covered: Set let state: RequestState + /// TMDB splits the show differently: the list follows Sonarr's (TVDB) numbering. + var numberingDiffers: Bool = false @Environment(\.appContext) private var context @State private var open: Int? @@ -17,6 +19,9 @@ struct SeasonList: View { var body: some View { VStack(alignment: .leading, spacing: Tokens.Spacing.md) { Text("seasons.title").font(.title3.weight(.bold)).accessibilityAddTraits(.isHeader) + if numberingDiffers { + Text("m.detail.seasons_numbering_note").font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + } ForEach(seasons) { season in VStack(spacing: 0) { Button { Task { await toggle(season.number) } } label: { header(season) } @@ -59,6 +64,8 @@ struct SeasonList: View { Text("seasons.loading").font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } else if let message = failed[number] { Text(verbatim: message).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + } else if let list = episodes[number], list.isEmpty { + Text("m.detail.no_episodes").font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } else { ForEach(episodes[number] ?? []) { episode in EpisodeRow(episode: episode) } } @@ -68,9 +75,11 @@ struct SeasonList: View { } private func meta(_ season: SeasonInfo) -> String { - let count = Plural.text("seasons.episodes", count: season.episodeCount) - guard let year = season.airDate.flatMap({ $0.count >= 4 ? String($0.prefix(4)) : nil }) else { return count } - return "\(count) · \(year)" + // 0 = the count is unknown (a season only Sonarr lists, not tracked yet): show no count. + var parts: [String] = [] + if season.episodeCount > 0 { parts.append(Plural.text("seasons.episodes", count: season.episodeCount)) } + if let year = season.airDate.flatMap({ $0.count >= 4 ? String($0.prefix(4)) : nil }) { parts.append(year) } + return parts.joined(separator: " · ") } private func toggle(_ number: Int) async { diff --git a/ios/Tipsarr/Tipsarr/Localizable.xcstrings b/ios/Tipsarr/Tipsarr/Localizable.xcstrings index 9eaf2199..93ce1587 100644 --- a/ios/Tipsarr/Tipsarr/Localizable.xcstrings +++ b/ios/Tipsarr/Tipsarr/Localizable.xcstrings @@ -2358,6 +2358,22 @@ } } }, + "error.season_numbering_changed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The seasons of this show changed. Reload and pick them again." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les saisons de cette série ont changé. Recharge la page et choisis-les à nouveau." + } + } + } + }, "error.setup_done": { "localizations": { "en": { @@ -5049,6 +5065,22 @@ } } }, + "m.detail.no_episodes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The episode list appears once Sonarr tracks this show." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La liste des épisodes apparaîtra quand Sonarr suivra cette série." + } + } + } + }, "m.detail.no_overview": { "localizations": { "en": { @@ -5129,6 +5161,22 @@ } } }, + "m.detail.seasons_numbering_note": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB." + } + } + } + }, "m.detail.share": { "localizations": { "en": { @@ -11375,6 +11423,23 @@ } } }, + "req.season_range": { + "comment": "placeholders: from, to", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seasons %1$@–%2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisons %1$@ à %2$@" + } + } + } + }, "req.seasons": { "comment": "placeholders: list, list", "localizations": { @@ -12415,6 +12480,22 @@ } } }, + "seasons.no_episodes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The episode list appears once Sonarr tracks this show." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La liste des épisodes apparaîtra quand Sonarr suivra cette série." + } + } + } + }, "seasons.no_preview": { "localizations": { "en": { @@ -12431,6 +12512,22 @@ } } }, + "seasons.numbering_note": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB." + } + } + } + }, "seasons.season": { "comment": "placeholders: n", "localizations": { @@ -12815,6 +12912,39 @@ } } }, + "srv.season": { + "comment": "placeholders: n", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Season %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saison %@" + } + } + } + }, + "srv.season.specials": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Specials" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épisodes spéciaux" + } + } + } + }, "state.approved": { "localizations": { "en": { diff --git a/locales/en/mobile.json b/locales/en/mobile.json index a1aec95e..30a90c6e 100644 --- a/locales/en/mobile.json +++ b/locales/en/mobile.json @@ -65,6 +65,8 @@ "m.issue.send": "Send report", "m.detail.no_overview": "No overview available.", "m.detail.seasons_count": "%1$@ season | %1$@ seasons", + "m.detail.seasons_numbering_note": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB.", + "m.detail.no_episodes": "The episode list appears once Sonarr tracks this show.", "m.search.prompt": "Movies, TV shows, people", "m.search.recents": "Recent searches", "m.search.clear": "Clear", diff --git a/locales/en/server.json b/locales/en/server.json index c9f5a34d..5a3a26fe 100644 --- a/locales/en/server.json +++ b/locales/en/server.json @@ -92,5 +92,7 @@ "webhook.var.comment.message": "Comment text (free text: use json)", "webhook.var.comment.userId": "Id of the comment author", "webhook.var.comment.name": "Name of the comment author", - "webhook.var.data.message": "Raw event data (the data of the JSON envelope)" + "webhook.var.data.message": "Raw event data (the data of the JSON envelope)", + "srv.season": "Season {n}", + "srv.season.specials": "Specials" } diff --git a/locales/en/web.json b/locales/en/web.json index 83de10f2..470fea97 100644 --- a/locales/en/web.json +++ b/locales/en/web.json @@ -224,6 +224,7 @@ "error.tmdb_not_configured": "The TMDB key is not configured. An administrator can add it in Settings.", "error.tmdb_failed": "TMDB did not answer. Try again in a moment.", "error.already_available": "This title is already available in the library.", + "error.season_numbering_changed": "The seasons of this show changed. Reload and pick them again.", "error.duplicate_request": "This title already has an active request.", "error.bad_state": "The request is not in a state that allows this.", "error.no_instance": "No Radarr or Sonarr instance is configured for this type of title.", @@ -414,6 +415,9 @@ "seasons.season": "Season {n}", "seasons.episodes": "{count} episode | {count} episodes", "seasons.loading": "Loading episodes…", + "seasons.numbering_note": "Seasons follow Sonarr’s numbering (TVDB), which splits this show differently from TMDB.", + "seasons.no_episodes": "The episode list appears once Sonarr tracks this show.", + "req.season_range": "Seasons {from}–{to}", "seasons.no_preview": "No preview", "req.which_seasons": "Which seasons?", "req.select_none": "Select none", diff --git a/locales/fr/mobile.json b/locales/fr/mobile.json index 42f41317..1a8e234f 100644 --- a/locales/fr/mobile.json +++ b/locales/fr/mobile.json @@ -65,6 +65,8 @@ "m.issue.send": "Envoyer le signalement", "m.detail.no_overview": "Aucun résumé disponible.", "m.detail.seasons_count": "%1$@ saison | %1$@ saisons", + "m.detail.seasons_numbering_note": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB.", + "m.detail.no_episodes": "La liste des épisodes apparaîtra quand Sonarr suivra cette série.", "m.search.prompt": "Films, séries, personnes", "m.search.recents": "Recherches récentes", "m.search.clear": "Effacer", diff --git a/locales/fr/server.json b/locales/fr/server.json index c69df080..f74d5b9e 100644 --- a/locales/fr/server.json +++ b/locales/fr/server.json @@ -92,5 +92,7 @@ "webhook.var.comment.message": "Texte du commentaire (texte libre : utilise json)", "webhook.var.comment.userId": "Identifiant de l’auteur du commentaire", "webhook.var.comment.name": "Nom de l’auteur du commentaire", - "webhook.var.data.message": "Données brutes de l’événement (le data de l’enveloppe JSON)" + "webhook.var.data.message": "Données brutes de l’événement (le data de l’enveloppe JSON)", + "srv.season": "Saison {n}", + "srv.season.specials": "Épisodes spéciaux" } diff --git a/locales/fr/web.json b/locales/fr/web.json index cdf6a4a4..db439258 100644 --- a/locales/fr/web.json +++ b/locales/fr/web.json @@ -224,6 +224,7 @@ "error.tmdb_not_configured": "La clé TMDB n’est pas configurée. Un administrateur peut l’ajouter dans les Réglages.", "error.tmdb_failed": "TMDB n’a pas répondu. Réessaie dans un instant.", "error.already_available": "Ce titre est déjà disponible dans la bibliothèque.", + "error.season_numbering_changed": "Les saisons de cette série ont changé. Recharge la page et choisis-les à nouveau.", "error.duplicate_request": "Ce titre a déjà une demande en cours.", "error.bad_state": "La demande n’est pas dans un état qui le permet.", "error.no_instance": "Aucune instance Radarr ou Sonarr n’est configurée pour ce type de titre.", @@ -414,6 +415,9 @@ "seasons.season": "Saison {n}", "seasons.episodes": "{count} épisode | {count} épisodes", "seasons.loading": "Chargement des épisodes…", + "seasons.numbering_note": "Les saisons suivent la numérotation de Sonarr (TVDB), qui découpe cette série autrement que TMDB.", + "seasons.no_episodes": "La liste des épisodes apparaîtra quand Sonarr suivra cette série.", + "req.season_range": "Saisons {from} à {to}", "seasons.no_preview": "Pas d’aperçu", "req.which_seasons": "Quelles saisons ?", "req.select_none": "Tout désélectionner",