- Self-contained YML: all CSS inlined (no global.css edit needed) - Vanilla JS client with MutationObserver bootstrap - Slide+fade month navigation via rAF (works in this Dynacat build) - Popover portaled to <body> to escape backdrop-filter containing blocks - Per-service enabled flag, unconfigured services silently skipped - Status <ul> exposes per-service configured/ok flags for client hints
arr-releases
A Dynacat (Panonim/dynacat) calendar widget that aggregates upcoming releases from Sonarr, Radarr and Lidarr.
- Always renders exactly 6 weeks (42 cells) with prev/next month spillover
- One coloured dot per service on each day that has a release
- Click any day with a dot to open a popover with full release details and cover art
- Smooth slide+fade animation on month navigation
- Works with any subset of the three services configured — set
SONARR_KEYonly and the widget will silently skip Radarr and Lidarr
(add a screenshot if you have
one — see How to add a screenshot)
Quick install (TL;DR)
# 1. Copy the widget definition
cp arr-calendar.yml /appdata/dynacat/config/
# 2. Copy the client JS
cp arr-calendar.js /appdata/dynacat/assets/
# 3. Add this line to /appdata/dynacat/config/dynacat.yml under document.head:
# <script src="/assets/arr-calendar.js" defer></script>
# 4. Restart the Dynacat container (the Go template cache is in-memory; YML
# edits do NOT recompile until the container restarts)
docker restart dynacat
Then $include: arr-calendar.yml in any page column and set at least one
of SONARR_KEY, RADARR_KEY, LIDARR_KEY as a container env var.
What the widget does
Each cell of the calendar corresponds to one day. On a day that has at least one release, the widget shows a small coloured dot:
- 🔵 Blue dot — Sonarr episode
- 🟢 Green dot — Radarr movie
- 🟣 Purple dot — Lidarr album
Multiple services on the same day produce a row of dots. Click the day to
open a popover listing every release on that date, with cover art, the
release type (Release / Digital / In Cinemas / Physical for Radarr;
S##E## — episode title for Sonarr; artist name for Lidarr) and a link
back to the *arr web UI.
Full install
1. Environment variables
The widget reads three API keys from the container environment. Set at least one — all three are optional:
| Env var | Purpose | Required? |
|---|---|---|
SONARR_KEY |
Sonarr API key | optional |
RADARR_KEY |
Radarr API key | optional |
LIDARR_KEY |
Lidarr API key | optional |
BASE_HOST |
The shared part of the *arr URLs (e.g. t3du.com). The widget builds Sonarr/Radarr/Lidarr URLs as https://<service>.${BASE_HOST}. |
required |
Get an API key from each *arr under Settings → General → API Key.
If you set the env var on the Dynacat container itself, no per-service
configuration is needed — defaults to
https://sonarr.${BASE_HOST}, https://radarr.${BASE_HOST},
https://lidarr.${BASE_HOST}. To use a different URL for a service, set
the corresponding *-url option in your $include of arr-calendar.yml
(see Customising URLs below).
2. File layout
/appdata/dynacat/
config/
dynacat.yml # main config (edit to add the script tag)
arr-calendar.yml # <-- this repo, copy here
assets/
arr-calendar.js # <-- this repo, copy here
global.css # (untouched; widget CSS is inlined in the YML)
3. Edit dynacat.yml
Add the script tag under document.head:
server:
assets-path: /app/assets
document:
head: |
<script src="/assets/arr-calendar.js" defer></script>
Then $include the widget in a page column (any column size — it adapts):
pages:
- name: Home
columns:
- size: small
widgets:
- $include: arr-calendar.yml
4. Restart the container
YML edits do not recompile the Go template on the fly. Glance/Dynacat
parses each custom-api template once on first render and caches the
compiled *template.Template in memory. File mtime updates, touch and
edits to the file do not invalidate the cache. After every edit to
arr-calendar.yml, restart the container:
docker restart dynacat
(or via your container manager — Dockhand, Unraid UI, etc.). The error
message you'll get if you forget is the dreaded
Config has errors: custom-api widget: parsing template: ... — see
Troubleshooting below.
5. Hard-refresh the browser
Ctrl+Shift+R (or Cmd+Shift+R on macOS) to bypass the browser cache.
Customising
All options live in the top of arr-calendar.yml under options::
- type: custom-api
options:
sonarr-url: https://sonarr.example.com
sonarr-key: ${SONARR_KEY}
radarr-url: https://radarr.example.com
radarr-key: ${RADARR_KEY}
lidarr-url: https://lidarr.example.com
lidarr-key: ${LIDARR_KEY}
exclude-physical: true
past-days: 90
future-days: 90
| Option | Default | Notes |
|---|---|---|
sonarr-url |
https://sonarr.${BASE_HOST} |
Override if your Sonarr is on a different host |
sonarr-key |
${SONARR_KEY} |
Set the env var instead of hard-coding |
radarr-url |
https://radarr.${BASE_HOST} |
|
radarr-key |
${RADARR_KEY} |
|
lidarr-url |
https://lidarr.${BASE_HOST} |
|
lidarr-key |
${LIDARR_KEY} |
|
exclude-physical |
true |
When true, Radarr movies whose display date is a physical release are hidden. Set to false to show them. |
past-days |
90 |
How many days into the past to fetch (for the "this month" view, so the first week of the month shows releases from the previous month) |
future-days |
90 |
How many days into the future to fetch (should be ≥ 60 to cover the full ~2-month view) |
If you want to keep the defaults but override the URLs, the cleanest
pattern is to copy the widget's options block into your main
dynacat.yml and $include the rest, or to edit arr-calendar.yml
directly.
Customising URLs
The default URLs use the BASE_HOST env var and assume Sonarr/Radarr/Lidarr
are reachable as subdomains of that host. To use a different host for one
of the services, override the corresponding *-url option in your
$include. The YML supports Go template strings, so you can interpolate
env vars:
- type: custom-api
options:
sonarr-url: https://sonarr.${BASE_HOST}
sonarr-key: ${SONARR_KEY}
radarr-url: http://192.168.1.50:7878 # absolute override
radarr-key: ${RADARR_KEY}
lidarr-key: ${LIDARR_KEY} # omitted URL → defaults to https://lidarr.${BASE_HOST}
Note: at least one of the three (url, key) pairs must be set per service
for the service to be queried. An empty *-key skips the service
entirely — see Partial-services support.
Partial-services support
This is the headline feature. The widget keeps working with any subset of the three services configured:
- Set only
SONARR_KEY→ calendar shows only Sonarr releases (blue dots only) - Set all three → all three colours of dots appear
- Set none → soft empty-state message: "Configure at least one of SONARR_KEY, RADARR_KEY, or LIDARR_KEY in the container environment to show releases."
A service is "configured" if both its URL and API key are non-empty. If either is empty, that service's API is silently skipped — no request goes out, no error widget appears, and the widget continues to render with the remaining services.
If a service is configured but unreachable (network down, service dead, 401 from a wrong key), the same logic applies: that service is skipped (the 5s HTTP timeout will trip), and the other services continue to render.
The status of each service is exposed to the client JS in a hidden
<ul class="arr-calendar-status">:
<ul class="arr-calendar-status" hidden>
<li data-service="sonarr" data-configured="true" data-ok="true"></li>
<li data-service="radarr" data-configured="true" data-ok="false"></li> <!-- service down -->
<li data-service="lidarr" data-configured="false" data-ok="true"></li> <!-- unconfigured = ok -->
</ul>
The widget JS doesn't currently surface this in the UI, but the data is there if you want to add a "Radarr is offline" pill in the popover.
How it works (architecture)
┌────────────────────────────────────────────────────────────┐
│ Go template (server side, every update-interval = 10m) │
│ 1. Compute $xxxEnabled = (URL != "") && (key != "") │
│ 2. Declare $xxxData := newRequest "" | getResponse │
│ (outer scope; empty URL → StatusCode 0 placeholder) │
│ 3. Inside {{- if $xxxEnabled }}: │
│ $xxxData = newRequest <real-url> | withHeader ... │
│ | getResponse │
│ 4. Emit hidden <li data-service=...> per release │
└────────────────────────────────────────────────────────────┘
│
▼ HTTP response, XHR-rendered
┌────────────────────────────────────────────────────────────┐
│ Browser │
│ <script src="/assets/arr-calendar.js" defer> │
│ MutationObserver watches for the widget to mount │
│ Reads the <li> elements, builds an in-memory model │
│ Renders 6-week grid; click → popover with covers │
│ Prev/next/undo buttons re-render with rAF slide+fade │
└────────────────────────────────────────────────────────────┘
Why this split? Dynacat's custom-api widget re-renders server-side
on the update interval. All interactivity (prev/next month, click
popovers) is handled client-side in vanilla JS. The widget HTML
includes a hidden <ul class="arr-release-data"> with one <li> per
release, encoded as data-* attributes. The client JS reads them with
element.getAttribute().
Why isn't the JS in the YML too?
Per the HTML5 spec, <script> tags inserted via innerHTML (which is
how Dynacat injects custom-api widget HTML) do not execute. Inline
<style> blocks work because CSP allows style-src 'self' 'unsafe-inline'; inline <script> is also allowed by CSP, but the
browser still doesn't run scripts that arrived via innerHTML.
<script src="data:..."> is rejected by CSP (data: is not
same-origin). The only working path: a real .js file at a real
same-origin path, loaded via <script src="..."> in
document.head. That's the one line in your main dynacat.yml.
File-by-file reference
arr-calendar.yml (the widget)
- Top:
options:block (env-var interpolation, all keys optional) template:body:- Lines 1-12: Go template variables (
$radarrUrl,$sonarrKey, etc.) - Lines 14-17: enabled flags, outer-scope
newRequest ""defaults - Lines 19-35: per-service
newRequest(only when enabled) - Lines 37-303: inline
<style>block (allarr-cal-*CSS rules) - Lines 305-498: widget HTML shell + grid + popover + data
<ul>
- Lines 1-12: Go template variables (
arr-calendar.js (the client)
- Bootstrap:
MutationObserverwatchesdocument.bodyfor the widget to mount (Dynacat loads widget HTML via XHR, so the script can run before the widget is in the DOM) - Per-widget guard:
data-arr-inited="1"attribute on the widget root prevents double-init when Dynacat's SSE re-morphs the widget HTML - Render: builds a 6-week grid (always 42 cells, with prev/next month
spillover) into
data-arr-grid - Click: shows a
position: fixedpopover with cover art, title, subtitle, link — portaled to<body>to escape ancestorbackdrop-filter/transformcontaining blocks - Animation: manual rAF-driven
style.opacity/style.transforminterpolation ondata-arr-gridfor prev/next; same pattern for the undo button entrance
Troubleshooting
"Config has errors: custom-api widget: parsing template: ... function not defined"
This means the Go template engine doesn't recognise a function you used. Common offenders and fixes:
| Error | Cause | Fix |
|---|---|---|
function "dict" not defined |
Used dict to fabricate an empty response object |
Use the outer-scope newRequest "" default pattern (see the YML) |
function "withTimeout" not defined |
Added ` | withTimeout "5s"` to the request chain |
function "subrequest" not defined |
Tried to use a non-existent helper | Check https://raw.githubusercontent.com/glanceapp/glance/main/internal/glance/widget-custom-api.go for the actual funcmap |
"Config has errors: ... :NNN: undefined variable $xxxData"
You declared a variable with := inside an {{- if }} block and then
referenced it outside. Go's text/template scopes := declarations to
the enclosing block. Fix: declare the variable at the outer scope with
newRequest "" | getResponse (gives a zero-valued response with
StatusCode = 0), then use = (not :=) inside the if-block to
reassign. See the "outer-scope default" pattern in arr-calendar.yml.
"Config has errors: ... :NNN: ... " on the line that doesn't look related
Custom-api templates are parsed lazily on first render, and parse errors are only surfaced at container restart. The line number in the error message may not match the line of the actual bug — it's the byte offset in the template string when the parser hit the error. Restart the container to see the error, not the YML edit.
Widget renders but no releases appear
- Check the browser DevTools console for errors in
arr-calendar.js - Verify the env vars are set in the container:
docker exec dynacat env | grep -E "SONARR|RADARR|LIDARR" - Test the API directly:
curl -H "X-Api-Key: $SONARR_KEY" "https://sonarr.${BASE_HOST}/api/v3/calendar?start=2026-06-01T00:00:00&end=2026-09-01T00:00:00&includeSeries=true"— should return JSON with an array - Check the hidden status
<ul>: open DevTools → Elements → search forarr-calendar-status. Each<li>hasdata-configuredanddata-okflags. If a service hasdata-ok="false", the API call failed. - If a service is missing from the
<ul>entirely, the request block is being skipped — check that both the URL and key are non-empty in the rendered HTML (theBASE_HOSTinterpolation could be failing ifBASE_HOSTisn't set on the container)
Popover doesn't appear when clicking a day
- Check the console for
Cannot set properties of nullor similar TypeErrors. Most common cause: the popover is being portaled to<body>on first show, then a second click tries to find it inside the widget and getsnull. The shippedarr-calendar.jshandles this via afindPopoverhelper.
Animation is missing / stuck
The widget uses a manual rAF-driven animation (grid.style.opacity and
grid.style.transform set every frame). If you see no animation:
- Open DevTools → Elements → find
.arr-calendar-grid - Click prev/next, sample
grid.style.opacityin the console — values should change between 0 and 1 over ~700ms - If they don't, your Dynacat might be running an older build that doesn't include the manual rAF path. The Web Animations API approach has been observed to fail on this user's setup; CSS class-toggle has also been observed to fail silently. The rAF approach is the only one that's been confirmed to work.
License
MIT. Do what you want with it. Cover art and release data belong to their respective rightsholders; this widget just displays them.
Credits
- Built for Dynacat, a fork of Glance by Niklas Jonsson and contributors.
- The slide+fade animation pattern is reverse-engineered from
internal/glance/static/js/calendar.jsin Glance. - The popover portal pattern (move to
<body>to escapebackdrop-filtercontaining blocks) is a well-known workaround documented across many CSS layout resources.
How to add a screenshot
- Open your dashboard
- Take a screenshot of the calendar widget area
- Save it as
docs/screenshot.png - Commit and push — the README will show it at the top