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
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
# 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
|
||||
|
||||
 *(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
|
||||
Reference in New Issue
Block a user