Files
droideparanoico fa442b1fef Initial release: arr-calendar widget with partial-services support
- 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
2026-06-16 09:55:30 +00:00

17 KiB

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_KEY only and the widget will silently skip Radarr and Lidarr

Calendar widget example (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 (all arr-cal-* CSS rules)
    • Lines 305-498: widget HTML shell + grid + popover + data <ul>

arr-calendar.js (the client)

  • Bootstrap: MutationObserver watches document.body for 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: fixed popover with cover art, title, subtitle, link — portaled to <body> to escape ancestor backdrop-filter / transform containing blocks
  • Animation: manual rAF-driven style.opacity / style.transform interpolation on data-arr-grid for 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.

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 for arr-calendar-status. Each <li> has data-configured and data-ok flags. If a service has data-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 (the BASE_HOST interpolation could be failing if BASE_HOST isn't set on the container)

Popover doesn't appear when clicking a day

  • Check the console for Cannot set properties of null or 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 gets null. The shipped arr-calendar.js handles this via a findPopover helper.

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.opacity in 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.js in Glance.
  • The popover portal pattern (move to <body> to escape backdrop-filter containing blocks) is a well-known workaround documented across many CSS layout resources.

How to add a screenshot

  1. Open your dashboard
  2. Take a screenshot of the calendar widget area
  3. Save it as docs/screenshot.png
  4. Commit and push — the README will show it at the top