Skip to content

fix!: correct endpoints, auth and paths that the live APIs reject - #213

Open
marksie1988 wants to merge 9 commits into
mainfrom
fix/live-verified-endpoint-corrections
Open

fix!: correct endpoints, auth and paths that the live APIs reject#213
marksie1988 wants to merge 9 commits into
mainfrom
fix/live-verified-endpoint-corrections

Conversation

@marksie1988

@marksie1988 marksie1988 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What this fixes

Eight *arr applications were run in Docker, driven through their own web interfaces, and their real API traffic recorded. Every endpoint pyarr targets was then checked against the running server. This corrects the 25 implementations proven wrong, plus one defect class found while fixing them.

Nothing here is inferred from documentation or upstream specs. Every claim below was settled by a live observation; anything that could not be was left alone rather than guessed.

Why these were easy to miss

Three of the eight applications answer an unmatched /api/ path with their single page application's HTML at HTTP 200 rather than a 404. A wrong path therefore returned a web page that pyarr handed back as data, and nothing raised. bazarr.system.get_health() was returning {"message": "<!doctype html>..."}.

The fixes

Area Problem Evidence
common/base.py _get/_delete appended /{item_id} to paths already ending in /, so every Dispatcharr detail route was requested as channels/channels//1 GET .../channels//1 returned text/html at 200
common/config.py Config was attached as .config on four clients but held only a constructor, so all of config/* was unreachable. Prowlarr had none at all config/ui and config/host load on every page of every app
common/system.py prowlarr.system.get_diskspace() raised PyarrResourceNotFound on every call Path absent from Prowlarr's routing table; the same call on Sonarr returns 200
whisparr/ Client was built from the Radarr components, but Whisparr V2 forks Sonarr v3 /api/v3/movie, /movie/lookup, /movie/editor, /moviefile all return an unrouted 404
bazarr/ get_health, get_task, restart and shutdown all targeted Servarr paths Bazarr does not serve; subtitle actions targeted a nonexistent subtitles/{id}; wanted paged with parameters Bazarr silently drops Container route registrations read from the running image
dispatcharr/ JWTs rejected in X-Api-Key; whole proxy component mounted under /api where it is served from the root; four HDHR routes broken by a trailing slash; three malformed URLs 62 of 67 read methods returned {"detail":"Invalid API key"}

Shared components kept shared

The tiering follows what each application actually serves, taken from the live routing tables rather than assumed:

  • Config (host, ui, downloadclient) for all six Servarr clients, MediaConfig (indexer, mediamanagement, naming, naming/examples) for the five media clients. App-specific sections stay on the per-app subclasses.
  • System stays shared by all six; only diskspace moves down to MediaSystem.
  • common/wanted.py is untouched for Servarr; Bazarr gets its own paging.
  • Whisparr now reuses _async/sonarr/* rather than duplicating it.

Verification

All 62 changed or added methods were executed against live containers:

  • 31 read-only methods return real JSON, no HTML fallbacks.
  • 13 config setters round-tripped: read each section, PUT it back, read again, confirm unchanged. 17 section/app combinations, all HTTP 202.
  • 14 state-changing methods with no target on an empty instance all resolved to the real view (JSON 204/400/404), never the SPA.
  • BazarrSystem.request_restart() proven by effect: the container log shows the restart, and it returns cleanly rather than raising.

Coverage by client afterwards: Prowlarr 27/27 read methods, Dispatcharr 53/67 (up from 5/67; the remainder are genuine record-not-found on an empty database).

Versions tested: Sonarr 4.0.19, Radarr 6.3.0, Lidarr 3.1.0, Readarr 0.4.18, Prowlarr 2.5.2, Whisparr 2.2.0, Bazarr 1.6.0, Dispatcharr 0.29.0.

Test suite

532 passing, up from 446. The suite also had a pre-existing failure that is now fixed: test_request_restart genuinely restarted the live application, which then went down for several seconds and sometimes failed to rebind its port entirely, breaking everything after it. Radarr failed deterministically because it is the only directory with a test file after test_system.py; Sonarr and Readarr were doing the same damage unnoticed. Those tests are now marked destructive and deselected by default, runnable with pytest -m destructive, with the request shape covered by mocked tests instead.

Breaking changes

Every removal below is a method that could only ever fail against the real server.

  • whisparr.movie and whisparr.movie_file removed. Use series, episode, episode_file. release and manual_import now take seriesId/episodeId.
  • prowlarr.system.get_diskspace() removed.
  • bazarr.subtitles.download() / .delete() removed. Use bazarr.episodes.download_subtitle(), bazarr.movies.delete_subtitle(), and so on.
  • bazarr.system.get_diskspace(), get_routes(), get_routes_duplicate(), get_task() removed. get_task becomes get_tasks().
  • bazarr.wanted_episodes.get() / wanted_movies.get() take start/length instead of page/page_size/sort_key/sort_dir.
  • Dispatcharr defaults to auth_scheme="bearer". Pass auth_scheme="apikey" for a generated API key.
  • dispatcharr.m3u.refresh_account_info() takes profile_id first.
  • dispatcharr.channel_profiles.partial_update_channels() renamed to partial_update_channel() and takes a channel_id.

Follow-ups not in this PR

  • --reruns 3 --reruns-delay 5 in the nox session was masking the restart flake and no longer serves that purpose.
  • CI no longer exercises the live restart endpoint, since addopts applies to the nox run. A final pytest -m destructive step would restore it safely.
  • The audit also catalogued 1,086 missing method+path pairs across the eight apps. Not touched here; this PR only corrects what was wrong.

Summary by Sourcery

Align synchronous and asynchronous clients with the endpoints, authentication schemes, and resource models served by the supported applications.

New Features:

  • Add working configuration access across Servarr clients and Prowlarr.
  • Support Bazarr-specific system operations, wanted-item paging, and media-scoped subtitle actions.
  • Support bearer or API-key authentication and model Whisparr V2 using Sonarr-compatible resources.

Bug Fixes:

  • Correct live API endpoints, authentication headers, route prefixes, trailing slashes, request parameters, and detail URLs across Bazarr, Dispatcharr, Whisparr, and Servarr clients.
  • Prevent destructive restart tests from destabilizing the default test suite while preserving mocked request coverage.

Enhancements:

  • Refactor shared configuration and system APIs into application-appropriate tiers, including media-only diskspace support.
  • Remove client methods for endpoints that are not served by the target applications and rename affected Dispatcharr operations to match their actual resources.

Tests:

  • Expand mocked coverage for corrected authentication, paths, configuration, system actions, Bazarr behavior, Dispatcharr routes, and Whisparr resources.
  • Mark live restart tests as destructive and exclude them by default.

…paths

Appending "/{item_id}" to a path that already ended in a slash produced a double
slash, so every Dispatcharr detail route was requested as
"channels/channels//1". Dispatcharr answers any unmatched path with its single
page application's HTML at HTTP 200 rather than a 404, so these calls returned a
web page instead of data and never raised.

_detail_path now preserves whichever convention the collection path uses:
Servarr keeps "series/1", Dispatcharr's Django routes get
"channels/channels/1/".

Verified against a live Dispatcharr 0.29.0 container.
Config was attached as .config on the Sonarr, Radarr, Lidarr and Readarr
clients, but each class held nothing but a constructor, so the whole config/*
family was unreachable. Prowlarr had no config component at all. The web
interface loads config/ui and config/host on every single page, and every
settings page reads and writes this family.

The tiering follows what each application actually serves, taken from the live
routing tables:

  Config       host, ui, downloadclient          all six Servarr clients
  MediaConfig  indexer, mediamanagement, naming  the five media clients
               naming/examples

Sections only some applications serve stay on the per-application subclasses:
importlist for Sonarr and Radarr, metadata for Radarr, metadataProvider for
Lidarr and Readarr, development for Readarr and Prowlarr.

Every getter and setter was round-tripped against live containers, reading a
section, writing it back unchanged and confirming the value was untouched.
…lients

Two client construction concerns, both driven by what the live servers accept.

RequestHandler gained an auth_scheme argument. It still defaults to "apikey",
sending X-Api-Key as before, but "bearer" now sends Authorization: Bearer.
Dispatcharr issues JWT access tokens and rejects them in X-Api-Key with
{"detail":"Invalid API key"}, which left 62 of its 67 read methods unusable.

System is split so each client only advertises what it serves. Prowlarr manages
no media and has no diskspace route, so prowlarr.system.get_diskspace() raised
PyarrResourceNotFound on every call. Everything else on System is served by all
six Servarr applications and stays shared. diskspace moves to MediaSystem,
attached by MediaArrClient.

BREAKING CHANGE: Prowlarr.system.get_diskspace() is removed. Prowlarr never
served /api/v1/diskspace, so the call could only ever fail. The other System
methods are unchanged for every client.
The client was assembled from the Radarr components, but Whisparr V2 ("Eros")
forks Sonarr v3, not Radarr. Its library primitives are series, shown as Sites
in the web interface, and episode, shown as Scenes. There is no movie concept
anywhere in the application: /api/v3/movie, /movie/lookup, /movie/editor and
/moviefile all return an unrouted 404, confirmed against Whisparr 2.2.0.108 and
absent from its routing table in every verb.

That left the entire media management half of the client aimed at endpoints the
application does not serve, and whisparr.config was an empty class.

Series, Episode, EpisodeFile, Release and ManualImport already existed under
_async/sonarr and match this build's paths and parameter names, so the fix is
largely a swap. Every path those components use was checked against Whisparr's
live routing table first, languageprofile included.

BREAKING CHANGE: whisparr.movie and whisparr.movie_file are removed, as neither
resource exists on Whisparr. Use whisparr.series, whisparr.episode and
whisparr.episode_file instead. whisparr.release and whisparr.manual_import now
take seriesId and episodeId rather than movieId, which the server ignored.
Bazarr is a Flask application, not a Servarr one, and answers any unmatched
/api/ path with its single page application's HTML at HTTP 200 rather than a
404. Several inherited methods were therefore returning a web page wrapped in
{"message": "<!doctype html>..."} without ever raising. The same trap was
already noted in a comment about subtitles/wanted.

  system.get_health()      GET health          -> now system/health
  system.get_task()         GET system/task     -> now system/tasks
  system.request_restart()  POST system/restart -> now POST system?action=
  system.request_shutdown() POST system/shutdown

Restart and shutdown also close the connection instead of answering, so a
dropped connection is expected and is swallowed. A ConnectError still raises,
so an unreachable instance is not hidden.

Subtitles are downloaded and deleted through the media they belong to, so those
actions move to the episodes and movies components, matching both the server's
URL layout and the existing component structure.

Wanted paged with the Servarr page and pageSize parameters. Bazarr declares only
start, length and the id list, and Flask-RestX silently drops anything else, so
paging had no effect at all.

BREAKING CHANGE: bazarr.subtitles.download() and bazarr.subtitles.delete() are
removed; there is no subtitles/{id} route. Use
bazarr.episodes.download_subtitle(), bazarr.episodes.delete_subtitle(),
bazarr.movies.download_subtitle() and bazarr.movies.delete_subtitle().
bazarr.system.get_diskspace(), get_routes(), get_routes_duplicate() and
get_task() are removed; Bazarr serves none of them, and get_task is replaced by
get_tasks(). bazarr.wanted_episodes.get() and wanted_movies.get() now take
start and length rather than page, page_size, sort_key and sort_dir.
…ng urls

Every fix here is a path or header the running server rejected. Dispatcharr
answers unmatched paths with its single page application's HTML at HTTP 200, so
none of these failures raised.

Auth: the client now defaults to bearer, because Dispatcharr issues JWT access
tokens from accounts/token/ and rejects them in X-Api-Key. 62 of 67 read methods
returned {"detail":"Invalid API key"}; 53 now succeed and the remainder are
genuine record-not-found responses against an empty instance.

Proxy: Dispatcharr serves these views from the server root, not under /api.
GET /api/proxy/ts/status returned HTML, GET /proxy/ts/status returns JSON. The
component now escapes the api prefix with ../ the way live.py already did, and
matches the schema's trailing slashes exactly, which differ per route.

HDHR: device.xml, discover.json, lineup.json and lineup_status.json are not
router routes and reject a trailing slash. hdhr/devices/ is one and keeps it.

  m3u.refresh_account_info        the profile id belongs in the path
  channel_profiles               the channel id segment was missing
  plugins.delete                 needs the /delete/ action route

BREAKING CHANGE: dispatcharr defaults to auth_scheme="bearer". Pass
auth_scheme="apikey" when authenticating with a generated API key rather than a
JWT. m3u.refresh_account_info() now takes profile_id as its first argument.
channel_profiles.partial_update_channels() is renamed to
partial_update_channel() and takes a channel_id, since the route acts on one
channel rather than all of them.
test_request_restart genuinely restarts the live application, which then goes
down for several seconds and sometimes fails to rebind its port entirely,
dying with "Failed to bind to address: address already in use". Every test
running afterwards against that application then fails.

Radarr failed deterministically because it is the only directory with a test
file after test_system.py alphabetically. Sonarr and Readarr were doing the same
damage with nothing behind them to notice. The --reruns 3 --reruns-delay 5 in
the nox session masked it whenever the application happened to recover inside
five seconds.

These are now marked destructive and deselected by default. Run them
deliberately with `pytest -m destructive`, where nothing follows them.

Restarting the application under test cannot be made safe inside a shared
fixture run: ordering it last still leaves the application down, and the rebind
failure is a race no delay reliably covers. The request shape is covered instead
by mocked tests that assert both calls POST to their own endpoints.
@sourcery-ai

sourcery-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

Aligns pyarr’s async/sync clients and shared components with what the live Servarr, Bazarr, Whisparr, and Dispatcharr APIs actually serve: fixes path construction and auth handling, introduces tiered Config/System abstractions, adds Bazarr- and Dispatcharr-specific components, switches Whisparr over to Sonarr-based primitives, and extends the test suite (including a destructive pytest marker) to verify all corrected endpoints against real containers.

Sequence diagram for Dispatcharr authentication and corrected routing

sequenceDiagram
    participant Client
    participant RequestHandler
    participant Dispatcharr
    Client->>RequestHandler: request(endpoint)
    RequestHandler->>Dispatcharr: Authorization: Bearer api_key
    Dispatcharr-->>RequestHandler: JSON response
    RequestHandler-->>Client: parsed response
    Client->>RequestHandler: get_ts_status(channel_id)
    RequestHandler->>Dispatcharr: GET ../proxy/ts/status/channel_id
    Dispatcharr-->>RequestHandler: proxy status JSON
    RequestHandler-->>Client: parsed response
Loading

Sequence diagram for Bazarr system actions

sequenceDiagram
    participant Client
    participant BazarrSystem
    participant Bazarr
    Client->>BazarrSystem: get_health()
    BazarrSystem->>Bazarr: GET system/health
    Bazarr-->>BazarrSystem: health JSON
    BazarrSystem-->>Client: health JSON
    Client->>BazarrSystem: request_restart()
    BazarrSystem->>Bazarr: POST system action=restart
    Bazarr-->>BazarrSystem: connection dropped while restarting
    BazarrSystem-->>Client: return cleanly
Loading

File-Level Changes

Change Details Files
Fix shared request helpers to build correct detail URLs across both Servarr and Dispatcharr API families.
  • Introduce CommonActions._detail_path to preserve or add trailing slashes correctly when appending item IDs.
  • Update _get and _delete in CommonActions (async/sync) to use _detail_path instead of manual string concatenation.
  • Add tests to assert correct path behavior for Servarr-style and Dispatcharr-style routes and to guard against accidental double slashes.
src/pyarr/_async/common/base.py
src/pyarr/_sync/common/base.py
tests/common/test_base_paths.py
Add tiered shared Config and System components and wire them into clients so configuration and diskspace endpoints match each app’s real surface.
  • Implement shared Config and MediaConfig classes (async/sync) for common Servarr config sections and media-only sections.
  • Refactor Sonarr/Radarr/Lidarr/Readarr config modules to subclass MediaConfig and expose only the app-specific extra sections.
  • Introduce MediaSystem subclass that owns get_diskspace, and change BaseArrClient/MediaArrClient to attach System/MediaSystem appropriately.
  • Add Prowlarr Config implementation that omits media sections but exposes development options, and ensure Prowlarr’s System lacks diskspace.
  • Add tests for Config/MediaConfig behavior, app-specific sections, and System/MediaSystem tiering across clients.
src/pyarr/_async/common/config.py
src/pyarr/_sync/common/config.py
src/pyarr/_async/common/system.py
src/pyarr/_sync/common/system.py
src/pyarr/_async/client.py
src/pyarr/_sync/client.py
src/pyarr/_async/lidarr/config.py
src/pyarr/_async/radarr/config.py
src/pyarr/_async/readarr/config.py
src/pyarr/_async/sonarr/config.py
src/pyarr/_async/prowlarr/__init__.py
src/pyarr/_async/prowlarr/config.py
src/pyarr/_sync/lidarr/config.py
src/pyarr/_sync/radarr/config.py
src/pyarr/_sync/readarr/config.py
src/pyarr/_sync/sonarr/config.py
src/pyarr/_sync/prowlarr/__init__.py
src/pyarr/_sync/prowlarr/config.py
tests/common/test_config.py
tests/common/test_system_tiers.py
Align Bazarr integration with its actual Flask API: custom system component, wanted paging, and media-scoped subtitle actions; remove invalid system/subtitle methods.
  • Replace Bazarr’s inherited Servarr System with BazarrSystem (async/sync) that targets /system/status, /system/health, /system/tasks and POST /system?action=..., with special handling for dropped connections on restart/shutdown.
  • Move wanted paging for Bazarr into BazarrWanted (async/sync) using start/length instead of Servarr’s page/pageSize, and wire Bazarr client to use BazarrWanted instead of common Wanted.
  • Relocate subtitle download/delete to Episodes/Movies for Bazarr (async/sync) using episodes/movies/subtitles endpoints and remove nonexistent subtitles/{id} routes from Subtitles.
  • Add tests for Bazarr system paths, restart/shutdown connection behavior, wanted paging semantics, subtitle routing, and client wiring (including absence of unsupported system methods).
src/pyarr/_async/bazarr/__init__.py
src/pyarr/_async/bazarr/system.py
src/pyarr/_async/bazarr/wanted.py
src/pyarr/_async/bazarr/episodes.py
src/pyarr/_async/bazarr/movies.py
src/pyarr/_async/bazarr/subtitles.py
src/pyarr/_sync/bazarr/__init__.py
src/pyarr/_sync/bazarr/system.py
src/pyarr/_sync/bazarr/wanted.py
src/pyarr/_sync/bazarr/episodes.py
src/pyarr/_sync/bazarr/movies.py
src/pyarr/_sync/bazarr/subtitles.py
tests/bazarr/test_system.py
tests/bazarr/test_system_actions.py
tests/bazarr/test_wanted.py
tests/bazarr/test_subtitles.py
Correct Dispatcharr authentication and routing so JWTs and proxy/HDHR/plugin/M3U endpoints hit the real Django views.
  • Extend RequestHandler (async/sync) to support auth_scheme='apikey' vs 'bearer' and set the appropriate header; validate scheme and add tests for header behavior.
  • Change Dispatcharr client constructors (async/sync) to default auth_scheme='bearer' for JWT tokens, with the option to force 'apikey' for generated keys.
  • Fix HDHR extension routes to drop trailing slashes, while keeping trailing slash on router-based devices collection/detail.
  • Update Proxy paths to escape the /api prefix with '../' and adjust trailing slashes to match root-served routes.
  • Fix M3u.refresh_account_info to take profile_id in the path instead of the body, and update ChannelProfiles.partial_update* to act on a single channel’s path rather than the collection.
  • Change Plugins.delete to use the dedicated delete action route instead of the generic _delete helper that produced double slashes.
  • Add tests covering Dispatcharr auth defaults, HDHR URL shapes, proxy paths, M3U account refresh, channel profile partial update, and plugin deletion.
src/pyarr/_async/utils/http.py
src/pyarr/_sync/utils/http.py
src/pyarr/_async/dispatcharr/__init__.py
src/pyarr/_async/dispatcharr/hdhr.py
src/pyarr/_async/dispatcharr/proxy.py
src/pyarr/_async/dispatcharr/m3u.py
src/pyarr/_async/dispatcharr/channel_profiles.py
src/pyarr/_async/dispatcharr/plugins.py
src/pyarr/_sync/dispatcharr/__init__.py
src/pyarr/_sync/dispatcharr/hdhr.py
src/pyarr/_sync/dispatcharr/proxy.py
src/pyarr/_sync/dispatcharr/m3u.py
src/pyarr/_sync/dispatcharr/channel_profiles.py
src/pyarr/_sync/dispatcharr/plugins.py
tests/common/test_auth_scheme.py
tests/dispatcharr/test_url_fixes.py
Switch Whisparr over to Sonarr-based components and shared MediaConfig, removing invalid movie/moviefile endpoints and enabling real config access.
  • Change Whisparr client (async/sync) to use MediaConfig instead of an empty Radarr Config stub, enabling shared host/ui/indexer/mediaManagement/naming config methods.
  • Replace Radarr movie/movie_file components with Sonarr series/episode/episode_file/manual_import/release components, matching Whisparr V2’s Sonarr v3 fork.
  • Remove Whisparr’s movie and movie_file surfaces and add tests ensuring only series/episode primitives are present and that the reused Sonarr components really target the series endpoint.
src/pyarr/_async/whisparr/__init__.py
src/pyarr/_sync/whisparr/__init__.py
tests/whisparr/test_client.py
Stabilize and extend the test suite, introducing a destructive marker for restart tests and adding request-shape coverage for Servarr system actions.
  • Add pytest marker configuration in pyproject.toml, defining destructive and defaulting addopts to -m not destructive so restart/shutdown tests are skipped unless explicitly requested.
  • Mark Sonarr/Radarr/Readarr live restart tests as @pytest.mark.destructive to avoid flaking CI by temporarily stopping the apps.
  • Add mocked tests for Servarr System/MediaSystem restart/shutdown request shapes that mirror the behavior of the live destructive tests.
  • Increase overall test coverage by adding focused tests for Bazarr, Dispatcharr, shared Config/System tiers, auth schemes, and path construction helpers.
pyproject.toml
tests/sonarr/test_system.py
tests/radarr/test_system.py
tests/readarr/test_system.py
tests/common/test_system_actions.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/pyarr/_async/common/base.py" line_range="34" />
<code_context>
+        Returns:
+            str: The endpoint to request.
+        """
+        if not item_id:
+            return path
+        if path.endswith("/"):
+            return f"{path}{item_id}/"
+        return f"{path}/{item_id}"
+
</code_context>
<issue_to_address>
**issue (bug_risk):** `_detail_path` treats an item ID of `0` as absent and returns the collection path. `_delete` therefore sends `DELETE` to the collection instead of the requested detail resource when `item_id=0`, whereas the old `_delete` always appended the ID.

**Triggers:** When a valid resource has ID 0.

**Suggested fix:** Check `item_id is None` rather than its truthiness, while preserving the trailing-slash handling.

```suggestion
        if item_id is None:
```
</issue_to_address>

### Comment 2
<location path="src/pyarr/_async/whisparr/__init__.py" line_range="57-61" />
<code_context>
             headers=headers,
         )
-        self.config = Config(self.http_utils)
-        self.movie = Movie(self.http_utils)
-        self.movie_file = MovieFile(self.http_utils)
+        self.config = MediaConfig(self.http_utils)
+        self.series = Series(self.http_utils)
+        self.episode = Episode(self.http_utils)
+        self.episode_file = EpisodeFile(self.http_utils)
</code_context>
<issue_to_address>
**issue (broader_impact):** The Whisparr client removes `movie` and `movie_file`, but the existing `tests/integration/test_whisparr.py` still calls `whisparr_client.movie.get()`. Running that integration test now raises `AttributeError` before making a request.

**Triggers:** When the existing Whisparr integration tests are included in the test run.

**Suggested fix:** Update the existing Whisparr integration test to exercise `series`/`episode` resources, or explicitly migrate/remove the obsolete test as part of the breaking change.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and a wrong default authentication scheme can deny every Dispatcharr request or send credentials through the wrong trust boundary, and the new action and deletion endpoints can restart or shut down an instance and remove subtitle or plugin files when called. Reverting restores the prior client behavior, but it cannot undo an outage or deletion that already occurred, and the authentication default is itself a policy decision affecting all users immediately.

Blocking findings: src/pyarr/_async/common/base.py:34, src/pyarr/_async/whisparr/__init__.py:61


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/pyarr/_async/common/base.py Outdated
Comment thread src/pyarr/_async/whisparr/__init__.py
@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.92683% with 111 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.40%. Comparing base (d958d3b) to head (11d719b).
⚠️ Report is 21 commits behind head on main.

Files with missing lines Patch % Lines
src/pyarr/_sync/common/config.py 41.30% 27 Missing ⚠️
src/pyarr/_sync/bazarr/system.py 56.00% 11 Missing ⚠️
src/pyarr/_sync/bazarr/wanted.py 38.88% 11 Missing ⚠️
src/pyarr/_sync/dispatcharr/proxy.py 11.11% 8 Missing ⚠️
src/pyarr/_async/dispatcharr/proxy.py 44.44% 5 Missing ⚠️
src/pyarr/_async/common/config.py 91.30% 4 Missing ⚠️
src/pyarr/_sync/bazarr/episodes.py 33.33% 4 Missing ⚠️
src/pyarr/_sync/bazarr/movies.py 33.33% 4 Missing ⚠️
src/pyarr/_sync/dispatcharr/hdhr.py 0.00% 4 Missing ⚠️
src/pyarr/_sync/radarr/config.py 63.63% 4 Missing ⚠️
... and 17 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #213      +/-   ##
==========================================
+ Coverage   47.56%   56.40%   +8.83%     
==========================================
  Files         164      182      +18     
  Lines        4457     5062     +605     
==========================================
+ Hits         2120     2855     +735     
+ Misses       2337     2207     -130     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

_detail_path tested the item id for truthiness, so an id of 0 was read as "no
id" and returned the collection path. The previous _delete always appended the
id, so this was a regression: _delete(path, 0) sent DELETE to the collection
instead of to item 0, which is a very different request.

Only None means "no item", which matches every caller's signature. Reported by
Sourcery on #213.
The integration test still called whisparr_client.movie.get(), which no longer
exists now the client is built from the Sonarr components. It kept passing
because the body was wrapped in a bare `except Exception: pass`, so the
AttributeError was swallowed along with everything else. That is exactly how the
call survived the change unnoticed, so the replacement asserts directly.

Reported by Sourcery on #213.
@marksie1988

Copy link
Copy Markdown
Collaborator Author

Both findings were valid and are fixed in 6dac575 and 11d719b.

1. _detail_path and item id 0 - correct, and worse than stated: this was a regression. The previous _delete always appended the id, so _delete(path, 0) built path/0, whereas the truthiness check turned it into a DELETE against the collection. _get had the same truthiness behaviour before this PR, so that half was pre-existing rather than new, but is None is the right predicate for both since every caller signature types item_id as ... | None = None. Added a regression test asserting _delete("indexer", item_id=0) targets indexer/0.

2. Obsolete Whisparr integration test - correct that it needed migrating, though the stated failure mode does not occur: the body is wrapped in except Exception: pass, so the AttributeError was swallowed and the test passed vacuously. That is precisely how the call to the removed movie resource survived unnoticed, so the replacement asserts directly instead of swallowing. It now exercises series and pins that movie/movie_file are absent.

Worth flagging that I had been running the suite with --ignore=tests/integration throughout, which is why this one escaped me. Running the full suite now: 640 passed, with one pre-existing failure unrelated to this PR - test_coverage_improvement.py::test_sonarr_release_extended fails identically on main, where a TVDB lookup returns nothing on an empty instance and lookup[0] raises IndexError.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant