fa442b1fef
- 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
378 lines
17 KiB
Markdown
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
|
|
|
|
 *(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
|