First public release. Prismarr is a single-container, self-hosted dashboard
that brings qBittorrent, Radarr, Sonarr, Prowlarr, Jellyseerr and TMDb
together behind one Symfony 8 / FrankenPHP UI. Everything below was built
between April 18 and April 26, 2026, on top of the IH-Argos fork.
Added
- Animated README showcase - looped GIF carousel on top of the README cycling
through seven UI screenshots (Dashboard, Discovery, Calendar, Movies, Series
detail, Downloads, Settings) at 3 s per slide, with the same screenshots also
available as static images inside a collapsible<details>block. - Status badges in the README - latest release, CI status, Docker Hub pulls,
image size, GitHub stars and last-commit date, alongside the existing stack
badges (license, PHP, Symfony, FrankenPHP, SQLite). - New README sections for the v1.0 public release: "Project status" (solo-dev
disclaimer plus an explicit call for feature requests, bug reports, code
reviews, UI critiques, design ideas and translations), "Why Prismarr?" (a
short comparison against Organizr, Heimdall, Homer, Homepage, Homarr,
Jellyseerr and the raw Servarr UIs), "FAQ" (six entries: PHP / Symfony, ARM
/ Raspberry Pi, internet requirements, reverse proxy, API-key storage,
backups, third-party translations), and "Star history". - "Note on AI usage" disclosure at the bottom of the README - rendered as a
blockquote (greyed-out) so it stays discreet, listing primary uses (i18n
translation, log / JS debugging, API endpoint cataloguing, code audits, SVG
icons & illustrations) and secondary uses (PHPUnit test debugging, mobile
responsive design, security review, doc translation, local commit messages,
single-container Docker design) of Claude Code as a support tool, with a
reminder that every line was reviewed and signed off by a human and that
make checkhad to be green before any commit. - Categorised connection test in
/admin/settings— the "Test connection"
button returns a structured diagnosis (ok / unconfigured / network / auth / forbidden / not_found / server_error / unknown) with the HTTP status
code included. The result is shown with an i18n message matching the category,
so admins know whether the problem is a wrong URL, a bad API key, or a
firewall blocking the request. - Live form override for connection test — the test button sends the
current form values (URL, API key / password) as overrides instead of always
reading from the database. The admin can type new credentials and test them
before saving. A server-side allowlist validates which keys may be overridden
per service. - Unified sidebar-visibility section in
/admin/settings → Display—
service toggles and internal-feature toggles (Calendar, Dashboard) are
grouped under a single "Sidebar visibility" sub-section with an auto-fill
two-column grid, freeing the service cards to focus solely on connection
status. - Profile page at
/profil— edit display name and password, upload an
avatar (JPG / PNG / WebP / GIF, 2 MB max). Avatars live in the
var/data/avatars/volume so they survive container recreations. The
page also shows a small personal stats block (watchlist count, member
since, role) and the four most recent watchlist additions. - Services health badge in the topbar — a coloured dot (green = all
up, orange = partial, red = none) with a dropdown listing the live
state of the six services. Refreshes every 60 s. Backed by a new
GET /api/health/servicesendpoint (ROLE_USER — the service list
leaks part of the configuration, so it is no longer public). - Calendar week and day views — toggle between Month, Week and Day
at the top of/calendrier. State is persisted inlocalStorageand
also reflected in the URL (?view=…&date=YYYY-MM-DD) so widgets on
the dashboard can deep-link into a specific day. Past days are
dimmed; events on past days are greyscaled and struck through. - iCal export at
/calendrier.ics— downloads an RFC 5545
calendar with stable UIDs (movie and TV episode releases, each typed
cinema / digital / physical / series). Existing calendar clients
update events in place rather than duplicating them. - Backup and import in
/admin/settings— export non-sensitive
settings as JSON, reimport them with a CSRF-protected form (version
check, 64 KB max, scalar-only values). Keys matchingapi_key,
passwordorsecretare never exported and are always filtered
out on import, even if a malicious file tries to smuggle them in. - About section in
/admin/settings— runtime information
(Prismarr, Symfony, PHP, SAPI, environment, database path and size,
server timezone), three counters (users / movies / series — tolerant
of Radarr / Sonarr being offline), and links to the project sources
and issue tracker. - Reset display preferences button in
/admin/settings— clears
everydisplay_*key so reading them falls back to the defaults. - Twig filters
|prismarr_date,|prismarr_timeand
|prismarr_datetime— apply the admin's chosen timezone, date
format (FR / US / ISO) and time format (24 h / 12 h) to any
DateTimeInterface, ISO 8601 string or timestamp. - Global search improvements — ARIA combobox, arrow-key
navigation with visible highlight, an inline clear button, a
recent-searches list stored inlocalStorage(shown when focusing
an empty input), and Everything / Movies / Series filter pills.
Results are now grouped (online discovery first, local library
second). - Main dashboard at
/tableau-de-bord— the new default landing page
for logged-in users. Aggregates seven widgets with graceful degradation
when a service is offline: hero spotlight (random library pick with
fanart, genres, rating, quality and a CTA), upcoming releases
(seven-day mini-calendar), pending Jellyseerr requests enriched with
TMDb metadata, live health of the six services, personal watchlist,
weekly TMDb trending, and most-recent library additions merged
across movies and series. - Display preferences in
/admin/settings— nine typed options
(home page, toasts, timezone, date/time format, theme colour,
default Radarr/Sonarr view, qBit auto-refresh, UI density) stored
asdisplay_*keys. The admin page now uses tab navigation
(Services / Display) with URL-hash +sessionStoragepersistence so
the admin stays on the same section across POST/Redirect/GET.
Effective behaviour wiring for these preferences lands in a follow-up. - Collapsible sidebar with a toggle button at the bottom: 4 rem
icons-only width when collapsed, CSS-only tooltips on hover, state
persisted inlocalStoragewith FOUC-prevention in<head>. - Admin settings page at
/admin/settings— edit service URLs and API keys
without replaying the setup wizard. Per-service "test connection" button,
live status pill, show/hide toggle for each service in the sidebar, and
show/hide toggle for internal features (Calendar). Two-column layout with
sticky section nav, designed to host future preference sections. - Branded error pages for 403/404/500/503 rendered with the Prismarr
chrome (sidebar, theme) instead of the default Symfony exception page.
Upstream exception message is never exposed — only the status code, a
friendly French title, and a CTA back to home. - Password show/hide toggle in the setup wizard (admin step + qBittorrent
download step) for users typing long API keys on small screens. /api/healthnow returns{status, db, timestamp}(ISO 8601) so
external monitoring dashboards can track liveness over time.- OCI image labels on the production Docker image (title, description,
licenses, source, url, documentation, vendor) — surfaced on Docker Hub
and viadocker inspect. - Smoke test coverage on every controller (
ControllersSmokeTestwith
DataProvider over 9 media routes + login + health). - Initial Prismarr application forked from IH-Argos (April 2026).
- FrankenPHP 1.3.6 single-container deployment with s6-overlay supervising
the web server and the Symfony Messenger worker. - Zero-config SQLite database, automatic secret generation on first boot.
- 7-step setup wizard: welcome → admin → TMDb → managers (Radarr + Sonarr) →
indexers (Prowlarr + Jellyseerr) → downloads (qBittorrent + Gluetun) → finish. - Media integrations:
- Radarr (~169 client methods, 143 routes, 37 templates)
- Sonarr (~160 client methods, 142 routes, 30 templates)
- Prowlarr (~70 methods, 15 templates)
- Jellyseerr (~60 methods, 13 templates)
- qBittorrent (~45 methods, VPN card, session card, magnet + torrent file upload)
- TMDb discovery page (hero, recommendations, 7 scrollable sections, watchlist)
- Integrated calendar with month grid, tooltips, per-type colours
- Hotkey global search (Ctrl+K) with debounced local + online (TMDb / TheTVDB) results.
- Quick-add modal (movies via Radarr, series via Sonarr) accessible from every page.
- Dynamic CSP header built from configured service URLs.
- Login rate-limiter (5 attempts per IP + username / 15 minutes, 25 per IP globally).
- Trusted proxies support for deployments behind Traefik / nginx / Caddy / Cloudflare Tunnel.
/api/healthendpoint (JSON status + DB ping) for Docker healthcheck.- Profiler access guard that returns 403 for non-RFC1918 clients when
APP_ENV=dev. - Admin recovery command:
php bin/console app:user:reset-password <email>. - Dynamic welcome homepage: auto-redirect to the first configured service.
- Doctrine migrations baseline (replaces
doctrine:schema:create). - PHPUnit test suite (~100 tests, services + subscribers + controllers + entities + Twig extensions).
make checktarget: PHP lint + Twig lint + full PHPUnit suite.
Security
-
Admin credentials no longer wiped on partial saves — browsers (Firefox,
Chrome) strip thevalueattribute ofinput[type=password]fields on
page render. Previously, any admin save action (e.g. changing the theme
colour) would silently overwrite every API key and password in the database
with an empty string, eventually causing qBittorrent to ban the Prismarr IP
after repeated empty-password login attempts.saveSubmitted()now skips
any field whose trimmed value is empty and whose name matches the sensitive
key pattern (password,api_key,secret). A regression test
(testEmptyPasswordFieldsAreNotWiped) is added. -
always_use_default_target_path: trueon the main firewall — Symfony
no longer redirects to whatever URL was in the session at login time
(typically an expired AJAX endpoint such as/api/health/services).
Users always land on the home route and honour theirdisplay_home_page
preference. -
^/api/health/servicesis gated behindROLE_USER(the exact
^/api/health$Docker healthcheck remains public). Previously the
whole^/api/healthprefix was public, which meant an unauthenticated
client could enumerate which external services an instance had
configured. -
CSRF tokens are now required on every new admin action
(/admin/settings/import,/admin/settings/reset-display) and every
profile mutation (/profilsave,/profil/avatarupload and delete). -
Avatar uploads validate MIME type against an allow-list, cap size at
2 MB, and the serving route uses a strict filename regex
(\d+\.(jpg|png|webp|gif)) to prevent path traversal. -
Settings export and import strip any key containing
api_key,
passwordorsecret, so a shared config file cannot accidentally
leak credentials and a hostile import file cannot inject them either. -
Container runs as non-root (
www-datavias6-setuidgid); s6-overlay keeps
PID 1 as root only as required. -
SSRF protection on user-provided URLs: protocol whitelist, cloud-metadata
blocklist,CURLOPT_REDIR_PROTOCOLS. -
XSS dead-code removal (
extra_fields|rawremoved from schema modal). -
CSRF tokens on every sensitive form.
-
#[IsGranted('ROLE_ADMIN')]on the six controllers that manage external
services (Radarr, Sonarr, Prowlarr, Jellyseerr, qBittorrent, Media). -
Login throttling via
symfony/rate-limiter. -
Dev-only
_profiler/_wdtroutes return 403 for remote clients. -
Strict-Transport-SecurityandPermissions-Policyresponse headers
emitted by Caddy (HSTS no-op on plain HTTP but picked up by an HTTPS
reverse proxy that forwards response headers). -
Session cookie marked
httponlyexplicitly (in addition to
secure: auto+samesite: lax). -
QBittorrentClientnow implementsResetInterface, preventing the
qBittorrent session cookie from leaking across users when the
FrankenPHP worker is re-used.
Changed
-
README is now in English and is the sole published version of the
project README. The temporary French copy used during the v1.0 review pass
has been removed; English is the source of truth for all public-facing
documentation. Twig<title>separators were also migrated from em dash
(—) to ASCII dash (-) for cleaner browser tab titles
(base.html.twig,security/login.html.twig, allsetup/*.html.twig). -
All user-visible strings in Twig templates (~50 hard-coded strings) and PHP
controllers are now routed through the Symfony Translator. The EN and FR
YAML files are in exact parity (4 188 keys each, zero duplicates, zero
broken placeholders). ICU plural forms are used where count varies
(media.import.blocked_warning). Flash messages, JSON API responses, and
theUniqueEntityconstraint onUser::emailare fully translated. -
Internal service messages (RadarrClient, SonarrClient, TorrentResolverService)
are hardcoded in English — they surface only in server logs, never in the UI. -
English is now the default application locale (
default_locale: en).
French remains the first and complete translation. New installs default
todisplay_language: enanddisplay_metadata_language: en-US. Users
who prefer French can switch via/admin/settings → Languages. -
The Discovery search block stays visible when a query returns zero
results — it now shows a "no results" message instead of disappearing,
so users know the search completed rather than silently failing. -
/admin/settings → Displayno longer shows the language dropdowns
(display_language,display_metadata_language) since they are already
editable in the dedicated Languages section. The defaults are preserved
internally so the Languages section can pre-select the current values. -
Series library now has a "Recently added" sort option, mirroring the one
already present on the movies page. Sort is client-side using
data-added(ISO 8601 froms.addedAt) so it works without an extra
API call. -
Display preferences are now effective — theme colour drives a dynamic
--tblr-primary/--tblr-primary-rgbCSS variable (declared after
the Tabler stylesheet so Tabler's default:rootno longer wins the
cascade), UI density togglesbody.density-compact/
body.density-comfortable, toasts togglesbody.toasts-off, qBit
auto-refresh reads its interval from the preference (setting it to
0 disables polling entirely), and thelasthome option reads a
rotatingprismarr_last_routeHttpOnly cookie to resume where the
user left off. -
display_default_view(default Radarr / Sonarr view) has been
dropped from the preferences — wiring it to the client-side view
switcher was too invasive for v1.0 and the feature is deferred to a
later release. The key is no longer written; any stale value already
stored in a user's DB is simply ignored. -
27 media templates had their browser tab title cleaned up: the
trailing— IH-ARGOSis gone, the tab now just readsPrismarr. -
Sidebar wording:
Films→Radarr,Séries→Sonarr(matches the
underlying service and improves the collapsed sidebar tooltips).
Calendar moved up in the sidebar order (right after Discovery). -
The topbar has been rebuilt into a three-column layout (title /
large centred search / actions). The user dropdown now links to the
new profile page and, for admins, to the settings page. -
Flash messages no longer auto-hide, so a long save confirmation or
error is not missed when it happens during a Turbo navigation. -
Trending / spotlight / Jellyseerr links on the dashboard now open
the in-page discovery modal (/decouverte?detail=type/id) instead
of hitting the JSON resolver endpoint. -
Home route (
/) now resolves to the admin'sdisplay_home_page
preference (dashboard by default), instead of always falling through
to the first configured service. The legacy fallback chain
(tmdb → radarr → sonarr → qbit → welcome) still kicks in when the
preferred target isn't configured. -
Gluetun HTTP client timeout raised from 4 s to 8 s (connect 2 s → 3 s) —
the previous values were too aggressive on slow VPN handshakes. -
Migrated from a multi-container stack (PHP-FPM + nginx + Redis) to a single
FrankenPHP container with filesystem cache and sessions. -
Retired
api-platform/coreandlexik/jwt-authentication-bundle— unused. -
Multi-stage-like Dockerfile:
.build-depspurged after PHP extensions compile.
gitandzipalso moved into.build-depsand purged aftercomposer install. -
Composer version pinned (
composer:2→composer:2.8) to avoid drift
across rebuilds. -
Final image trimmed from 577 MB to 282 MB, then another ~10 MB after
purging the Composer build-time deps. -
Settings live in the
settingDB table, not in.env.local— managed by
the wizard, persistent across container recreations. -
Home page chooses the first configured service (TMDb → Radarr → Sonarr → qBit
→ welcome fallback) instead of hardcoding/decouverte. -
Sidebar "Paramètres" link moved to the footer area next to logout (admin-only).
-
"Modifier la configuration" banner button points to
/admin/settingsnow
rather than replaying the setup wizard. -
Session files moved from
var/sessions/tovar/data/sessions/so they
persist inside the one Docker volume mounted in production and survive
docker compose up -d --force-recreate. -
Gluetun HTTP timeout bumped from 4 s to 8 s (connect 2 s → 3 s) — the
previous values were too aggressive on slow VPN handshakes.
Fixed
- TMDb client timeouts raised from 4 s connect / 10 s total to 8 s / 15 s,
withCURLOPT_NOSIGNAL=1added. The 4 s budget could not absorb the
occasional Docker embedded-DNS latency spike (127.0.0.11) plus the
IPv6-then-IPv4 connect fallback inside the container, leading to
spurious "Resolving timed out" errors on TMDb calls even with a healthy
internet connection. Same pattern as the GluetunClient bump. - The Jellyseerr language dropdown in
/admin/settings → Languagesnow
reads and writes the global app locale (GET/POST /api/v1/settings/main)
instead of the per-user admin setting (/api/v1/user/1/settings/main).
The dropdown was showing "English" while Jellyseerr's own Settings →
General → Display Language correctly showed "Français". On save,
Prismarr now pushes a minimal{locale}payload to the global endpoint
(a full payload triggers HTTP 400 becauseapiKeyis read-only there)
and also updates user 1's per-user setting (which drives the language
of TMDb metadata returned by Jellyseerr API calls made via the admin
API key). - Dashboard mini-calendar no longer drops the upcoming events of the
last displayed days. The earlier 8-item cap was applied globally and
silently truncated the week; events are now limited per day with a
clickable "+N more" link that deep-links into the calendar day view.
Today's morning episodes also stop being misclassified as "past". - Dashboard and calendar hovers no longer get stuck in a highlighted
state after a tap on touch devices — hover rules are now wrapped in
@media (hover: hover) and (pointer: fine). - The trending / recent tiles on the dashboard now open the discovery
modal correctly (the previous link pointed at the JSON resolver
endpoint, which did nothing visible for the user). - qBittorrent client cURL calls now set
CURLOPT_NOSIGNAL=1and an
explicit 3 s connect timeout on the four entry points
(login / getRaw / request / post). WithoutNOSIGNAL, libcurl falls
back toSIGALRMfor DNS resolution — a signal PHP masks — leaving
DNS lookups stuck for 30+ seconds whenever qBittorrent is unreachable
and producing aFatalErroron Alpine PHP. Calls are now capped at
~11 s total regardless of the backing service's state. - The browser-side qBittorrent summary poll now uses an exponential
backoff (15 s → 30 s → 60 s → 120 s cap) on failure, resetting to the
base interval on success. Previously a 1 s retry loop hammered the
endpoint whenever the NAS or qBit was down. - Dashboard "Upcoming releases" widget now shows only each movie's next
future release date (rather than surfacing items whose digital or
physical release was weeks in the past). - Pending Jellyseerr requests on the dashboard now display the real
title/year by enriching each request with a cached TMDb lookup,
instead of showing raw "TMDb #<id>" placeholders. - Media clients (Radarr, Sonarr, Prowlarr, Jellyseerr, qBittorrent, TMDb,
Gluetun) implementResetInterfaceso FrankenPHP worker instances
reload the API key/URL between requests. Previously, an admin updating
a service via/admin/settingshad to wait for the worker to recycle
(10–30 min) before the new value was picked up. AdminSettings::save()also clearscache.appso stale TMDb responses
aren't served after a key change.SetupController::guardAdminExists()now returns?RedirectResponse
and every call site uses the return value — previously the redirect
was issued but the method kept running, potentially double-rendering
the wizard step.GluetunClient::reset()now also zeroes the three cache timestamps
(publicIpCacheAt,statusCacheAt,portCacheAt); previously reset
would keep stale entries alive for the rest of the TTL.
Contributor
- GitHub Actions CI workflow (
.github/workflows/ci.yml) running
make check(PHP syntax lint + Twig lint + full PHPUnit suite) on every
pull request and on every push tomain. The job builds the Prismarr
container with the dev compose overlay, installs Composer dev dependencies
inside it, waits for/api/healthto be ready, then runs the same
make checkcontract that contributors run locally. - GitHub Actions release workflow (
.github/workflows/release.yml):
triggered by pushing av*.*.*tag, sets up QEMU + Buildx, builds a
multi-architecture image (linux/amd64+linux/arm64), pushes it to
Docker Hub undershoshuo/prismarr(or a configurable image name) with
semver tags:X.Y.Z,:X.Y,:Xand:latest, and creates a GitHub
release whose body is auto-extracted from the matchingCHANGELOG.md
section. - Public docs polished for the v1.0 release: every em dash (
—)
replaced by an ASCII dash (-) acrossCONTRIBUTING.md,SECURITY.md,
.github/PULL_REQUEST_TEMPLATE.mdand.github/ISSUE_TEMPLATE/*.md;
CONTRIBUTING.mdupdated to reflect the EN-first i18n reality (UI strings
go throughmessages+intl-icu.{en,fr}.yaml, English is the source of
truth) and the live CI workflow (no longer "starting in v1.1"). Commit
messages are now allowed in either English or French. - PHPUnit 13 deprecations and notices eliminated: one
with()call without a
matchingexpects()rule was converted toexpects($this->once()), and 17
TestCase classes that use mocks purely for stub return values are now
annotated with#[AllowMockObjectsWithoutExpectations]. The test run output
is now a cleanOK (179 tests, 376 assertions)with no extra lines. CONTRIBUTING.mdadds a six-category "Definition of Done" checklist and
five non-negotiable golden rules.make checkmust be green before every commit.- New
tests/AbstractWebTestCasebase class boots a real kernel with an
isolated SQLite file, seeds an admin + thesetup_completedflag, and
logs in the admin — foundation for functional tests that need a live
request/response cycle. make testnow passes-e APP_ENV=testtodocker exec; previously
the container'sAPP_ENV=devwas overriding theAPP_ENVdirective
declared inphpunit.dist.xml.