Files
arr-releases/arr-calendar.yml
T
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

506 lines
19 KiB
YAML

- 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 }}