diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml
index 9ea46eb..fdbf082 100644
--- a/ansible/playbooks/indri.yml
+++ b/ansible/playbooks/indri.yml
@@ -78,6 +78,23 @@
no_log: true
tags: [caddy]
+ # Jellyfin API key for metrics collection
+ - name: Fetch Jellyfin API key
+ ansible.builtin.command:
+ cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get ceywxkcd3z7najsy2nmmbs2vke --fields credential --reveal
+ delegate_to: localhost
+ register: _jellyfin_metrics_api_key
+ changed_when: false
+ no_log: true
+ check_mode: false
+ tags: [jellyfin_metrics]
+
+ - name: Set Jellyfin API key fact
+ ansible.builtin.set_fact:
+ jellyfin_metrics_api_key: "{{ _jellyfin_metrics_api_key.stdout }}"
+ no_log: true
+ tags: [jellyfin_metrics]
+
roles:
- role: alloy
tags: alloy
@@ -97,5 +114,9 @@
tags: minikube_metrics
- role: plex_metrics
tags: plex_metrics
+ - role: jellyfin
+ tags: jellyfin
+ - role: jellyfin_metrics
+ tags: jellyfin_metrics
- role: caddy
tags: caddy
diff --git a/ansible/roles/alloy/defaults/main.yml b/ansible/roles/alloy/defaults/main.yml
index 747a926..22462e4 100644
--- a/ansible/roles/alloy/defaults/main.yml
+++ b/ansible/roles/alloy/defaults/main.yml
@@ -72,6 +72,12 @@ alloy_mcquack_logs:
- path: /Users/erichblume/Library/Logs/mcquack.zot.err.log
service: zot
stream: stderr
+ - path: /Users/erichblume/Library/Logs/mcquack.jellyfin.out.log
+ service: jellyfin
+ stream: stdout
+ - path: /Users/erichblume/Library/Logs/mcquack.jellyfin.err.log
+ service: jellyfin
+ stream: stderr
alloy_plex_logs:
- path: /Users/erichblume/Library/Logs/Plex Media Server/Plex Media Server.log
diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml
index 34dc7fb..9f9a142 100644
--- a/ansible/roles/caddy/defaults/main.yml
+++ b/ansible/roles/caddy/defaults/main.yml
@@ -28,6 +28,9 @@ caddy_services:
- name: registry
host: "registry.{{ caddy_domain }}"
backend: "http://localhost:5050"
+ - name: jellyfin
+ host: "jellyfin.{{ caddy_domain }}"
+ backend: "http://localhost:8096"
# K8s services (via Tailscale Ingress)
# Caddy proxies to existing Tailscale endpoints - traffic stays local
diff --git a/ansible/roles/jellyfin/defaults/main.yml b/ansible/roles/jellyfin/defaults/main.yml
new file mode 100644
index 0000000..6c96d34
--- /dev/null
+++ b/ansible/roles/jellyfin/defaults/main.yml
@@ -0,0 +1,23 @@
+---
+# Jellyfin media server configuration
+
+# Port Jellyfin listens on
+jellyfin_port: 8096
+
+# Data directory (standard macOS location)
+jellyfin_data_dir: "{{ ansible_env.HOME }}/Library/Application Support/jellyfin"
+
+# Media path (NFS mount from sifaka)
+jellyfin_media_path: /Volumes/allisonflix
+
+# Homebrew cask application path
+jellyfin_cask_app_path: /Applications/Jellyfin.app
+
+# Binary path inside the cask app
+jellyfin_binary: "{{ jellyfin_cask_app_path }}/Contents/MacOS/jellyfin"
+
+# Web client path (different from binary location in Homebrew cask)
+jellyfin_webdir: "{{ jellyfin_cask_app_path }}/Contents/Resources/jellyfin-web"
+
+# Log directory
+jellyfin_log_dir: "{{ ansible_env.HOME }}/Library/Logs"
diff --git a/ansible/roles/jellyfin/handlers/main.yml b/ansible/roles/jellyfin/handlers/main.yml
new file mode 100644
index 0000000..410ec82
--- /dev/null
+++ b/ansible/roles/jellyfin/handlers/main.yml
@@ -0,0 +1,6 @@
+---
+- name: Reload jellyfin
+ ansible.builtin.shell: |
+ launchctl unload ~/Library/LaunchAgents/mcquack.jellyfin.plist 2>/dev/null || true
+ launchctl load ~/Library/LaunchAgents/mcquack.jellyfin.plist
+ changed_when: true
diff --git a/ansible/roles/jellyfin/tasks/main.yml b/ansible/roles/jellyfin/tasks/main.yml
new file mode 100644
index 0000000..6a92aa4
--- /dev/null
+++ b/ansible/roles/jellyfin/tasks/main.yml
@@ -0,0 +1,30 @@
+---
+- name: Install Jellyfin via Homebrew cask
+ community.general.homebrew_cask:
+ name: jellyfin
+ state: present
+
+- name: Ensure Jellyfin data directory exists
+ ansible.builtin.file:
+ path: "{{ jellyfin_data_dir }}"
+ state: directory
+ mode: '0755'
+
+- name: Deploy Jellyfin LaunchAgent plist
+ ansible.builtin.template:
+ src: mcquack.jellyfin.plist.j2
+ dest: ~/Library/LaunchAgents/mcquack.jellyfin.plist
+ mode: '0644'
+ notify: Reload jellyfin
+
+- name: Check if Jellyfin LaunchAgent is loaded
+ ansible.builtin.command: launchctl list mcquack.jellyfin
+ register: jellyfin_launchctl_check
+ changed_when: false
+ failed_when: false
+
+- name: Load Jellyfin LaunchAgent if not loaded
+ ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.jellyfin.plist
+ when: jellyfin_launchctl_check.rc != 0
+ changed_when: true
+ failed_when: false
diff --git a/ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2 b/ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2
new file mode 100644
index 0000000..e39e028
--- /dev/null
+++ b/ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Label
+ mcquack.jellyfin
+ EnvironmentVariables
+
+ PATH
+ /opt/homebrew/bin:/usr/bin:/bin
+
+ ProgramArguments
+
+ {{ jellyfin_binary }}
+ --service
+ --datadir
+ {{ jellyfin_data_dir }}
+ --webdir
+ {{ jellyfin_webdir }}
+
+ WorkingDirectory
+ {{ jellyfin_data_dir }}
+ RunAtLoad
+
+ KeepAlive
+
+ StandardErrorPath
+ {{ jellyfin_log_dir }}/mcquack.jellyfin.err.log
+ StandardOutPath
+ {{ jellyfin_log_dir }}/mcquack.jellyfin.out.log
+
+
diff --git a/ansible/roles/jellyfin_metrics/defaults/main.yml b/ansible/roles/jellyfin_metrics/defaults/main.yml
new file mode 100644
index 0000000..8b3e8f1
--- /dev/null
+++ b/ansible/roles/jellyfin_metrics/defaults/main.yml
@@ -0,0 +1,20 @@
+---
+# Jellyfin metrics collection configuration
+
+# Jellyfin server URL
+jellyfin_metrics_url: "http://localhost:8096"
+
+# Path to file containing Jellyfin API key (should have 600 permissions)
+jellyfin_metrics_api_key_file: "/Users/erichblume/.jellyfin-api-key"
+
+# Metrics collection interval in seconds
+jellyfin_metrics_interval: 60
+
+# Output directory for prometheus textfile collector
+jellyfin_metrics_dir: /opt/homebrew/var/node_exporter/textfile
+
+# Script installation path
+jellyfin_metrics_script: /Users/erichblume/.local/bin/jellyfin-metrics
+
+# Log directory for metrics script output
+jellyfin_metrics_log_dir: /opt/homebrew/var/log
diff --git a/ansible/roles/jellyfin_metrics/handlers/main.yml b/ansible/roles/jellyfin_metrics/handlers/main.yml
new file mode 100644
index 0000000..8921fe3
--- /dev/null
+++ b/ansible/roles/jellyfin_metrics/handlers/main.yml
@@ -0,0 +1,6 @@
+---
+- name: Reload jellyfin-metrics
+ ansible.builtin.shell: |
+ launchctl unload ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist 2>/dev/null || true
+ launchctl load ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist
+ changed_when: true
diff --git a/ansible/roles/jellyfin_metrics/tasks/main.yml b/ansible/roles/jellyfin_metrics/tasks/main.yml
new file mode 100644
index 0000000..8cbe412
--- /dev/null
+++ b/ansible/roles/jellyfin_metrics/tasks/main.yml
@@ -0,0 +1,55 @@
+---
+- name: Fetch Jellyfin API key (when running with --tags jellyfin_metrics)
+ ansible.builtin.command:
+ cmd: op --vault vg6xf6vvfmoh5hqjjhlhbeoaie item get ceywxkcd3z7najsy2nmmbs2vke --fields credential --reveal
+ delegate_to: localhost
+ register: jellyfin_metrics_api_key_fallback
+ changed_when: false
+ no_log: true
+ check_mode: false
+ when: jellyfin_metrics_api_key is not defined
+
+- name: Set Jellyfin API key fact (fallback)
+ ansible.builtin.set_fact:
+ jellyfin_metrics_api_key: "{{ jellyfin_metrics_api_key_fallback.stdout }}"
+ no_log: true
+ when: jellyfin_metrics_api_key is not defined
+
+- name: Write Jellyfin API key file
+ ansible.builtin.copy:
+ content: "{{ jellyfin_metrics_api_key }}"
+ dest: "{{ jellyfin_metrics_api_key_file }}"
+ mode: '0600'
+ no_log: true
+
+- name: Ensure bin directory exists
+ ansible.builtin.file:
+ path: "{{ jellyfin_metrics_script | dirname }}"
+ state: directory
+ mode: '0755'
+
+- name: Deploy jellyfin metrics collection script
+ ansible.builtin.template:
+ src: jellyfin-metrics.sh.j2
+ dest: "{{ jellyfin_metrics_script }}"
+ mode: '0755'
+ notify: Reload jellyfin-metrics
+
+- name: Deploy jellyfin-metrics LaunchAgent plist
+ ansible.builtin.template:
+ src: jellyfin-metrics.plist.j2
+ dest: ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist
+ mode: '0644'
+ notify: Reload jellyfin-metrics
+
+- name: Check if jellyfin-metrics LaunchAgent is loaded
+ ansible.builtin.command: launchctl list mcquack.eblume.jellyfin-metrics
+ register: jellyfin_metrics_launchctl_check
+ changed_when: false
+ failed_when: false
+
+- name: Load jellyfin-metrics LaunchAgent if not loaded
+ ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.jellyfin-metrics.plist
+ when: jellyfin_metrics_launchctl_check.rc != 0
+ changed_when: true
+ failed_when: false
diff --git a/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.plist.j2 b/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.plist.j2
new file mode 100644
index 0000000..ead3c54
--- /dev/null
+++ b/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.plist.j2
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Label
+ mcquack.eblume.jellyfin-metrics
+ EnvironmentVariables
+
+ PATH
+ /opt/homebrew/bin:/usr/bin:/bin
+
+ ProgramArguments
+
+ {{ jellyfin_metrics_script }}
+
+ StartInterval
+ {{ jellyfin_metrics_interval }}
+ RunAtLoad
+
+ StandardErrorPath
+ {{ jellyfin_metrics_log_dir }}/jellyfin-metrics.err.log
+ StandardOutPath
+ {{ jellyfin_metrics_log_dir }}/jellyfin-metrics.out.log
+
+
diff --git a/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2 b/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2
new file mode 100644
index 0000000..0f8b0f5
--- /dev/null
+++ b/ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2
@@ -0,0 +1,137 @@
+#!/bin/bash
+# {{ ansible_managed }}
+# Collects Jellyfin Media Server metrics for node_exporter textfile collector
+
+set -euo pipefail
+
+JELLYFIN_URL="{{ jellyfin_metrics_url }}"
+API_KEY_FILE="{{ jellyfin_metrics_api_key_file }}"
+OUTPUT_FILE="{{ jellyfin_metrics_dir }}/jellyfin.prom"
+TEMP_FILE="${OUTPUT_FILE}.tmp"
+
+# Read API key from file
+get_api_key() {
+ if [ -f "$API_KEY_FILE" ]; then
+ cat "$API_KEY_FILE" | tr -d '\n'
+ else
+ echo ""
+ fi
+}
+
+# Make API request with optional API key
+api_request() {
+ local endpoint="$1"
+ local use_auth="${2:-true}"
+ local api_key
+ local url="${JELLYFIN_URL}${endpoint}"
+
+ if [ "$use_auth" = "true" ]; then
+ api_key=$(get_api_key)
+ if [ -n "$api_key" ]; then
+ curl -s -H "Accept: application/json" -H "X-Emby-Token: $api_key" "$url" 2>/dev/null
+ else
+ curl -s -H "Accept: application/json" "$url" 2>/dev/null
+ fi
+ else
+ curl -s -H "Accept: application/json" "$url" 2>/dev/null
+ fi
+}
+
+# Initialize metrics
+jellyfin_up=0
+jellyfin_version=""
+jellyfin_sessions_total=0
+jellyfin_sessions_playing=0
+jellyfin_sessions_paused=0
+jellyfin_transcode_sessions_total=0
+
+# Library metrics will be built dynamically
+library_metrics=""
+
+# Check server health (no auth required)
+health=$(api_request "/health" false)
+if [ "$health" = "Healthy" ]; then
+ jellyfin_up=1
+fi
+
+# Get system info for version (requires auth)
+if [ "$jellyfin_up" -eq 1 ] && [ -f "$API_KEY_FILE" ]; then
+ system_info=$(api_request "/System/Info")
+ if [ -n "$system_info" ]; then
+ jellyfin_version=$(echo "$system_info" | jq -r '.Version // ""')
+ fi
+
+ # Get library counts (virtual folders)
+ libraries=$(api_request "/Library/VirtualFolders")
+ if [ -n "$libraries" ] && echo "$libraries" | jq -e '.' > /dev/null 2>&1; then
+ # Process each library
+ while IFS=$'\t' read -r lib_name lib_type lib_id; do
+ if [ -n "$lib_name" ] && [ -n "$lib_type" ]; then
+ # Get item count for this library
+ # Map collection type to item type for counting
+ case "$lib_type" in
+ movies) item_type="Movie" ;;
+ tvshows) item_type="Series" ;;
+ music) item_type="MusicAlbum" ;;
+ *) item_type="" ;;
+ esac
+
+ if [ -n "$item_type" ] && [ -n "$lib_id" ]; then
+ items=$(api_request "/Items?parentId=${lib_id}&recursive=true&includeItemTypes=${item_type}&limit=0")
+ item_count=$(echo "$items" | jq -r '.TotalRecordCount // 0' 2>/dev/null || echo "0")
+ library_metrics="${library_metrics}jellyfin_library_items{library=\"${lib_name}\",type=\"${lib_type}\"} ${item_count}
+"
+ fi
+ fi
+ done < <(echo "$libraries" | jq -r '.[] | [.Name, .CollectionType, .ItemId] | @tsv' 2>/dev/null || true)
+ fi
+
+ # Get active sessions
+ sessions=$(api_request "/Sessions")
+ if [ -n "$sessions" ] && echo "$sessions" | jq -e '.' > /dev/null 2>&1; then
+ jellyfin_sessions_total=$(echo "$sessions" | jq -r 'length')
+
+ # Count playing sessions (NowPlayingItem is present and IsPaused is false)
+ jellyfin_sessions_playing=$(echo "$sessions" | jq -r '[.[] | select(.NowPlayingItem != null and .PlayState.IsPaused == false)] | length')
+
+ # Count paused sessions
+ jellyfin_sessions_paused=$(echo "$sessions" | jq -r '[.[] | select(.NowPlayingItem != null and .PlayState.IsPaused == true)] | length')
+
+ # Count transcode sessions (TranscodingInfo is present)
+ jellyfin_transcode_sessions_total=$(echo "$sessions" | jq -r '[.[] | select(.TranscodingInfo != null)] | length')
+ fi
+fi
+
+# Write metrics
+cat > "$TEMP_FILE" << EOF
+# HELP jellyfin_up Jellyfin Media Server is up and responding
+# TYPE jellyfin_up gauge
+jellyfin_up ${jellyfin_up}
+
+# HELP jellyfin_version_info Jellyfin Media Server version information
+# TYPE jellyfin_version_info gauge
+jellyfin_version_info{version="${jellyfin_version}"} 1
+
+# HELP jellyfin_sessions_total Total number of active Jellyfin sessions
+# TYPE jellyfin_sessions_total gauge
+jellyfin_sessions_total ${jellyfin_sessions_total}
+
+# HELP jellyfin_sessions_playing Number of sessions currently playing
+# TYPE jellyfin_sessions_playing gauge
+jellyfin_sessions_playing ${jellyfin_sessions_playing}
+
+# HELP jellyfin_sessions_paused Number of sessions currently paused
+# TYPE jellyfin_sessions_paused gauge
+jellyfin_sessions_paused ${jellyfin_sessions_paused}
+
+# HELP jellyfin_transcode_sessions_total Number of sessions being transcoded
+# TYPE jellyfin_transcode_sessions_total gauge
+jellyfin_transcode_sessions_total ${jellyfin_transcode_sessions_total}
+
+# HELP jellyfin_library_items Number of items in each Jellyfin library
+# TYPE jellyfin_library_items gauge
+${library_metrics}
+EOF
+
+# Atomic move
+mv "$TEMP_FILE" "$OUTPUT_FILE"
diff --git a/argocd/manifests/grafana-config/dashboards/configmap-jellyfin.yaml b/argocd/manifests/grafana-config/dashboards/configmap-jellyfin.yaml
new file mode 100644
index 0000000..4208c8c
--- /dev/null
+++ b/argocd/manifests/grafana-config/dashboards/configmap-jellyfin.yaml
@@ -0,0 +1,634 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: grafana-dashboard-jellyfin
+ namespace: monitoring
+ labels:
+ grafana_dashboard: "1"
+data:
+ jellyfin.json: |
+ {
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [
+ {
+ "options": {
+ "0": { "color": "red", "index": 0, "text": "DOWN" }
+ },
+ "type": "value"
+ },
+ {
+ "options": {
+ "1": { "color": "green", "index": 1, "text": "UP" }
+ },
+ "type": "value"
+ }
+ ],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "red", "value": null },
+ { "color": "green", "value": 1 }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 },
+ "id": 1,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "jellyfin_up",
+ "refId": "A"
+ }
+ ],
+ "title": "Jellyfin Status",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [{ "color": "blue", "value": null }]
+ },
+ "unit": "string"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 6, "x": 4, "y": 0 },
+ "id": 2,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "/^version$/",
+ "values": false
+ },
+ "textMode": "value"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "jellyfin_version_info",
+ "format": "table",
+ "instant": true,
+ "refId": "A"
+ }
+ ],
+ "title": "Version",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "yellow", "value": 3 },
+ { "color": "orange", "value": 5 }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 4, "x": 10, "y": 0 },
+ "id": 3,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "jellyfin_sessions_total",
+ "refId": "A"
+ }
+ ],
+ "title": "Active Sessions",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "yellow", "value": 1 },
+ { "color": "orange", "value": 2 }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 4, "x": 14, "y": 0 },
+ "id": 4,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "jellyfin_transcode_sessions_total",
+ "refId": "A"
+ }
+ ],
+ "title": "Transcoding",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [{ "color": "purple", "value": null }]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
+ "id": 5,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "count(jellyfin_library_items)",
+ "refId": "A"
+ }
+ ],
+ "title": "Libraries",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "fixedColor": "blue",
+ "mode": "fixed"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [{ "color": "blue", "value": null }]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 8, "x": 0, "y": 4 },
+ "id": 6,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum(jellyfin_library_items{type=\"movies\"})",
+ "refId": "A"
+ }
+ ],
+ "title": "Movies",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "fixedColor": "green",
+ "mode": "fixed"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [{ "color": "green", "value": null }]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 8, "x": 8, "y": 4 },
+ "id": 7,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum(jellyfin_library_items{type=\"tvshows\"})",
+ "refId": "A"
+ }
+ ],
+ "title": "TV Shows",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "fixedColor": "orange",
+ "mode": "fixed"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [{ "color": "orange", "value": null }]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": { "h": 4, "w": 8, "x": 16, "y": 4 },
+ "id": 8,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "sum(jellyfin_library_items{type=\"music\"})",
+ "refId": "A"
+ }
+ ],
+ "title": "Music Albums",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisBorderShow": false,
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "insertNulls": false,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [{ "color": "green", "value": null }]
+ },
+ "unit": "short"
+ },
+ "overrides": [
+ {
+ "matcher": { "id": "byName", "options": "Playing" },
+ "properties": [
+ {
+ "id": "color",
+ "value": { "fixedColor": "green", "mode": "fixed" }
+ }
+ ]
+ },
+ {
+ "matcher": { "id": "byName", "options": "Paused" },
+ "properties": [
+ {
+ "id": "color",
+ "value": { "fixedColor": "yellow", "mode": "fixed" }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
+ "id": 9,
+ "options": {
+ "legend": {
+ "calcs": ["mean", "max"],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "jellyfin_sessions_playing",
+ "legendFormat": "Playing",
+ "refId": "A"
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "jellyfin_sessions_paused",
+ "legendFormat": "Paused",
+ "refId": "B"
+ }
+ ],
+ "title": "Sessions Over Time",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisBorderShow": false,
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "insertNulls": false,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "auto",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [{ "color": "green", "value": null }]
+ },
+ "unit": "short"
+ },
+ "overrides": [
+ {
+ "matcher": { "id": "byName", "options": "Transcode Sessions" },
+ "properties": [
+ {
+ "id": "color",
+ "value": { "fixedColor": "red", "mode": "fixed" }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
+ "id": 10,
+ "options": {
+ "legend": {
+ "calcs": ["mean", "max"],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "10.0.0",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "expr": "jellyfin_transcode_sessions_total",
+ "legendFormat": "Transcode Sessions",
+ "refId": "A"
+ }
+ ],
+ "title": "Transcode Sessions",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "loki"
+ },
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
+ "id": 11,
+ "options": {
+ "dedupStrategy": "none",
+ "enableLogDetails": true,
+ "prettifyLogMessage": false,
+ "showCommonLabels": false,
+ "showLabels": false,
+ "showTime": true,
+ "sortOrder": "Descending",
+ "wrapLogMessage": false
+ },
+ "targets": [
+ {
+ "datasource": { "type": "loki", "uid": "loki" },
+ "expr": "{service=\"jellyfin\"}",
+ "refId": "A"
+ }
+ ],
+ "title": "Jellyfin Logs",
+ "type": "logs"
+ }
+ ],
+ "refresh": "30s",
+ "schemaVersion": 38,
+ "tags": ["jellyfin", "media"],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "Jellyfin Media Server",
+ "uid": "jellyfin",
+ "version": 1,
+ "weekStart": ""
+ }
diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml
index 6a14e45..de76cbc 100644
--- a/argocd/manifests/grafana-config/kustomization.yaml
+++ b/argocd/manifests/grafana-config/kustomization.yaml
@@ -14,6 +14,7 @@ resources:
- dashboards/configmap-macos.yaml
- dashboards/configmap-minikube.yaml
- dashboards/configmap-plex.yaml
+ - dashboards/configmap-jellyfin.yaml
- dashboards/configmap-postgresql.yaml
- dashboards/configmap-services.yaml
- dashboards/configmap-zot.yaml
diff --git a/argocd/manifests/homepage/external-secret-jellyfin.yaml b/argocd/manifests/homepage/external-secret-jellyfin.yaml
new file mode 100644
index 0000000..0c365c3
--- /dev/null
+++ b/argocd/manifests/homepage/external-secret-jellyfin.yaml
@@ -0,0 +1,20 @@
+# ExternalSecret for Jellyfin API key
+# Used by Homepage Jellyfin widget
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+ name: homepage-jellyfin
+ namespace: homepage
+spec:
+ refreshInterval: 1h
+ secretStoreRef:
+ kind: ClusterSecretStore
+ name: onepassword-blumeops
+ target:
+ name: homepage-jellyfin
+ creationPolicy: Owner
+ data:
+ - secretKey: apikey
+ remoteRef:
+ key: jellyfin
+ property: credential
diff --git a/argocd/manifests/homepage/values.yaml b/argocd/manifests/homepage/values.yaml
index 355118b..3088f85 100644
--- a/argocd/manifests/homepage/values.yaml
+++ b/argocd/manifests/homepage/values.yaml
@@ -32,6 +32,11 @@ env:
secretKeyRef:
name: homepage-openweathermap
key: apikey
+ - name: HOMEPAGE_VAR_JELLYFIN_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: homepage-jellyfin
+ key: apikey
config:
# Host services (non-k8s, on indri or LAN)
@@ -77,6 +82,16 @@ config:
query: borgmatic_repo_deduplicated_size_bytes
format:
type: bytes
+ - Jellyfin:
+ href: https://jellyfin.ops.eblu.me
+ icon: jellyfin
+ description: Media server
+ widget:
+ type: jellyfin
+ url: https://jellyfin.ops.eblu.me
+ key: "{{HOMEPAGE_VAR_JELLYFIN_API_KEY}}"
+ enableBlocks: true
+ enableNowPlaying: true
# External bookmarks
bookmarks: