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: