Media library alert runbook¶
Source rule: cluster/monitoring/media-library-alerts.yaml (PrometheusRule media-library-alerts, namespace monitoring, group media-library-health).
Context¶
Three alerts cover the media namespace library stack: Jellyfin serving errors, *arr backlog stagnation, and the data/media ZFS pool on the PVE host.
Two of the three carry verification caveats recorded during this runbook's review — see the flagged blocks below. Read them before trusting a "no alert firing" signal as evidence of health.
.workingdir2/archive/HANDOFF-media-stack.md still lists a fourth alert, StashScenesUnstashboxed, as living in this file. It does not exist in this rule file or anywhere else in the repo. The handoff doc is stale on that point.
| Namespace (workloads) | media |
| Namespace (rule) | monitoring |
*arr metrics source |
exportarr v2.3.0 sidecar per app, own Service + ServiceMonitor each |
| Jellyfin metrics sources | native /metrics (prometheus-net / ASP.NET) + jellyfin-exporter (:9594) |
| ZFS metrics source | node_exporter on the PVE01-home host (PR #548), not a pod |
Note that sonarr, sonarr-4k, sonarr-anime, radarr, radarr-4k and lidarr are separate Helm releases, each with its own ServiceMonitor and therefore its own job label.
JellyfinHighErrorRate¶
Severity: warning · Component: media · Subsystem: jellyfin
Fires when: the 5xx share of Jellyfin HTTP responses exceeds 5% and stays above it continuously for 10m.
sum(rate(jellyfin_http_responses_total{status=~"5.."}[5m]))
/
clamp_min(sum(rate(jellyfin_http_responses_total[5m])), 1) > 0.05
Two details that change how you read this:
- The rate window is 5m; the
for:is 10m. The alert needs the 5m-windowed ratio to stay over the threshold for a full 10 minutes, so a short error burst will not page. - The denominator is
clamp_min(..., 1), floored at 1 request/sec. Below ~1 rps of total traffic the denominator is held at 1, so the computed ratio understates the true error share. On a quiet library, a genuinely broken Jellyfin can sit under 5% and never fire.
Caveat — metric producer not confirmed.
jellyfin_http_responses_totalappears nowhere in this repo except inside this expression: no dashboard references it, andjellyfin-exporterdocuments onlyjellyfin_media_count,jellyfin_now_playing_stateandjellyfin_transcoding_sessions(apps/media/jellyfin-exporter/README.md). Native Jellyfin/metricsemits prometheus-net runtime and ASP.NET/Kestrel series, which use different names. Confirm the series exists before treating silence as health — run the bare selector below. If it returns nothing, this alert cannot fire and correcting the rule is a Git change.
Likely causes¶
- Hardware transcode failure — Arc dGPU driver or device-plugin unavailability causing FFmpeg to fail out.
- FFmpeg crashes on unsupported codecs or damaged media files.
- Plugin crash surfacing as 500s.
- Library database corruption or unavailability blocking metadata reads.
Diagnose (all read-only)
kubectl -n media logs -l app.kubernetes.io/name=jellyfin --tail=200
kubectl -n media logs -l app.kubernetes.io/name=jellyfin --previous --tail=200
kubectl -n media describe pod -l app.kubernetes.io/name=jellyfin
kubectl -n media top pod -l app.kubernetes.io/name=jellyfin
kubectl -n media get pod -l app.kubernetes.io/name=jellyfin -o wide
PromQL:
jellyfin_http_responses_total
sum(rate(jellyfin_http_responses_total{status=~"5.."}[5m])) by (status)
sum(rate(jellyfin_http_responses_total[5m]))
jellyfin_transcoding_sessions
Run the bare jellyfin_http_responses_total selector first. An empty result means the alert is inert and the incident must be diagnosed from logs, not from this alert.
Resolution¶
- Transcode-path faults are usually node-level (driver,
gpu.intel.com/xeadvertisement). Diagnose on the node; any device-plugin or node config change is a Git change undercluster/or the Ansible tree. - A bad image or plugin version is fixed by bumping the pinned tag/digest in
apps/media/jellyfin/values.yamland letting ArgoCD sync — a Git change, not a live edit. - Corrupt media files are fixed at the source file, outside the cluster.
- If a pod restart is genuinely the remedy, note that an ArgoCD sync does not restart an unchanged pod. Restarting is a live mutation and is out of scope for this GitOps cluster without operator sign-off; prefer landing the real fix in Git.
ArrMissingBacklogStale¶
Severity: warning · Component: media · Subsystem: arr
Fires when: the combined missing count is > 0 and has recorded zero changes over a 7-day window sampled hourly, holding for 1h.
(
(sonarr_episode_missing_total +
radarr_movie_missing_total +
ignoring(__name__) lidarr_album_missing_total) > 0
)
and on(job)
(changes(
(sonarr_episode_missing_total +
radarr_movie_missing_total +
ignoring(__name__) lidarr_album_missing_total)[7d:1h]) == 0)
The for: 1h is easy to miss and matters: this is a slow-burn alert, not a live signal. The [7d:1h] is a subquery — a 7-day range evaluated at 1-hour resolution.
Caveat — cross-job vector matching. Each
*arrruns its ownexportarrsidecar behind its own ServiceMonitor (cluster/monitoring/servicemonitors.yaml), sosonarr_episode_missing_total,radarr_movie_missing_totalandlidarr_album_missing_totalcarry differentjob/instance/podlabels. Prometheus arithmetic between instant vectors matches one-to-one on the remaining labels, so these three series have no matching partners and the sum plausibly evaluates to an empty vector — meaning the alert never fires. Theand on(job)and the{{ $labels.job }}in the annotation also imply per-job evaluation, which contradicts summing across jobs. Verify by running the sum expression directly before trusting silence. Fixing this requires rewriting the rule, which is a Git change.
Likely causes (assuming the alert is live)
- Broken or unreachable Prowlarr indexer — searches return nothing, so the backlog never drains.
- Custom-format or quality-profile gating so strict that no available release qualifies.
- Stuck download-client queue (qBittorrent / SABnzbd integration failure, stalled items).
- Network or credential failure between the
*arrapps and Prowlarr or the download clients.
Diagnose (all read-only)
kubectl -n media logs -l app.kubernetes.io/name=sonarr --tail=100 # repeat: radarr, lidarr
kubectl -n media logs -l app.kubernetes.io/name=prowlarr --tail=100
kubectl -n media logs -l app.kubernetes.io/name=qbittorrent --tail=100
kubectl -n media logs -l app.kubernetes.io/name=sabnzbd --tail=100
kubectl -n media get pods -o wide
PromQL — query each metric separately, since the combined expression is the suspect part:
sonarr_episode_missing_total
radarr_movie_missing_total
lidarr_album_missing_total
changes(sonarr_episode_missing_total[7d:1h])
The *arr Wanted view and the Prowlarr indexer health page are the authoritative UI checks; both are read-only surfaces as long as you do not trigger re-searches.
Resolution¶
- Indexer credentials/availability live in Prowlarr config, managed by
configarr/ the bootstrap Jobs — correcting them is a Git change to the relevant chart values or ConfigMap, then an ArgoCD sync. - Quality profiles and custom formats are likewise Git changes via
configarr. - A genuinely hung download client needs a pod restart, which is a live mutation — do not perform it as a routine step. Land the underlying fix in Git, or escalate to the operator if a restart is unavoidable.
DataMediaZfsHigh¶
Severity: critical · Component: media · Subsystem: storage
Fires when: data/media ZFS consumption exceeds 90% and holds for 30m.
(
sum(node_zfs_zpool_dataset_used_bytes{dataset="data/media"})
/
(sum(node_zfs_zpool_dataset_used_bytes{dataset="data/media"}) +
sum(node_zfs_zpool_dataset_available_bytes{dataset="data/media"}))
) > 0.90
Ratio is used / (used + available). Both series come from node_exporter running on the PVE01-home host, not from a pod in the cluster. data/media is hierarchical — child datasets share the parent's free space, hence the sum().
This is the one alert in this group with no verification caveat: the metrics and the mechanism check out.
Likely causes¶
- Library growth outrunning pool capacity — the ordinary case.
- Accumulated ZFS snapshots holding space that deleted files would otherwise release.
- Large incomplete downloads or temp files, e.g. a stalled qBittorrent/SABnzbd job.
*arrhistory/queue retention holding onto imported artifacts.
Longhorn metadata bloat, ARC eviction and scan churn are consequences of the pool filling, not causes — do not chase them as the root cause.
Diagnose (all read-only)
PromQL first — it needs no host access:
node_zfs_zpool_dataset_used_bytes{dataset="data/media"}
node_zfs_zpool_dataset_available_bytes{dataset="data/media"}
node_zfs_zpool_dataset_used_bytes{dataset=~"data/media.*"}
predict_linear(node_zfs_zpool_dataset_available_bytes{dataset="data/media"}[6h], 7*24*3600)
The predict_linear line estimates available bytes a week out; a negative result means the pool fills within the week.
On the PVE01-home host (read-only commands only):
zfs list -o name,used,avail,refer -r data/media
zfs list -t snapshot -o name,used,creation -s used -r data/media
zpool list -o name,size,alloc,free,cap,health
In-cluster context:
The
zfscommands must be run on the PVE host. There is no node-exporter pod to exec into for this data — node-exporter here is a host-level install, and the exporter container would carry neither a shell nor thezfsbinary.
Resolution¶
- Pruning snapshots and deleting media are live host actions on PVE, outside the GitOps flow. They are destructive and permanent — get operator confirmation before removing anything, and prefer the largest expirable snapshots identified by the
zfs list -t snapshotquery above. - Changing snapshot retention policy is a Git change to the snapshot job/cron definition, not an ad-hoc host edit.
- Growing the pool or adjusting quotas is a Git change in the infrastructure-as-code tree (Ansible/Terraform), plus the physical capacity work.
*arrhistory/queue retention settings are a Git change viaconfigarr.- If the alert is firing because the threshold is genuinely too tight for normal operation, adjust it in
cluster/monitoring/media-library-alerts.yaml— a Git change. Do not edit the PrometheusRule live; ArgoCD will revert it.