Sonarr v3 shim for Jellyseerr #84

Merged
naps62-yolo merged 2 commits from issue/43-sonarr-shim into main 2026-08-22 23:33:09 +01:00
Owner

Adds the Sonarr v3 series, lookup, root/profile, language-profile, and tag surface used by Jellyseerr.

Season requests populate TMDB seasons and episodes atomically, then set wanted only on episodes in selected seasons. Sonarr vocabulary remains isolated from arr-core.

Closes #43

Adds the Sonarr v3 series, lookup, root/profile, language-profile, and tag surface used by Jellyseerr. Season requests populate TMDB seasons and episodes atomically, then set `wanted` only on episodes in selected seasons. Sonarr vocabulary remains isolated from `arr-core`. Closes #43
naps62-yolo added 1 commit 2026-08-22 23:12:55 +01:00
feat(compat): add Sonarr series shim
ci / web (pull_request) Successful in 31s
e2e / e2e (pull_request) Successful in 1m11s
ci / rust (pull_request) Successful in 3m0s
a2901d480f
naps62-yolo reviewed 2026-08-22 23:19:30 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed a2901d4. Findings inline. One that has no single line to sit on: the duplicate check in series.rs:76 runs outside the transaction, so two concurrent adds of the same show hit UNIQUE(tmdb_id) and surface as a 500 instead of the validation error.

The season-request mapping itself matches §4.1 — episodes of unselected seasons are inserted with wanted = 0, auto_track stays false, and the test proves the wanted set.

Reviewed `a2901d4`. Findings inline. One that has no single line to sit on: the duplicate check in `series.rs:76` runs outside the transaction, so two concurrent adds of the same show hit `UNIQUE(tmdb_id)` and surface as a 500 instead of the validation error. The season-request mapping itself matches §4.1 — episodes of unselected seasons are inserted with `wanted = 0`, `auto_track` stays false, and the test proves the wanted set. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -108,10 +110,15 @@ fn endpoints() -> Router<CompatState> {
.route("/rootFolder", get(system::root_folders))
.route("/qualityprofile", get(system::quality_profiles))
.route("/qualityProfile", get(system::quality_profiles))
.route("/languageprofile", get(system::language_profiles))
Author
Owner

system/status answers appName: "Radarr" and version 5.14.0.9383 on the Sonarr side too. lib.rs:45 says Jellyseerr branches on the major version to pick a dialect, and 5.x reads as a v4/v5-era Sonarr — which contradicts this line adding the v3-only languageprofile endpoint.

`system/status` answers `appName: "Radarr"` and version 5.14.0.9383 on the Sonarr side too. `lib.rs:45` says Jellyseerr branches on the major version to pick a dialect, and 5.x reads as a v4/v5-era Sonarr — which contradicts this line adding the v3-only `languageprofile` endpoint. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -112,3 +116,4 @@
.route("/movie", get(movies::list).post(movies::add))
.route("/movie/lookup", get(movies::lookup))
.route("/movie/{movie_id}", get(movies::get))
.route("/series", get(series::list).post(series::add))
Author
Owner

No PUT on /series. A second season request for a show already in the library hits the duplicate check at series.rs:76 and gets a 400 — Sonarr's flow for that case is an update of the existing season list.

As written, only the first season request per show works. Implementing the update or stating the limitation in the PR body both settle it.

No PUT on `/series`. A second season request for a show already in the library hits the duplicate check at `series.rs:76` and gets a 400 — Sonarr's flow for that case is an update of the existing season list. As written, only the first season request per show works. Implementing the update or stating the limitation in the PR body both settle it. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -337,0 +370,4 @@
}
impl SeriesResource {
pub(crate) fn from_search(hit: &arr_meta::SeriesSearchResult) -> Self {
Author
Owner

blank() leaves seasons: [], so from_search results carry no season list — series/lookup?term=tvdb:N returns a series with no seasons while the tmdb: branch fills them from from_tmdb.

A client that builds its season selection from the lookup result then posts an empty seasons array: series created, nothing marked wanted, no error anywhere. The new test only asserts the season count on the tmdb: branch, so it passes either way. Fetch full detail for tvdb and title hits.

`blank()` leaves `seasons: []`, so `from_search` results carry no season list — `series/lookup?term=tvdb:N` returns a series with no seasons while the `tmdb:` branch fills them from `from_tmdb`. A client that builds its season selection from the lookup result then posts an empty `seasons` array: series created, nothing marked wanted, no error anywhere. The new test only asserts the season count on the `tmdb:` branch, so it passes either way. Fetch full detail for tvdb and title hits. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -337,0 +409,4 @@
title: row.title.clone(),
sort_title: row.title.to_lowercase(),
year: row.year.unwrap_or_default(),
tvdb_id: 0,
Author
Owner

tvdb_id: 0 on every library row, and migration 0005 has no column to store one. So GET /series and every already-added series report tvdbId: 0 — the key Sonarr consumers match a series on.

arr_meta::Series.tvdb_id is already fetched at add time; persisting it costs one column. Related: ListQuery filters on tmdbId, where Sonarr's list filter is tvdbId.

`tvdb_id: 0` on every library row, and migration 0005 has no column to store one. So `GET /series` and every already-added series report `tvdbId: 0` — the key Sonarr consumers match a series on. `arr_meta::Series.tvdb_id` is already fetched at add time; persisting it costs one column. Related: `ListQuery` filters on `tmdbId`, where Sonarr's list filter is `tvdbId`. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -337,0 +478,4 @@
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddSeries {
Author
Owner

AddSeries requires tmdbId and has no tvdbId. Sonarr v3's series resource carries no tmdbId at all — a Sonarr-shaped POST identifies the show by tvdbId, and that body is rejected by the Json extractor with a 422 before add ever runs.

#43's acceptance is a season request from the live Jellyseerr, so this decides whether the PR meets it. Either accept tvdbId and resolve it through find_series_by_tvdb, or paste the captured payload showing Jellyseerr echoes tmdbId back from the lookup result.

`AddSeries` requires `tmdbId` and has no `tvdbId`. Sonarr v3's series resource carries no `tmdbId` at all — a Sonarr-shaped POST identifies the show by `tvdbId`, and that body is rejected by the Json extractor with a 422 before `add` ever runs. #43's acceptance is a season request from the live Jellyseerr, so this decides whether the PR meets it. Either accept `tvdbId` and resolve it through `find_series_by_tvdb`, or paste the captured payload showing Jellyseerr echoes `tmdbId` back from the lookup result. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -337,0 +490,4 @@
pub quality_profile_id: Option<i64>,
#[serde(default)]
pub language_profile_id: Option<i64>,
#[serde(default)]
Author
Owner

monitored, seasonFolder, qualityProfileId, languageProfileId and addOptions are read and dropped with no comment. AddMovie right above documents each dropped field and why it is dropped; this struct should follow it.

`monitored`, `seasonFolder`, `qualityProfileId`, `languageProfileId` and `addOptions` are read and dropped with no comment. `AddMovie` right above documents each dropped field and why it is dropped; this struct should follow it. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -0,0 +97,4 @@
}
let mut tx = state.pool().begin().await?;
let series_id = sqlx::query(
Author
Owner

These three inserts use runtime sqlx::query/query_scalar while every SELECT in the file uses the checked macros. CLAUDE.md requires compile-time-checked queries.

series.rs:319 also parks use sqlx::Row as _; at the bottom of the file purely to support the resulting .get::<i64, _>(0). Both disappear with query!/query_scalar!.

These three inserts use runtime `sqlx::query`/`query_scalar` while every SELECT in the file uses the checked macros. CLAUDE.md requires compile-time-checked queries. `series.rs:319` also parks `use sqlx::Row as _;` at the bottom of the file purely to support the resulting `.get::<i64, _>(0)`. Both disappear with `query!`/`query_scalar!`. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -0,0 +240,4 @@
)
.fetch_all(state.pool())
.await?;
let all_seasons = sqlx::query!(
Author
Owner

load_series reads the whole seasons table on every call — including GET /series/{id}, which needs one series' seasons — then filters in Rust, so the cost is O(series × seasons) per request. get likewise loads every series' tags to take one entry. Both should be constrained by series id in SQL.

`load_series` reads the whole `seasons` table on every call — including `GET /series/{id}`, which needs one series' seasons — then filters in Rust, so the cost is O(series × seasons) per request. `get` likewise loads every series' tags to take one entry. Both should be constrained by series id in SQL. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
@@ -17,3 +16,1 @@
///
/// TV roots are excluded rather than merged: they belong to the `series`
/// half of the shim, and a Radarr offered a TV root would file films into it.
/// `GET /api/v3/rootfolder` — the real roots (§5.1).
Author
Owner

Dropping the kind == "movie" filter offers TV roots and TV profiles to the Radarr half, and movie roots to the Sonarr half. The comment being deleted here warned about exactly that.

Both shims share one /api/v3 mount, so nothing distinguishes the caller, and a wrong pick fails later as a 400 from resolve_root on every request. Jellyseerr appends /api/v3 to whatever base URL it is given, so mounting the Sonarr surface under its own prefix would let the per-kind filters come back.

Dropping the `kind == "movie"` filter offers TV roots and TV profiles to the Radarr half, and movie roots to the Sonarr half. The comment being deleted here warned about exactly that. Both shims share one `/api/v3` mount, so nothing distinguishes the caller, and a wrong pick fails later as a 400 from `resolve_root` on every request. Jellyseerr appends `/api/v3` to whatever base URL it is given, so mounting the Sonarr surface under its own prefix would let the per-kind filters come back. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
naps62-yolo added 1 commit 2026-08-22 23:25:34 +01:00
Merge remote-tracking branch 'origin/main' into issue/43-sonarr-shim
ci / web (pull_request) Successful in 33s
ci / rust (pull_request) Successful in 2m6s
e2e / e2e (pull_request) Successful in 57s
17262dd058
naps62-yolo reviewed 2026-08-22 23:26:15 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed 17262dd. No findings — the new head only merges origin/main; arr-compat and arr-meta are byte-identical to a2901d4, so the findings on that review still stand as posted.

Reviewed `17262dd`. No findings — the new head only merges `origin/main`; `arr-compat` and `arr-meta` are byte-identical to `a2901d4`, so the findings on that review still stand as posted. <!-- agent-meta: {"model":"claude-opus-5","session":"d87bcf98"} -->
naps62-yolo merged commit 38bd4102bb into main 2026-08-22 23:33:09 +01:00
naps62-yolo deleted branch issue/43-sonarr-shim 2026-08-22 23:33:10 +01:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yolo/arr#84