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:
2026-06-16 09:55:30 +00:00
commit fa442b1fef
5 changed files with 1548 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# OS / editor cruft
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.idea/
.vscode/
# Optional docs (uncomment to ignore the placeholder screenshot if you don't have one)
# docs/screenshot.png
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 David Álvarez
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+377
View File
@@ -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
![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
+634
View File
@@ -0,0 +1,634 @@
(function () {
'use strict';
var MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
var WEEKDAYS_MON_FIRST = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function parseISODate(s) {
var parts = s.split('-');
return {
y: parseInt(parts[0], 10),
m: parseInt(parts[1], 10) - 1,
d: parseInt(parts[2], 10)
};
}
function formatISO(y, m, d) {
return y + '-' + pad2(m + 1) + '-' + pad2(d);
}
function daysInMonth(y, m) {
return new Date(y, m + 1, 0).getDate();
}
function collectReleases(widget) {
var byDate = {};
var lis = widget.querySelectorAll('.arr-release-data > li');
for (var i = 0; i < lis.length; i++) {
var li = lis[i];
var date = li.getAttribute('data-date');
if (!date) continue;
if (!byDate[date]) byDate[date] = [];
byDate[date].push({
service: li.getAttribute('data-service'),
date: date,
title: li.getAttribute('data-title') || '',
subtitle: li.getAttribute('data-subtitle') || '',
link: li.getAttribute('data-link') || '#',
cover: li.getAttribute('data-cover') || ''
});
}
return byDate;
}
// Build the rows for the current month. The calendar always renders
// exactly 6 weeks (42 day cells) so its height is constant across all
// months. The first row of cells starts on `firstDayOfWeek` (Monday)
// and may include spillover days from the previous month; the last
// row may include spillover days from the next month. This matches
// Glance's built-in calendar (FULL_MONTH_SLOTS = 7 * 6).
//
// Spillover formula matches Glance's calendar.js exactly: when the
// month starts on firstDayOfWeek itself, we treat that as "a full
// previous week" (7 spillover days), not zero — this keeps every row
// aligned to a complete Mon..Sun week.
function buildRows(year, month, firstDayOfWeek) {
if (firstDayOfWeek === undefined) firstDayOfWeek = 0; // 0 = Monday
var firstDay = new Date(year, month, 1);
// Monday-first: convert JS getDay() (0=Sun..6=Sat) to 0=Mon..6=Sun
var firstWeekday = (firstDay.getDay() + 6) % 7;
var totalDays = daysInMonth(year, month);
var prevMonthDays = daysInMonth(year, month - 1);
// Header row (weekday labels) — separate from the day grid below.
var rows = [];
var headerRow = [];
for (var w = 0; w < 7; w++) {
headerRow.push({ type: 'weekday', text: WEEKDAYS_MON_FIRST[w] });
}
rows.push(headerRow);
// Distribute 42 cells into: prev-month spillover, current month,
// next-month spillover. Matches Glance's `previousMonthSpilloverDays`
// formula: when firstWeekday == firstDayOfWeek we render a full week
// of prev-month spillover (7), not 0.
var prevSpill = (firstWeekday - firstDayOfWeek + 7) % 7 || 7; // 1..7
var currentDays = totalDays; // 28..31
var nextSpill = 42 - (prevSpill + currentDays); // 4..14
// Sanity guard — the math always adds to 42 for valid months, but if
// something pathological happens, clamp to keep the layout stable.
if (nextSpill < 0) nextSpill = 0;
// Build 6 rows of 7 cells each, filling in:
// 1..prevSpill → previous-month spillover (from prev month)
// prevSpill+1..prevSpill+currentDays → current month
// the rest → next-month spillover
var cellIndex = 0;
for (var r = 0; r < 6; r++) {
var row = [];
for (var d = 0; d < 7; d++) {
cellIndex++;
if (cellIndex <= prevSpill) {
// Previous-month spillover: day number is prevMonthDays - prevSpill + cellIndex
row.push({
type: 'day',
day: prevMonthDays - prevSpill + cellIndex,
outsideMonth: true,
iso: null,
releases: []
});
} else if (cellIndex <= prevSpill + currentDays) {
var dayOfMonth = cellIndex - prevSpill;
var iso = formatISO(year, month, dayOfMonth);
row.push({
type: 'day',
day: dayOfMonth,
outsideMonth: false,
iso: iso,
releases: [] // filled in by renderGrid
});
} else {
// Next-month spillover: day number starts at 1
var nextDay = cellIndex - (prevSpill + currentDays);
row.push({
type: 'day',
day: nextDay,
outsideMonth: true,
iso: null,
releases: []
});
}
}
rows.push(row);
}
return rows;
}
// Animation constants matching the built-in Glance calendar's slide+fade
// effect when navigating between months. Uses requestAnimationFrame to
// manually drive the animation. This is the most reliable approach
// because it doesn't depend on CSS transitions, Web Animations API, or
// any specific browser behavior.
var ANIM_DURATION_ENTER = 700;
// Slide distance is 0.8rem — converted to pixels at call time using
// the actual root font-size (Dynacat sets it to 10px, so 0.8rem ≈ 8px).
var ANIM_DISTANCE_REM = 0.8;
// Render the new content first (so navigation is responsive even if the
// animation fails for any reason), then apply the slide+fade animation
// as a visual flourish.
function animateGridChange(widgetOrGrid, renderNew, dir) {
if (dir !== -1) dir = 1;
// Resolve the live grid element.
var gridEl = widgetOrGrid;
if (gridEl && gridEl.classList && !gridEl.classList.contains('arr-calendar-grid')) {
gridEl = widgetOrGrid.querySelector('.arr-calendar-grid');
}
if (!gridEl) {
renderNew();
return;
}
// Compute the actual pixel distance based on the root font size.
// (CSS rem resolves to the root font-size; Dynacat sets it to 10px.)
var rootStyle = window.getComputedStyle(document.documentElement);
var rootFontSize = parseFloat(rootStyle.fontSize) || 10;
var distancePx = ANIM_DISTANCE_REM * rootFontSize;
// Direction:
// NEXT (forward in time): new month slides in from the RIGHT
// PREV (backward in time): new month slides in from the LEFT
var startX = dir > 0 ? distancePx : -distancePx;
// Render the new content first.
renderNew();
// Animate via rAF. We use a "linear-tween" approach: compute progress
// over the duration, interpolate opacity and transform.
var startTime = null;
function step(now) {
if (startTime === null) startTime = now;
var elapsed = now - startTime;
var progress = Math.min(elapsed / ANIM_DURATION_ENTER, 1);
// Use the same easing as Glance's calendar: cubic-bezier(0.22, 1, 0.36, 1)
// Approximated as a simple easeOutCubic for performance.
var eased = 1 - Math.pow(1 - progress, 3);
gridEl.style.opacity = String(eased);
gridEl.style.transform = 'translateX(' + (startX * (1 - eased)) + 'px)';
if (progress < 1) {
requestAnimationFrame(step);
} else {
// Clean up inline styles when done
gridEl.style.opacity = '';
gridEl.style.transform = '';
}
}
requestAnimationFrame(step);
}
// Animate the undo button sliding in from the left (matches the built-in
// Glance calendar's "calendar-undo-button" entrance animation). Uses
// rAF-driven manual animation for reliability.
function animateUndoEntrance(undoEl) {
if (!undoEl || undoEl.hidden) return;
// translateX(-100%) means shift by the element's own width; use
// offsetWidth to compute the actual pixel distance.
var startX = -undoEl.offsetWidth;
var startTime = null;
function step(now) {
if (startTime === null) startTime = now;
var elapsed = now - startTime;
var progress = Math.min(elapsed / 400, 1);
var eased = 1 - Math.pow(1 - progress, 3);
undoEl.style.opacity = String(eased);
undoEl.style.transform = 'translateX(' + (startX * (1 - eased)) + 'px)';
if (progress < 1) {
requestAnimationFrame(step);
} else {
undoEl.style.opacity = '';
undoEl.style.transform = '';
}
}
requestAnimationFrame(step);
}
function renderGrid(widget, year, month, byDate, today, todayParts) {
var monthEl = widget.querySelector('[data-arr-month]');
var yearEl = widget.querySelector('[data-arr-year]');
var monthNumEl = widget.querySelector('[data-arr-monthnum]');
var undoEl = widget.querySelector('[data-arr-undo]');
var gridEl = widget.querySelector('[data-arr-grid]');
monthEl.textContent = MONTH_NAMES[month];
yearEl.textContent = year;
monthNumEl.textContent = pad2(month + 1);
// Show undo button only when not viewing the current month
var onCurrent = (year === todayParts.y && month === todayParts.m);
undoEl.hidden = onCurrent;
gridEl.innerHTML = '';
var rows = buildRows(year, month);
for (var r = 0; r < rows.length; r++) {
var row = rows[r];
// Group weekday cells: render header row as plain text cells
if (row[0].type === 'weekday') {
for (var i = 0; i < 7; i++) {
var wd = document.createElement('div');
wd.className = 'arr-cal-weekday';
wd.textContent = row[i].text;
gridEl.appendChild(wd);
}
continue;
}
for (var j = 0; j < 7; j++) {
var cellData = row[j];
var cell = document.createElement('button');
cell.type = 'button';
cell.className = 'arr-cal-cell';
if (cellData.outsideMonth) {
cell.classList.add('arr-cal-outside');
} else {
var releases = byDate[cellData.iso] || [];
cellData.releases = releases;
cell.setAttribute('data-date', cellData.iso);
cell.setAttribute('aria-label', cellData.iso +
(releases.length > 0 ? ', ' + releases.length + ' releases' : ''));
if (cellData.iso === today) cell.classList.add('arr-cal-today');
if (releases.length > 0) cell.classList.add('arr-cal-has-releases');
}
var num = document.createElement('span');
num.className = 'arr-cal-num';
num.textContent = String(cellData.day);
cell.appendChild(num);
if (!cellData.outsideMonth && cellData.releases.length > 0) {
var dots = document.createElement('span');
dots.className = 'arr-cal-dots';
var seen = {};
for (var k = 0; k < cellData.releases.length; k++) {
var svc = cellData.releases[k].service;
if (seen[svc]) continue;
seen[svc] = true;
var dot = document.createElement('span');
dot.className = 'arr-dot arr-dot-' + svc;
dots.appendChild(dot);
}
cell.appendChild(dots);
(function (cd, c) {
c.addEventListener('click', function (ev) {
ev.stopPropagation();
showPopover(widget, c, cd.releases);
});
})(cellData, cell);
} else if (!cellData.outsideMonth) {
(function (c) {
c.addEventListener('click', function (ev) {
ev.stopPropagation();
hidePopover(widget);
});
})(cell);
}
gridEl.appendChild(cell);
}
}
}
// Position the popover relative to the viewport (position: fixed) so it
// can overlay any other widget on the page. The popover must live in
// document.body, NOT inside the widget, because the widget is wrapped by
// .widget-content (which has backdrop-filter: blur) — and backdrop-filter
// creates a containing block for position:fixed descendants, which would
// clip the popover to the widget. Moving it to <body> avoids that.
function positionPopover(pop, cell) {
var rect = cell.getBoundingClientRect();
var popRect = pop.getBoundingClientRect();
var margin = 8;
var vw = window.innerWidth;
var vh = window.innerHeight;
// Default: place below the cell, horizontally aligned to its left edge
var top = rect.bottom + margin;
var left = rect.left;
// If popover would overflow the bottom, place it above the cell
if (top + popRect.height > vh - margin) {
top = rect.top - popRect.height - margin;
}
// If still off-screen (cell near top), clamp to viewport
if (top < margin) top = margin;
// If popover would overflow the right edge, shift left
if (left + popRect.width > vw - margin) {
left = vw - popRect.width - margin;
}
if (left < margin) left = margin;
pop.style.position = 'fixed';
pop.style.top = top + 'px';
pop.style.left = left + 'px';
}
function showPopover(widget, cell, releases) {
var pop = findPopover(widget);
if (!pop) {
// No popover element found anywhere — should not happen since the
// widget template ships one, but be defensive.
return;
}
pop.innerHTML = '';
var header = document.createElement('div');
header.className = 'arr-popover-header';
var iso = cell.getAttribute('data-date');
var d = parseISODate(iso);
var headerDate = new Date(d.y, d.m, d.d);
var opts = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
header.textContent = headerDate.toLocaleDateString(undefined, opts);
pop.appendChild(header);
if (releases.length === 0) {
var empty = document.createElement('div');
empty.className = 'arr-popover-empty';
empty.textContent = 'No releases on this day.';
pop.appendChild(empty);
} else {
var list = document.createElement('ul');
list.className = 'arr-popover-list';
releases.sort(function (a, b) {
return a.service.localeCompare(b.service);
});
for (var i = 0; i < releases.length; i++) {
var rel = releases[i];
var item = document.createElement('li');
item.className = 'arr-popover-item arr-svc-' + rel.service;
var cover = document.createElement('img');
cover.className = 'arr-popover-cover';
cover.loading = 'lazy';
cover.alt = '';
if (rel.cover) {
cover.src = rel.cover;
} else {
cover.style.display = 'none';
}
item.appendChild(cover);
var text = document.createElement('div');
text.className = 'arr-popover-text';
var titleEl = document.createElement('a');
titleEl.className = 'arr-popover-title';
titleEl.href = rel.link;
titleEl.target = '_blank';
titleEl.rel = 'noopener noreferrer';
titleEl.textContent = rel.title;
text.appendChild(titleEl);
var sub = document.createElement('div');
sub.className = 'arr-popover-sub';
var badge = document.createElement('span');
badge.className = 'arr-popover-badge arr-svc-badge-' + rel.service;
badge.textContent = rel.service;
sub.appendChild(badge);
var subText = document.createTextNode(' ' + rel.subtitle);
sub.appendChild(subText);
text.appendChild(sub);
item.appendChild(text);
list.appendChild(item);
}
pop.appendChild(list);
}
// Move the popover out of the widget to <body> so it can use
// position:fixed relative to the viewport. The widget is wrapped by
// .widget-content which has backdrop-filter, and backdrop-filter
// creates a containing block for position:fixed descendants.
if (pop.parentElement !== document.body) {
document.body.appendChild(pop);
}
// Tag the popover with its owning widget id so we can find it again
// after it's been moved (findPopover uses this).
pop.dataset.widgetId = widget.dataset.arrId;
// Make visible first (so getBoundingClientRect works), then position
pop.hidden = false;
pop.style.top = '0px';
pop.style.left = '0px';
// Force layout, then position relative to the clicked cell
positionPopover(pop, cell);
pop.dataset.date = iso;
}
function hidePopover(widget) {
var pop = findPopover(widget);
if (!pop) return;
pop.hidden = true;
pop.innerHTML = '';
delete pop.dataset.date;
// Move the popover back inside the widget so it stays associated with it
// (and so the next showPopover will find it there).
if (pop.parentElement === document.body) {
widget.appendChild(pop);
}
}
function initWidget(widget) {
ensureWidgetId(widget);
if (widget.dataset.arrInited === '1') {
// Already inited — but Dynacat's SSE may have re-morphed the widget
// HTML in place. In that case the cells were replaced and lost their
// click handlers. Detect a fresh grid (no children yet) and re-render.
var existingGrid = widget.querySelector('.arr-calendar-grid');
if (existingGrid && existingGrid.children.length > 0) return;
}
widget.dataset.arrInited = '1';
var today = widget.getAttribute('data-today') ||
formatISO(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
var todayParts = parseISODate(today);
var byDate = collectReleases(widget);
var view = { y: todayParts.y, m: todayParts.m };
function render() {
renderGrid(widget, view.y, view.m, byDate, today, todayParts);
}
// Track whether the undo button is currently visible, so we can decide
// whether to animate its entrance on the next render.
var undoWasVisible = false;
// Capture the current grid state for animation, then re-render with
// the new month and animate the change. The grid element is looked up
// FRESH on each call so that the animation applies to the live grid
// even if the widget was re-morphed by Dynacat's SSE update between
// page load and now (a closure-captured reference would be stale).
function navigateWithTransition(dir) {
if (dir !== -1) dir = 1;
var onCurrent = (view.y === todayParts.y && view.m === todayParts.m);
var undoWillBeVisible = !onCurrent;
animateGridChange(widget, function () {
render();
}, dir);
if (undoWillBeVisible && !undoWasVisible) {
// Wait a tick so the DOM updates before animating
setTimeout(function () {
animateUndoEntrance(widget.querySelector('[data-arr-undo]'));
}, 0);
}
undoWasVisible = undoWillBeVisible;
}
widget.querySelector('[data-arr-nav="prev"]').addEventListener('click', function () {
view.m -= 1;
if (view.m < 0) { view.m = 11; view.y -= 1; }
hidePopover(widget);
navigateWithTransition(-1);
});
widget.querySelector('[data-arr-nav="next"]').addEventListener('click', function () {
view.m += 1;
if (view.m > 11) { view.m = 0; view.y += 1; }
hidePopover(widget);
navigateWithTransition(1);
});
widget.querySelector('[data-arr-undo]').addEventListener('click', function () {
// Direction: if current view is in the past, undo = forward (+1).
// If in the future, undo = backward (-1).
var dir = 1;
if (view.y > todayParts.y || (view.y === todayParts.y && view.m > todayParts.m)) {
dir = -1;
}
view.y = todayParts.y;
view.m = todayParts.m;
hidePopover(widget);
navigateWithTransition(dir);
});
render();
undoWasVisible = false; // initial render, undo is hidden
}
// Find the popover element. It normally lives inside the widget, but
// during display it's moved to <body> to escape the widget's
// backdrop-filter containing block. Look in both places.
function findPopover(widget) {
var pop = widget.querySelector('[data-arr-popover]');
if (pop) return pop;
// The popover may be on <body>. Find it by its dataset/attribute.
var bodyPops = document.body.querySelectorAll('[data-arr-popover].arr-calendar-popover');
for (var i = 0; i < bodyPops.length; i++) {
var bp = bodyPops[i];
if (bp.dataset.widgetId === widget.dataset.arrId) return bp;
}
return null;
}
// Assign a stable id to the widget so we can find its popover on <body>.
function ensureWidgetId(widget) {
if (!widget.dataset.arrId) {
widget.dataset.arrId = 'arrcal_' + Math.random().toString(36).slice(2, 10);
}
return widget.dataset.arrId;
}
// Close popover when clicking outside any arr-calendar-widget
document.addEventListener('click', function (ev) {
var widgets = document.querySelectorAll('.arr-calendar-widget');
for (var i = 0; i < widgets.length; i++) {
var w = widgets[i];
if (w.contains(ev.target)) continue;
var pop = findPopover(w);
if (pop && !pop.hidden) hidePopover(w);
}
});
// Esc closes popover
document.addEventListener('keydown', function (ev) {
if (ev.key !== 'Escape') return;
var widgets = document.querySelectorAll('.arr-calendar-widget');
for (var i = 0; i < widgets.length; i++) {
hidePopover(widgets[i]);
}
});
// Reposition popover on scroll/resize so it stays attached to its cell
window.addEventListener('scroll', function () {
var widgets = document.querySelectorAll('.arr-calendar-widget');
for (var i = 0; i < widgets.length; i++) {
var widget = widgets[i];
var pop = findPopover(widget);
if (!pop || pop.hidden) continue;
var iso = pop.dataset.date;
if (!iso) continue;
var cell = widget.querySelector('.arr-cal-cell[data-date="' + iso + '"]');
if (cell) positionPopover(pop, cell);
}
}, true);
window.addEventListener('resize', function () {
var widgets = document.querySelectorAll('.arr-calendar-widget');
for (var i = 0; i < widgets.length; i++) {
var widget = widgets[i];
var pop = findPopover(widget);
if (!pop || pop.hidden) continue;
var iso = pop.dataset.date;
if (!iso) continue;
var cell = widget.querySelector('.arr-cal-cell[data-date="' + iso + '"]');
if (cell) positionPopover(pop, cell);
}
});
// Boot: scan DOM now, then watch for widgets added later (Dynacat's
// custom-api widgets are inserted via innerHTML/XHR after page load,
// which means our <script src="arr-calendar.js"> tag inside the widget
// is never executed, and the initial scan() finds zero widgets).
function scan() {
var widgets = document.querySelectorAll('.arr-calendar-widget:not([data-arr-inited])');
for (var i = 0; i < widgets.length; i++) {
initWidget(widgets[i]);
}
}
function startObserver() {
scan();
var observer = new MutationObserver(function (mutations) {
var found = false;
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType !== 1) continue;
if (n.classList && n.classList.contains('arr-calendar-widget')) {
initWidget(n);
found = true;
} else if (n.querySelectorAll) {
var inner = n.querySelectorAll('.arr-calendar-widget');
for (var k = 0; k < inner.length; k++) {
initWidget(inner[k]);
found = true;
}
}
}
}
if (!found) scan();
});
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.body) {
startObserver();
} else {
document.addEventListener('DOMContentLoaded', startObserver);
}
})();
+505
View File
@@ -0,0 +1,505 @@
- type: custom-api
title: Arr Calendar
hide-header: true
update-interval: 10m
options:
radarr-url: https://radarr.${BASE_HOST}
radarr-key: ${RADARR_KEY}
sonarr-url: https://sonarr.${BASE_HOST}
sonarr-key: ${SONARR_KEY}
lidarr-url: https://lidarr.${BASE_HOST}
lidarr-key: ${LIDARR_KEY}
exclude-physical: true
past-days: 90
future-days: 90
template: |
{{- /* === *arr Calendar Widget — self-contained === */}}
{{- /* CSS is inlined below (works because Dynacat CSP allows style-src */}}
{{- /* 'self' 'unsafe-inline'). JS lives in /assets/arr-calendar.js and */}}
{{- /* must be wired once in dynacat.yml > document.head: */}}
{{- /* <script src="/assets/arr-calendar.js" defer></script> */}}
<style>
.arr-calendar-widget {
--arr-sonarr: #3b82f6;
--arr-radarr: #22c55e;
--arr-lidarr: #a855f7;
position: relative;
font-family: inherit;
}
.arr-calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
margin-bottom: 0.6rem;
min-height: 2.8rem;
}
.arr-cal-header-left {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
height: 2.8rem;
}
.arr-cal-month {
font-size: var(--font-size-h2, 1.6rem);
font-weight: 600;
color: var(--color-text-highlight);
line-height: 1;
}
.arr-cal-year {
font-size: var(--font-size-h3, 1.5rem);
font-weight: 500;
color: var(--color-text-highlight);
line-height: 1;
}
.arr-cal-undo {
background: none;
border: none;
position: relative;
cursor: pointer;
width: 2.8rem;
height: 2.8rem;
color: var(--color-text-base);
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 0.2rem;
border-radius: var(--border-radius, 5px);
transition: color 0.15s ease;
}
.arr-cal-undo[hidden] { display: none; }
.arr-cal-undo svg { width: 2rem; height: 2rem; }
.arr-cal-undo::before {
content: '';
position: absolute;
inset: -0.2rem;
border-radius: var(--border-radius, 5px);
background-color: var(--color-text-subdue);
opacity: 0;
transition: opacity 0.2s;
z-index: -1;
}
.arr-cal-undo:hover { color: var(--color-text-highlight); }
.arr-cal-undo:hover::before { opacity: 0.4; }
.arr-cal-header-right {
display: inline-flex;
align-items: center;
gap: 0.7rem;
}
.arr-cal-monthnum {
font-size: var(--font-size-h3, 1.5rem);
font-weight: 500;
color: var(--color-text-highlight);
line-height: 1;
margin-top: 0.1rem;
min-width: 1.4rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.arr-cal-nav {
background: none;
border: none;
position: relative;
cursor: pointer;
width: 2.8rem;
height: 2.8rem;
color: var(--color-text-base);
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: var(--border-radius, 5px);
transition: color 0.15s ease;
}
.arr-cal-nav svg { width: 2rem; height: 2rem; }
.arr-cal-nav::before {
content: '';
position: absolute;
inset: -0.2rem;
border-radius: var(--border-radius, 5px);
background-color: var(--color-text-subdue);
opacity: 0;
transition: opacity 0.2s;
z-index: -1;
}
.arr-cal-nav:hover { color: var(--color-text-highlight); }
.arr-cal-nav:hover::before { opacity: 0.4; }
.arr-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
}
.arr-cal-weekday {
font-size: var(--font-size-h6, 1.1rem);
font-weight: 500;
text-transform: uppercase;
color: var(--color-text-subdue);
padding: 0.3rem 0;
line-height: 1.4;
}
.arr-cal-cell {
position: relative;
padding: 0.4rem 0;
color: var(--color-text-base);
border-radius: var(--border-radius, 5px);
background: none;
border: none;
font: inherit;
font-size: var(--font-size-base, 1.3rem);
cursor: pointer;
height: 3.2rem;
min-height: 3.2rem;
max-height: 3.2rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, background-color 0.15s ease;
}
.arr-cal-cell:hover:not(.arr-cal-outside) {
background-color: var(--color-widget-background-highlight);
}
.arr-cal-outside {
color: var(--color-text-subdue);
cursor: default;
pointer-events: none;
}
.arr-cal-today {
background-color: var(--color-popover-border);
color: var(--color-text-highlight);
}
.arr-cal-has-releases { font-weight: 600; }
.arr-cal-num {
font-size: inherit;
font-weight: inherit;
line-height: 1;
padding-bottom: 0.4rem;
}
.arr-cal-dots {
position: absolute;
bottom: 0.15rem;
left: 0;
right: 0;
display: inline-flex;
gap: 0.2rem;
align-items: center;
justify-content: center;
pointer-events: none;
line-height: 0;
}
.arr-dot {
display: inline-block;
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
flex-shrink: 0;
}
.arr-dot-sonarr { background: var(--arr-sonarr); }
.arr-dot-radarr { background: var(--arr-radarr); }
.arr-dot-lidarr { background: var(--arr-lidarr); }
.arr-calendar-popover {
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: max-content;
max-width: min(360px, calc(100vw - 2rem));
min-width: 260px;
max-height: 60vh;
overflow-y: auto;
background: var(--color-popover-background);
border: 1px solid var(--color-popover-border);
border-radius: var(--border-radius, 5px);
padding: 0.75rem 0.9rem;
box-shadow: 0 15px 20px -10px hsla(var(--bghs), calc(var(--bgl) * 0.2), 0.5);
color: var(--color-text-paragraph);
}
.arr-popover-header {
font-weight: 600;
font-size: var(--font-size-h3, 1.5rem);
margin-bottom: 0.6rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.arr-popover-empty {
font-size: var(--font-size-base, 1.3rem);
opacity: 0.7;
padding: 0.4rem 0;
}
.arr-popover-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.arr-popover-item {
display: flex;
gap: 0.6rem;
align-items: center;
padding: 0.3rem;
border-radius: 0.4rem;
background: rgba(255, 255, 255, 0.04);
border-left: 3px solid transparent;
}
.arr-popover-item.arr-svc-sonarr { border-left-color: var(--arr-sonarr); }
.arr-popover-item.arr-svc-radarr { border-left-color: var(--arr-radarr); }
.arr-popover-item.arr-svc-lidarr { border-left-color: var(--arr-lidarr); }
.arr-popover-cover {
width: 36px;
height: 54px;
object-fit: cover;
border-radius: 0.25rem;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
opacity: 1 !important;
}
.arr-popover-text { flex: 1; min-width: 0; }
.arr-popover-title {
display: block;
font-weight: 500;
font-size: var(--font-size-h4, 1.4rem);
text-decoration: none;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.arr-popover-title:hover { color: var(--color-primary, #a78bfa); }
.arr-popover-sub {
font-size: var(--font-size-h6, 1.1rem);
opacity: 0.8;
margin-top: 0.15rem;
display: flex;
align-items: center;
gap: 0.4rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.arr-popover-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 0.3rem;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #fff;
flex-shrink: 0;
}
.arr-svc-badge-sonarr { background: var(--arr-sonarr); }
.arr-svc-badge-radarr { background: var(--arr-radarr); }
.arr-svc-badge-lidarr { background: var(--arr-lidarr); }
.arr-release-data,
.arr-calendar-status { display: none !important; }
.arr-calendar-empty {
font-size: var(--font-size-base, 1.3rem);
color: var(--color-text-subdue);
padding: 1.2rem 0.4rem;
text-align: center;
line-height: 1.4;
}
.arr-calendar-empty code {
font-size: 0.95em;
padding: 0.05rem 0.3rem;
background: var(--color-widget-background-highlight, rgba(255,255,255,0.05));
border-radius: 0.25rem;
}
</style>
{{ $radarrUrl := .Options.StringOr "radarr-url" "" }}
{{ $sonarrUrl := .Options.StringOr "sonarr-url" "" }}
{{ $lidarrUrl := .Options.StringOr "lidarr-url" "" }}
{{ $radarrKey := .Options.StringOr "radarr-key" "" }}
{{ $sonarrKey := .Options.StringOr "sonarr-key" "" }}
{{ $lidarrKey := .Options.StringOr "lidarr-key" "" }}
{{ $excludePhysical := .Options.BoolOr "exclude-physical" false }}
{{ $pastDays := .Options.IntOr "past-days" 90 }}
{{ $futureDays := .Options.IntOr "future-days" 90 }}
{{ $pastH := mul $pastDays 24 }}
{{ $futureH := mul $futureDays 24 }}
{{ $start := offsetNow (printf "-%dh" $pastH) | formatTime "2006-01-02T15:04:05" }}
{{ $end := offsetNow (printf "+%dh" $futureH) | formatTime "2006-01-02T15:04:05" }}
{{ $nowIso := now | formatTime "2006-01-02T15:04:05" }}
{{- /* A service is "configured" if BOTH the URL and the API key are set.
Unconfigured services are silently skipped — no request, no error.
We declare $xxxData at the OUTER scope with an empty URL, which
Glance's getResponse() handles by returning a zero-valued response
with Response.StatusCode = 0 (see fetchCustomAPIResponse in
widget-custom-api.go: empty URL short-circuits to a no-op). The
outer-scope declaration is required because Go text/template
variables declared with `:=` inside an if-block are scoped to that
block — referencing them outside the block yields "undefined
variable". The if-block then reassigns the outer variable with `=`
when the service is enabled. */}}
{{ $radarrEnabled := and (ne $radarrUrl "") (ne $radarrKey "") }}
{{ $sonarrEnabled := and (ne $sonarrUrl "") (ne $sonarrKey "") }}
{{ $lidarrEnabled := and (ne $lidarrUrl "") (ne $lidarrKey "") }}
{{- $radarrData := newRequest "" | getResponse }}
{{- $sonarrData := newRequest "" | getResponse }}
{{- $lidarrData := newRequest "" | getResponse }}
{{- if $radarrEnabled }}
{{ $radarrReq := printf "%s/api/v3/calendar?start=%s&end=%s" $radarrUrl $start $end }}
{{- $radarrData = newRequest $radarrReq
| withHeader "Accept" "application/json"
| withHeader "X-Api-Key" $radarrKey
| getResponse }}
{{- end }}
{{- if $sonarrEnabled }}
{{ $sonarrReq := printf "%s/api/v3/calendar?includeSeries=true&start=%s&end=%s" $sonarrUrl $start $end }}
{{- $sonarrData = newRequest $sonarrReq
| withHeader "Accept" "application/json"
| withHeader "X-Api-Key" $sonarrKey
| getResponse }}
{{- end }}
{{- if $lidarrEnabled }}
{{ $lidarrReq := printf "%s/api/v1/calendar?start=%s&end=%s" $lidarrUrl $start $end }}
{{- $lidarrData = newRequest $lidarrReq
| withHeader "Accept" "application/json"
| withHeader "X-Api-Key" $lidarrKey
| getResponse }}
{{- end }}
{{ $anyConfigured := or $radarrEnabled $sonarrEnabled $lidarrEnabled }}
<div class="arr-calendar-widget" data-today="{{ now | formatTime "2006-01-02" }}">
<div class="arr-calendar-header">
<div class="arr-cal-header-left">
<div class="arr-cal-month" data-arr-month></div>
<div class="arr-cal-year" data-arr-year></div>
<button class="arr-cal-undo" type="button" data-arr-undo title="Back to current month" hidden>
<svg stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke-width="1.8" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3"></path>
</svg>
</button>
</div>
<div class="arr-cal-header-right">
<button class="arr-cal-nav" type="button" data-arr-nav="prev" title="Previous month" aria-label="Previous month">
<svg stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke-width="2" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 6 8 12l6 6"></path>
</svg>
</button>
<div class="arr-cal-monthnum" data-arr-monthnum></div>
<button class="arr-cal-nav" type="button" data-arr-nav="next" title="Next month" aria-label="Next month">
<svg stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke-width="2" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m10 6 6 6-6 6"></path>
</svg>
</button>
</div>
</div>
<div class="arr-calendar-grid" data-arr-grid></div>
<div class="arr-calendar-popover" data-arr-popover hidden></div>
<ul class="arr-release-data" hidden>
{{- if and $radarrEnabled (eq $radarrData.Response.StatusCode 200) }}
{{- range $radarrData.JSON.Array "" }}
{{- $title := .String "title" }}
{{- $id := .Int "id" }}
{{- $titleSlug := .String "titleSlug" }}
{{- $link := printf "%s/movie/%s" $radarrUrl $titleSlug }}
{{- $coverUrl := printf "%s/api/v3/mediacover/%d/poster-250.jpg?apikey=%s" $radarrUrl $id $radarrKey }}
{{- $releaseDate := .String "releaseDate" }}
{{- $releaseType := "Release" }}
{{- $selectedDate := $releaseDate }}
{{- $inCinemas := "" }}{{ if .Exists "inCinemas" }}{{ $inCinemas = .String "inCinemas" }}{{ end }}
{{- $digitalRelease := "" }}{{ if .Exists "digitalRelease" }}{{ $digitalRelease = .String "digitalRelease" }}{{ end }}
{{- $physicalRelease := "" }}{{ if .Exists "physicalRelease" }}{{ $physicalRelease = .String "physicalRelease" }}{{ end }}
{{- if and (ne $physicalRelease "") (gt $physicalRelease $nowIso) }}
{{- $releaseType = "Physical" }}
{{- $selectedDate = $physicalRelease }}
{{- end }}
{{- if and (ne $digitalRelease "") (gt $digitalRelease $nowIso) }}
{{- if or (eq $selectedDate $releaseDate) (lt $digitalRelease $selectedDate) }}
{{- $releaseType = "Digital" }}
{{- $selectedDate = $digitalRelease }}
{{- end }}
{{- end }}
{{- if and (ne $inCinemas "") (gt $inCinemas $nowIso) }}
{{- if or (eq $selectedDate $releaseDate) (lt $inCinemas $selectedDate) }}
{{- $releaseType = "In Cinemas" }}
{{- $selectedDate = $inCinemas }}
{{- end }}
{{- end }}
{{- if or (not $excludePhysical) (ne $releaseType "Physical") }}
{{- $dateOnly := $selectedDate | parseTime "RFC3339" | formatTime "2006-01-02" }}
<li data-service="radarr" data-date="{{ $dateOnly }}" data-title="{{ $title }}" data-subtitle="{{ $releaseType }}" data-link="{{ $link }}" data-cover="{{ $coverUrl }}"></li>
{{- end }}
{{- end }}
{{- end }}
{{- if and $sonarrEnabled (eq $sonarrData.Response.StatusCode 200) }}
{{- range $sonarrData.JSON.Array "" }}
{{- $seriesId := .Int "seriesId" }}
{{- $coverUrl := printf "%s/api/v3/mediacover/%d/poster-250.jpg?apikey=%s" $sonarrUrl $seriesId $sonarrKey }}
{{- $title := "" }}{{ if .Exists "series.title" }}{{ $title = .String "series.title" }}{{ end }}
{{- $titleSlug := "" }}{{ if .Exists "series.titleSlug" }}{{ $titleSlug = .String "series.titleSlug" }}{{ end }}
{{- $link := printf "%s/series/%s" $sonarrUrl $titleSlug }}
{{- $dateOnly := .String "airDateUtc" | parseTime "RFC3339" | formatTime "2006-01-02" }}
{{- $seString := printf "S%02dE%02d" (.Int "seasonNumber") (.Int "episodeNumber") }}
{{- $epTitle := .String "title" }}
{{- $subtitle := printf "%s - %s" $seString $epTitle }}
<li data-service="sonarr" data-date="{{ $dateOnly }}" data-title="{{ $title }}" data-subtitle="{{ $subtitle }}" data-link="{{ $link }}" data-cover="{{ $coverUrl }}"></li>
{{- end }}
{{- end }}
{{- if and $lidarrEnabled (eq $lidarrData.Response.StatusCode 200) }}
{{- range $lidarrData.JSON.Array "" }}
{{- $albumId := .Int "id" }}
{{- $title := "" }}{{ if .Exists "title" }}{{ $title = .String "title" }}{{ end }}
{{- $artistTitle := "" }}{{ if .Exists "artist.artistName" }}{{ $artistTitle = .String "artist.artistName" }}{{ end }}
{{- $foreignAlbumId := "" }}{{ if .Exists "foreignAlbumId" }}{{ $foreignAlbumId = .String "foreignAlbumId" }}{{ end }}
{{- $link := printf "%s/album/%s" $lidarrUrl $foreignAlbumId }}
{{- $coverUrl := printf "%s/api/v1/MediaCover/album/%d/poster-250.jpg?apikey=%s" $lidarrUrl $albumId $lidarrKey }}
{{- if .Exists "releaseDate" }}
{{- $dateOnly := .String "releaseDate" | parseTime "RFC3339" | formatTime "2006-01-02" }}
<li data-service="lidarr" data-date="{{ $dateOnly }}" data-title="{{ $title }}" data-subtitle="{{ $artistTitle }}" data-link="{{ $link }}" data-cover="{{ $coverUrl }}"></li>
{{- end }}
{{- end }}
{{- end }}
</ul>
<ul class="arr-calendar-status" hidden>
{{- if $sonarrEnabled }}
<li data-service="sonarr" data-configured="true" data-ok="{{ eq $sonarrData.Response.StatusCode 200 }}"></li>
{{- else }}
<li data-service="sonarr" data-configured="false" data-ok="true"></li>
{{- end }}
{{- if $radarrEnabled }}
<li data-service="radarr" data-configured="true" data-ok="{{ eq $radarrData.Response.StatusCode 200 }}"></li>
{{- else }}
<li data-service="radarr" data-configured="false" data-ok="true"></li>
{{- end }}
{{- if $lidarrEnabled }}
<li data-service="lidarr" data-configured="true" data-ok="{{ eq $lidarrData.Response.StatusCode 200 }}"></li>
{{- else }}
<li data-service="lidarr" data-configured="false" data-ok="true"></li>
{{- end }}
</ul>
</div>
{{- /* Soft empty-state message when the user hasn't configured any service.
We intentionally do NOT render the giant widget-error banner — partial
availability (e.g. Sonarr-only) is success, not an error. */}}
{{ if not $anyConfigured }}
<div class="arr-calendar-empty">
Configure at least one of <code>SONARR_KEY</code>, <code>RADARR_KEY</code>,
or <code>LIDARR_KEY</code> in the container environment to show releases.
</div>
{{ end }}