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

378 lines
17 KiB
Markdown

# arr-releases
A Dynacat ([Panonim/dynacat](https://github.com/Panonim/dynacat)) calendar widget
that aggregates upcoming releases from [Sonarr](https://sonarr.tv),
[Radarr](https://radarr.video) and [Lidarr](https://lidarr.audio).
- 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](docs/screenshot.png) *(add a screenshot if you have
one — see [How to add a screenshot](#how-to-add-a-screenshot))*
## Quick install (TL;DR)
```bash
# 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](#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`:
```yaml
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):
```yaml
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:
```bash
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](#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:`:
```yaml
- 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:
```yaml
- 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).
## 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">`:
```html
<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 | Remove it — Glance's default HTTP client already has a 5s timeout |
| `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 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](https://github.com/Panonim/dynacat), a fork of
[Glance](https://github.com/glanceapp/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