Sonarr v3 shim for Jellyseerr #84
Reference in New Issue
Block a user
Delete Branch "issue/43-sonarr-shim"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
wantedonly on episodes in selected seasons. Sonarr vocabulary remains isolated fromarr-core.Closes #43
Reviewed
a2901d4. Findings inline. One that has no single line to sit on: the duplicate check inseries.rs:76runs outside the transaction, so two concurrent adds of the same show hitUNIQUE(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_trackstays false, and the test proves the wanted set.@@ -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))system/statusanswersappName: "Radarr"and version 5.14.0.9383 on the Sonarr side too.lib.rs:45says 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-onlylanguageprofileendpoint.@@ -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))No PUT on
/series. A second season request for a show already in the library hits the duplicate check atseries.rs:76and 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.
@@ -337,0 +370,4 @@}impl SeriesResource {pub(crate) fn from_search(hit: &arr_meta::SeriesSearchResult) -> Self {blank()leavesseasons: [], sofrom_searchresults carry no season list —series/lookup?term=tvdb:Nreturns a series with no seasons while thetmdb:branch fills them fromfrom_tmdb.A client that builds its season selection from the lookup result then posts an empty
seasonsarray: series created, nothing marked wanted, no error anywhere. The new test only asserts the season count on thetmdb:branch, so it passes either way. Fetch full detail for tvdb and title hits.@@ -337,0 +409,4 @@title: row.title.clone(),sort_title: row.title.to_lowercase(),year: row.year.unwrap_or_default(),tvdb_id: 0,tvdb_id: 0on every library row, and migration 0005 has no column to store one. SoGET /seriesand every already-added series reporttvdbId: 0— the key Sonarr consumers match a series on.arr_meta::Series.tvdb_idis already fetched at add time; persisting it costs one column. Related:ListQueryfilters ontmdbId, where Sonarr's list filter istvdbId.@@ -337,0 +478,4 @@#[derive(Debug, Clone, Deserialize)]#[serde(rename_all = "camelCase")]pub struct AddSeries {AddSeriesrequirestmdbIdand has notvdbId. Sonarr v3's series resource carries notmdbIdat all — a Sonarr-shaped POST identifies the show bytvdbId, and that body is rejected by the Json extractor with a 422 beforeaddever runs.#43's acceptance is a season request from the live Jellyseerr, so this decides whether the PR meets it. Either accept
tvdbIdand resolve it throughfind_series_by_tvdb, or paste the captured payload showing Jellyseerr echoestmdbIdback from the lookup result.@@ -337,0 +490,4 @@pub quality_profile_id: Option<i64>,#[serde(default)]pub language_profile_id: Option<i64>,#[serde(default)]monitored,seasonFolder,qualityProfileId,languageProfileIdandaddOptionsare read and dropped with no comment.AddMovieright above documents each dropped field and why it is dropped; this struct should follow it.@@ -0,0 +97,4 @@}let mut tx = state.pool().begin().await?;let series_id = sqlx::query(These three inserts use runtime
sqlx::query/query_scalarwhile every SELECT in the file uses the checked macros. CLAUDE.md requires compile-time-checked queries.series.rs:319also parksuse sqlx::Row as _;at the bottom of the file purely to support the resulting.get::<i64, _>(0). Both disappear withquery!/query_scalar!.@@ -0,0 +240,4 @@).fetch_all(state.pool()).await?;let all_seasons = sqlx::query!(load_seriesreads the wholeseasonstable on every call — includingGET /series/{id}, which needs one series' seasons — then filters in Rust, so the cost is O(series × seasons) per request.getlikewise loads every series' tags to take one entry. Both should be constrained by series id in SQL.@@ -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).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/v3mount, so nothing distinguishes the caller, and a wrong pick fails later as a 400 fromresolve_rooton every request. Jellyseerr appends/api/v3to whatever base URL it is given, so mounting the Sonarr surface under its own prefix would let the per-kind filters come back.Reviewed
17262dd. No findings — the new head only mergesorigin/main;arr-compatandarr-metaare byte-identical toa2901d4, so the findings on that review still stand as posted.