Added
- Usenet download clients: SABnzbd and NZBGet (#20). Two new download pages modelled on the qBittorrent one, one per client. Each has three view modes (list / table / compact, persisted in
localStorage), column and menu sorting, a status-filter dropdown plus search, multi-select with a bulk action bar (pause / resume / delete), a per-item detail modal, and an Add modal that takes drag-dropped.nzbfiles, several URLs at once and a category picker (categories read live from the client). Rows carry a per-state icon (downloading / queued / paused / repairing / extracting / moving / done / failed), URL fetches show a "wait Xs" badge while the server holds the slot, and each action raises its own toast instead of a generic confirmation. NZBGet failure statuses (FAILURE/PAR, …) are translated to a readable message. - Dedicated paginated Usenet history page -
/usenet/{client}/history, reached from a toolbar button, paginates server-side (SABnzbdstart/limit, NZBGet sliced) so a long history never bloats the live queue page. The main page no longer fetches history at all, saving one upstream call per poll. - SABnzbd and NZBGet in the setup wizard - the
/setup/downloadsstep now configures both Usenet clients (URL, credentials, Test connection, recap) alongside qBittorrent. - Usenet health pill and sidebar download badge - each client gets the same health dot as the rest of the services (key-aware, behind the per-service circuit breaker) and a background-hydrating queue-count badge in the sidebar.
- Server-side pagination, filtering and sorting on the films / series libraries (#19). The browser no longer receives the whole library at once: the page renders a single page (50 / 100 / 200 / 500 items, set in
/admin/settings → Display) with status, quality/network, genre, language, sort and search all applied server-side. Facets are computed over the full library,?open={id}deep-links resolve to the right page, and v1.0?filter=bookmarks still work. Libraries of 10,000+ items stay responsive. - Multi-instance Radarr and Sonarr (#21). Configure as many instances per service as you need (1080p / 4K / Anime…); legacy single-config is migrated to a default instance at first boot. Each instance has its own URL, API key, name, slug, position, enabled flag.
- Instance manager in
/admin/settings- add / rename / reorder / enable-disable / set default / delete + per-row Test connection. CSRF per action, ROLE_ADMIN. - Dynamic sidebar - flat link (1 instance) → pill group (2–3) → dropdown (4+). Active instance highlighted across navigation.
- Slug-aware routing - every Radarr/Sonarr admin and media route is now
/medias/{slug}/.... AMultiInstanceBinderSubscriberbinds the right client per request; unknown slug → clean 404. - Per-instance health circuit breaker -
ServiceHealthCachekeyed by(service, slug), one outage doesn't silence its siblings. - Cross-instance aggregation everywhere (Phase D) - dashboard widgets, calendar (UI + iCal), Ctrl+K search, qBit badge resolver and TMDb library lookup merge every enabled instance and dedup by
tmdbId/tvdbId. iCalUIDs rooted ontmdbId/tvdbIdfor cross-instance stability. - Quick-Add target picker (Phase E) -
/decouverte/resolveexposesinstances(current owners) +candidates(every enabled instance, withis_default). The modal lets the user pick where to add when 2+ Radarr/Sonarr exist. - Settings export v2 - JSON dump now includes the
instances[]topology (no API keys); restore preserves the original slug ordering. v1 backups still accepted. - Expandable shelves on Radarr/Sonarr shelf views (PR #29).
- Per-service enable/disable toggle (#15) - a switch in
/admin/settingsfor Prowlarr, Jellyseerr, qBittorrent and TMDb. Disabled: the service drops out of the sidebar, its pages bounce home with a "{service} is disabled" notice, HealthService stops pinging it, the dashboard/topbar treat it as not configured, and the clients themselves refuse to talk to it (so dashboard widgets that call them directly stop fetching from a "disabled" service). URL and API key stay in the DB, re-enabling is one click. Disabling a Radarr/Sonarr instance gives the same notice, named after the instance, instead of a bare 404. PRISMARR_FRAME_ANCESTORSenv var (#25) - set it to a space-separated origin list to embed Prismarr in an iframe (Organizr, Heimdall, …). Unset keeps the default lockdown (frame-ancestors 'self'+X-Frame-Options: SAMEORIGIN).- Pending-requests badge on the Jellyseerr sidebar entry - a count bubble, like the qBittorrent downloads badge, shows how many requests are awaiting action. It hydrates in the background (60s poll), hides itself when nothing is pending, and backs off instead of flashing zero when Jellyseerr is unreachable.
- Support links in
/admin/settings- a discreet "Support" link at the bottom of the settings sidebar and a "Support & Community" card in the About tab, pointing to the project's Buy Me a Coffee page and Discord server. The two URLs are centralised as Twig globals so they live in a single place.
Changed
- Dashboard "Services health" widget stays live and breaks down by instance. The card re-fetches its fragment on a 30s interval instead of freezing at first paint (paused while the tab is hidden), and Radarr/Sonarr now render one chip per enabled instance, named after the instance (Radarr 1080p, Radarr 4K) like the topbar dropdown, instead of a single aggregated dot.
- Settings services page restyled. Each integration shows its real brand logo (from the Apache-2.0 dashboard-icons set) on a light chip with the brand colour kept as a ring, instead of a generic coloured cog. The service cards also lay out in a responsive two-column grid so the list stays compact as clients are added; the multi-instance Radarr/Sonarr cards span the full width since their table needs the room.
- UI emojis replaced with Tabler icons (#18). Status emojis now rely on the toast/banner type icon; the remaining semantic emojis became inline Tabler SVG through a shared Twig macro, so icons follow the surrounding text colour and the theme picked in
/admin/settingsand render the same on every platform. - Action buttons on the About and Updates settings sections carry Tabler icons - the GitHub, Docker Hub, roadmap, bug-report and docs links are now icon-prefixed (same macro as #18) so the page reads at a glance.
- Films / series toolbar redesign + loading feedback - the filter, sort and view-mode controls were reorganised into a two-card toolbar for the server-side pagination, and the grid dims briefly while a filter or page change reloads so the action feels responsive.
- Dashboard loads instantly, widgets hydrate in the background (#27, #30) - the page renders as a shell with skeletons and each widget (hero, upcoming, requests, health, recommendations, recent additions) hydrates from its own fragment endpoint after first paint, so the dashboard is interactive in ~25ms instead of blocking 7-12s on upstream calls. A short shared cache (45s) on the heavy Radarr/Sonarr aggregates dedupes the parallel fragment calls and brings warm loads to ~70ms; empty results aren't cached, so a transient upstream failure retries on the next paint.
- Slim stats strip on the films / series pages (#7) - the four tall stat cards became a single one-line strip (numbers plus inline labels) to reclaim vertical space, and the films Downloads queue card is now collapsible to match the series page.
- Compact interface density tightens the detail modals (#6) - when Interface density is set to Compact, the film and series detail modals scroll internally to fit the viewport, with a shorter fanart header, smaller title and denser cards; Comfortable density is unchanged. Reuses the existing
display_ui_densitypreference instead of adding a dedicated toggle. - Languages card redesigned for multi-instance - per-service blocks, per-instance UI + info-language selectors; partial failures reported by instance name.
- Sonarr manual import is reliable - uses
GET /api/v3/manualimport?downloadId=<hash>instead of forging the payload, dedups queue items sharing a downloadId, reports grouped reject reasons. - Interactive release search is more patient and tells you why it's empty - the upstream search ran out of time at 45-60s while setups with several slow indexers routinely take 70-90s (Sonarr/Radarr themselves wait that long); bumped to 90s, with
set_time_limit()raised to match on the three search routes. And a search that times out now returns 504 so the UI shows "the indexers took too long" instead of a misleading "no releases found".RadarrClient::getReleasesForMoviealso gained theCONNECTTIMEOUT/NOSIGNALthe other clients have. - Calendar uses Sonarr local
airDate(#26) - episodes stay on the right day regardless of viewer TZ. Same fix onseries_missing/series_cutoff. TorrentResolverServicematchesoriginalTitle+ everyalternateTitles[].title- French installs (Aventures croisées ↔ Swapped) resolve correctly. Accent folding moved tointl Transliteratorto dodge an Alpine/musl iconv bug.- Topbar health badge surfaces every instance - one row per enabled instance instead of one aggregate per service.
- Queue card on the series page is collapsible - mirrors the existing calendar card.
ServiceInstanceProvider::getDefault()only returns an enabled instance - disabling the default Radarr/Sonarr instance no longer leaves it as the fallback target the autowired clients bind to; the first enabled instance takes over (or the service reads as unconfigured if none are).AppVersionreports the build's own version - readsPRISMARR_VERSION(stamped by the release/beta workflows), falls back to the1.1.0-devconstant for local builds.:latest,:betaandmake deveach show the right string;version_compareranks1.1.0-beta.Nbelow1.1.0so beta testers get nudged to the stable.
Fixed
- 10 sub-pages of
/admin/settings/{radarr,sonarr}/...had buttons silently doing nothing in multi-instance - hardcoded legacy URLs migrated to slug-aware routes, all sub-pages also moved to Turbo-safe JS. - Sonarr indexer / notification test+delete buttons - 4
fetch()URLs leaked the Twig~operator into a JS string literal, killing the IIFE on script load. - Films "Rename file" preview - hit the legacy non-slug route and never received a payload.
- TMDb "My recommendations" biased to the default instance - seeds now iterate every enabled Radarr/Sonarr, dedup by
tmdbId. /admin/settingsAbout widget counts - aggregate films / series across instances, dedup bytmdbId/tvdbId. Falls back to-only if every instance fails.- Dashboard "Services" card aggregates Radarr/Sonarr health across instances - was reflecting the default only, divergent from the topbar dropdown.
- Dashboard mini-calendar dedups episodes on
tvdbId-seriesIdis per-instance, so two Sonarr instances tracking the same show duplicated rows. - Quick-Add modal slug context - Ctrl+K from a Sonarr page no longer 404s when adding a film.
- Home redirect 500 -
redirectToRoute('app_media_films')was called without{slug}; helper now hydrates from the default instance. - Sonarr / Radarr
request()blew up on bare-string responses -"OK"from notification test/delete is now coerced to[]. window.prismarrBytesrace on films page - hoisted to the<head>script sosetInterval(refreshQueue)doesn't fire before the definition.- Queue count badge illegible (grey on indigo) - forced
text-white. TorrentResolverServiceURLs lacked the slug prefix - qBit Radarr/Sonarr badges always 404'd in v1.1.0.- Settings import v2 lost the original sidebar ordering -
positionis now restored from the export. - iCal dedup key parenthesised explicitly - visual ambiguity around
??vs.precedence (the previous form was correct but error-prone). - Dead
CURRENT_RADARR_SLUG/CURRENT_SONARR_SLUGJS globals removed - Quick-Add was reverted toDEFAULT_*_SLUGsemantics and nothing else read them. - qBittorrent 5.2.0 reported as unreachable (#28) - the runtime client demanded HTTP
200exactly; qBit 5.2.0 answers204 No Contenton some Web API endpoints. Now accepts the whole 2xx range, matching the connection-test path. HomeControllerredirect-loop when the chosen home page is a disabled service - the fallback chain and thedisplay_home_page = 'discovery' / 'qbittorrent' / 'last'paths now askHealthService::isConfigured()instead ofConfigService::has(), so a disabled service is treated as not-configured and the next viable target is picked. Without this guard,ServiceRouteGuardSubscriberwould redirect the disabled service back toapp_homeand the browser would bounce until the redirect cap kicked in.pollCmdno longer claims "no release found" when Sonarr/Radarr's completion message lacks the report-count phrase - the regex(\d+) reports downloadedonly matched the plural form (so1 report downloadedwas read as zero) and didn't match newer Sonarr v4 messages that just say "Completed". Now permissive (singular/plural,downloaded/grabbed) and falls back to the neutral "complete" banner when no count is found - instead of the misleading "no result found" warning.- Pre-1.1.0 media URLs 404'd after upgrade -
/medias/films,/medias/series,/medias/radarr/...and/medias/sonarr/...(and the AJAX routes a cached v1.0.x page keeps polling) now 307-redirect to the default instance's slug-aware path. Method preserved, so cached POST handlers keep working. Bookmarks survivedocker compose pull. - Multi-instance Radarr/Sonarr table overflowed on narrow viewports - the six-column instance table had no scroll container and spilled out of its card on phone widths; it now scrolls horizontally inside the card (same pattern as the languages table).
Security
- Sanitised upstream bodies before logging -
RadarrClient::request()/SonarrClient::request()redactapikey=,"apiKey":, magnet links, then truncate to 200 chars. - Sanitised JSON 500 on the films bulk endpoints -
filmsBulkRefresh,filmsBulkSearchandfilmQueueImportno longer leak$e->getMessage(). showPageBannerXSS hardening - text-safeshowPageBanner(viatextContent) for the 46 sites with upstream-controlled strings; explicitshowPageBannerHtmlfor the 11 sites that intentionally render markup.window.escHtml()helper + 7 innerHTML splice sites - escape& < > " 'in the Quick-Add picker rows + candidates<select>, the topbar health dropdown, the Ctrl+K search result row, the recent-search row, the Quick-Add profile/folder<option>lists, the TMDb discovery cards, and the calendar tooltip.ServiceInstanceProvider::create/updatereject bad URL schemes -file:///javascript:/gopher://blocked at write time viaHealthService::urlBlockedReason. Defense in depth, the cURL layer is already pinned to HTTP(S).AdminInstancesController::testInstanceasserts type ∈ServiceInstance::TYPES- guard against a future probeFor() that lazily accepts more types.HealthService::urlBlockedReasonreportsmalformedfor a URLparse_url()can't parse (e.g. a port outside 0-65535), so editing an instance with a bad port shows "Invalid instance URL (malformed)" instead of a misleading "(scheme)".PRISMARR_FRAME_ANCESTORSalso strips;and,alongside control chars:;would closeframe-ancestorsand smuggle a fresh CSP directive,,splits the header into multiple intersected policies (a footgun that silently breaks the app even though it can't weaken the policy). Origins are space-separated, neither character is ever legitimate.- Global qBittorrent poll script gated on
service_configured('qbittorrent')- when qBit is disabled the poll endpoint redirects to home, the JS reads HTML,r.json()throws, the circuit breaker backs off to two minutes. Skipping the script entirely when the service is off keeps the page quiet. - Usenet Add-URL is pinned through
HealthService::urlBlockedReason(#20) - a submitted fetch URL is rejected if it isn't HTTP(S) or resolves to the link-local cloud-metadata range, so the feature can't be turned into an SSRF probe. The guard itself was hardened to also catch IPv4-mapped IPv6 literals (::ffff:169.254.169.254) and trailing-dot hosts that slipped past the first check yet still resolved to the metadata IP at request time. - The Usenet wizard no longer wipes a stored secret on re-submit -
/setupprefills password and API-key fields blank (browsers striptype=passwordvalues), so re-submitting a step used to overwrite the saved secret with an empty string.save()now skips an empty sensitive field instead of nulling it, and the managers step falls back to the stored key. Same guard applied to the Radarr/Sonarr API keys. - Usenet poll-summary endpoint returns a fixed
unreachablemarker instead of the raw upstream exception message, so a misconfigured client can't echo connection internals back into the page.
Tests
- 32 new unit tests on the v1.1.0 plumbing -
ServiceInstanceProvider(22),MultiInstanceBinderSubscriber(7),ServiceHealthCacheinstance-keyed entries (3). Plus ~17 onTorrentResolverService+SonarrClient::manualImportFromQueueItems. - 4 new
TmdbControllerTestcases pinning Phase D+E -/decouverte/resolveexposesinstances+candidates, series match bytvdbId, recommendations dedup across instances. Smoke tests seed defaultradarr-1/sonarr-1instances. - 5 new dataProvider cases on
ServiceInstanceProvider::createpinning the URL-scheme rejection. - 3 new
AppVersionTestcases -PRISMARR_VERSIONoverrides the constant,dev/empty falls back, a beta build is ranked below the matching stable. - 16 new cases on the #28 / #15 / #25 work -
QBittorrentClient2xx acceptance (9),HealthServiceper-service kill switch (3),AdminSettingsControllerpersisting the<service>_enabledform flag (1),CspHeaderSubscriberframe-ancestors widening + header-injection guard (3). - 3 new
LegacyMediaRedirectTestcases - index + sub-page redirects land on the default instance, real slug routes aren't intercepted. - 2 new
urlBlockedReasoncases (malformed-URL reason) + a blocked-URL provider entry for an out-of-range port. - 4 more #15 cases -
ConfigExtensionhides a disabled service from the sidebar (2),ServiceRouteGuardSubscriberbounces a disabled service (1) and a disabled instance (1) home. - 2
getDefault()cases - skips a disabled flagged instance, returns null when every instance is disabled. - 4
MediaReleasesSearchTestcases - episode/season/film release search returns 504 when the upstream call doesn't complete, a plain JSON array when it does. - 5
FlatServiceClientDisabledTestcases - Prowlarr/Jellyseerr/qBittorrent/TMDb clients throwServiceNotConfiguredExceptionfromensureConfigwhen their kill switch is on, fall through to the credential check when the flag is absent. - 2
CspHeaderSubscribercases -PRISMARR_FRAME_ANCESTORSstrips;and,on top of the existing CR/LF strip. - 2 new
HomeControllerTestcases pinning the redirect-loop fix -discoveryandlastpreferences fall through cleanly when the target service is disabled. - Usenet (#20) -
NzbgetClientbyte-pair recombination (signed 32-bit low half) and percentage clamp,SabnzbdClientwait-label parsing (only "sec" labels yield a wait),SetupControllerempty-secret preservation + managers key fallback,UsenetControllerAdd-URL SSRF rejection, andHealthService::urlBlockedReasonlink-local evasions (IPv4-mapped IPv6, trailing dot) with loopback still allowed. - Suite is 534 tests / 1150 assertions, up from 273 / 565 at the end of v1.0.6.
Migrations
migrations/Version20260503000000.php(Big Bang) - createsservice_instance, seeds the legacyradarr_url/radarr_api_key/sonarr_url/sonarr_api_keysettings into a default instance per service (slug = radarr-1/sonarr-1), then drops the four settings rows. Reversible.
Internal
docker-compose.example.ymlsurfaces theTZ,PHP_MEMORY_LIMITandPHP_MAX_EXECUTION_TIMEknobs already supported by the image so users don't need to dig through docs to discover them.- New
beta.ymlworkflow - manual dispatch builds the currentmainasshoshuo/prismarr:beta(multi-arch). Pushes only the:betatag, never:latest, never a GitHub release. README has a "Testing pre-release builds" note for opt-in testers. release.ymlignores pre-release tags (v*-*) and gates the:latestDocker tag behind a no-hyphen check - av1.1.0-beta.1git tag can no longer trigger a release or clobber:latest.- Unraid Community Applications template (
unraid/prismarr.xml) - one-click install template: image, WebUI on 7070, the data volume pinned to/var/www/html/var/data, and the optionalTZ/PHP_MEMORY_LIMIT/PHP_MAX_EXECUTION_TIME/TRUSTED_PROXIESenv vars. .github/FUNDING.ymlenables the GitHub Sponsor button (Buy Me a Coffee). The README gained Discord and Buy Me a Coffee badges plus a "Support and community" section.