From 77b34ec523257235decff0809e93f4a6efb70d1c Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 15 Jan 2026 15:10:20 -0800 Subject: [PATCH 1/3] Add Plex Media Server observability (metrics, logs, dashboard) - Create plex_metrics ansible role with textfile collector script - Add Plex log collection to Alloy configuration - Add Grafana dashboard for Plex server monitoring - Update indri.yml playbook to include plex_metrics role Metrics collected: - plex_up (server health) - plex_version_info (server version) - plex_sessions_total/playing/paused (active sessions) - plex_transcode_sessions_total/video/audio (transcoding) - plex_library_items{library,type} (library counts) Requires Plex token stored at ~/.plex-token on indri. Co-Authored-By: Claude Opus 4.5 --- ansible/playbooks/indri.yml | 2 + ansible/roles/alloy/defaults/main.yml | 5 + ansible/roles/alloy/templates/config.alloy.j2 | 15 + .../roles/grafana/files/dashboards/plex.json | 679 ++++++++++++++++++ ansible/roles/plex_metrics/defaults/main.yml | 20 + ansible/roles/plex_metrics/handlers/main.yml | 5 + ansible/roles/plex_metrics/meta/main.yml | 3 + ansible/roles/plex_metrics/tasks/main.yml | 31 + .../templates/plex-metrics.plist.j2 | 26 + .../plex_metrics/templates/plex-metrics.sh.j2 | 165 +++++ 10 files changed, 951 insertions(+) create mode 100644 ansible/roles/grafana/files/dashboards/plex.json create mode 100644 ansible/roles/plex_metrics/defaults/main.yml create mode 100644 ansible/roles/plex_metrics/handlers/main.yml create mode 100644 ansible/roles/plex_metrics/meta/main.yml create mode 100644 ansible/roles/plex_metrics/tasks/main.yml create mode 100644 ansible/roles/plex_metrics/templates/plex-metrics.plist.j2 create mode 100644 ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 diff --git a/ansible/playbooks/indri.yml b/ansible/playbooks/indri.yml index 7d14d92..b6f8086 100644 --- a/ansible/playbooks/indri.yml +++ b/ansible/playbooks/indri.yml @@ -24,3 +24,5 @@ tags: devpi - role: devpi_metrics tags: devpi_metrics + - role: plex_metrics + tags: plex_metrics diff --git a/ansible/roles/alloy/defaults/main.yml b/ansible/roles/alloy/defaults/main.yml index d4b83a2..a81d0ef 100644 --- a/ansible/roles/alloy/defaults/main.yml +++ b/ansible/roles/alloy/defaults/main.yml @@ -61,5 +61,10 @@ alloy_mcquack_logs: service: borgmatic stream: stderr +alloy_plex_logs: + - path: /Users/erichblume/Library/Logs/Plex Media Server/Plex Media Server.log + service: plex + stream: stdout + # Enable log collection (requires Loki to be running) alloy_collect_logs: true diff --git a/ansible/roles/alloy/templates/config.alloy.j2 b/ansible/roles/alloy/templates/config.alloy.j2 index a830533..069d8d4 100644 --- a/ansible/roles/alloy/templates/config.alloy.j2 +++ b/ansible/roles/alloy/templates/config.alloy.j2 @@ -56,6 +56,15 @@ local.file_match "mcquack_logs" { ] } +// Discover log files - Plex Media Server +local.file_match "plex_logs" { + path_targets = [ +{% for log in alloy_plex_logs %} + {__path__ = "{{ log.path }}", service = "{{ log.service }}", stream = "{{ log.stream }}"}, +{% endfor %} + ] +} + // Read and forward brew service logs loki.source.file "brew_logs" { targets = local.file_match.brew_logs.targets @@ -68,6 +77,12 @@ loki.source.file "mcquack_logs" { forward_to = [loki.relabel.add_host.receiver] } +// Read and forward Plex logs +loki.source.file "plex_logs" { + targets = local.file_match.plex_logs.targets + forward_to = [loki.relabel.add_host.receiver] +} + // Add host label to all logs loki.relabel "add_host" { forward_to = [loki.write.loki.receiver] diff --git a/ansible/roles/grafana/files/dashboards/plex.json b/ansible/roles/grafana/files/dashboards/plex.json new file mode 100644 index 0000000..9cd8fc5 --- /dev/null +++ b/ansible/roles/grafana/files/dashboards/plex.json @@ -0,0 +1,679 @@ +{ + "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": "plex_up", + "refId": "A" + } + ], + "title": "Plex 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": "plex_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": "plex_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": "plex_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(plex_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": 6, "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(plex_library_items{type=\"movie\"})", + "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": 6, "x": 6, "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(plex_library_items{type=\"show\"})", + "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": 6, "x": 12, "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(plex_library_items{type=\"artist\"})", + "refId": "A" + } + ], + "title": "Music Artists", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "purple", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "purple", "value": null} + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 6, "x": 18, "y": 4}, + "id": 9, + "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(plex_library_items{type=\"photo\"})", + "refId": "A" + } + ], + "title": "Photos", + "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": 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": "plex_sessions_playing", + "legendFormat": "Playing", + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "expr": "plex_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": "Video Transcode"}, + "properties": [ + {"id": "color", "value": {"fixedColor": "red", "mode": "fixed"}} + ] + }, + { + "matcher": {"id": "byName", "options": "Audio Transcode"}, + "properties": [ + {"id": "color", "value": {"fixedColor": "orange", "mode": "fixed"}} + ] + } + ] + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 8}, + "id": 11, + "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": "plex_transcode_video_sessions", + "legendFormat": "Video Transcode", + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "expr": "plex_transcode_audio_sessions", + "legendFormat": "Audio Transcode", + "refId": "B" + } + ], + "title": "Transcode Sessions", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": {"h": 8, "w": 24, "x": 0, "y": 16}, + "id": 12, + "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=\"plex\"}", + "refId": "A" + } + ], + "title": "Plex Logs", + "type": "logs" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["plex", "media"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Plex Media Server", + "uid": "plex", + "version": 1, + "weekStart": "" +} diff --git a/ansible/roles/plex_metrics/defaults/main.yml b/ansible/roles/plex_metrics/defaults/main.yml new file mode 100644 index 0000000..667f641 --- /dev/null +++ b/ansible/roles/plex_metrics/defaults/main.yml @@ -0,0 +1,20 @@ +--- +# Plex metrics collection configuration + +# Plex server URL +plex_url: "http://localhost:32400" + +# Path to file containing Plex token (should have 600 permissions) +plex_token_file: "/Users/erichblume/.plex-token" + +# Metrics collection interval in seconds +plex_metrics_interval: 60 + +# Output directory for prometheus textfile collector +plex_metrics_dir: /opt/homebrew/var/node_exporter/textfile + +# Script installation path +plex_metrics_script: /Users/erichblume/bin/plex-metrics + +# Log directory for metrics script output +plex_metrics_log_dir: /opt/homebrew/var/log diff --git a/ansible/roles/plex_metrics/handlers/main.yml b/ansible/roles/plex_metrics/handlers/main.yml new file mode 100644 index 0000000..128ecad --- /dev/null +++ b/ansible/roles/plex_metrics/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: reload plex-metrics + ansible.builtin.shell: | + launchctl unload ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist 2>/dev/null || true + launchctl load ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist diff --git a/ansible/roles/plex_metrics/meta/main.yml b/ansible/roles/plex_metrics/meta/main.yml new file mode 100644 index 0000000..2925213 --- /dev/null +++ b/ansible/roles/plex_metrics/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: alloy diff --git a/ansible/roles/plex_metrics/tasks/main.yml b/ansible/roles/plex_metrics/tasks/main.yml new file mode 100644 index 0000000..ea4be15 --- /dev/null +++ b/ansible/roles/plex_metrics/tasks/main.yml @@ -0,0 +1,31 @@ +--- +- name: Ensure bin directory exists + ansible.builtin.file: + path: "{{ plex_metrics_script | dirname }}" + state: directory + mode: '0755' + +- name: Deploy plex metrics collection script + ansible.builtin.template: + src: plex-metrics.sh.j2 + dest: "{{ plex_metrics_script }}" + mode: '0755' + notify: reload plex-metrics + +- name: Deploy plex-metrics LaunchAgent plist + ansible.builtin.template: + src: plex-metrics.plist.j2 + dest: ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist + mode: '0644' + notify: reload plex-metrics + +- name: Check if plex-metrics LaunchAgent is loaded + ansible.builtin.command: launchctl list mcquack.eblume.plex-metrics + register: launchctl_check + changed_when: false + failed_when: false + +- name: Load plex-metrics LaunchAgent if not loaded + ansible.builtin.command: launchctl load ~/Library/LaunchAgents/mcquack.eblume.plex-metrics.plist + when: launchctl_check.rc != 0 + failed_when: false diff --git a/ansible/roles/plex_metrics/templates/plex-metrics.plist.j2 b/ansible/roles/plex_metrics/templates/plex-metrics.plist.j2 new file mode 100644 index 0000000..836bee1 --- /dev/null +++ b/ansible/roles/plex_metrics/templates/plex-metrics.plist.j2 @@ -0,0 +1,26 @@ + + + + + + Label + mcquack.eblume.plex-metrics + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/bin:/bin + + ProgramArguments + + {{ plex_metrics_script }} + + StartInterval + {{ plex_metrics_interval }} + RunAtLoad + + StandardErrorPath + {{ plex_metrics_log_dir }}/plex-metrics.err.log + StandardOutPath + {{ plex_metrics_log_dir }}/plex-metrics.out.log + + diff --git a/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 new file mode 100644 index 0000000..5627c1d --- /dev/null +++ b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 @@ -0,0 +1,165 @@ +#!/bin/bash +# {{ ansible_managed }} +# Collects Plex Media Server metrics for node_exporter textfile collector + +set -euo pipefail + +PLEX_URL="{{ plex_url }}" +TOKEN_FILE="{{ plex_token_file }}" +OUTPUT_FILE="{{ plex_metrics_dir }}/plex.prom" +TEMP_FILE="${OUTPUT_FILE}.tmp" + +# Read token from file +get_token() { + if [ -f "$TOKEN_FILE" ]; then + cat "$TOKEN_FILE" | tr -d '\n' + else + echo "" + fi +} + +# Make API request with optional token +api_request() { + local endpoint="$1" + local use_token="${2:-true}" + local token + local url="${PLEX_URL}${endpoint}" + + if [ "$use_token" = "true" ]; then + token=$(get_token) + if [ -n "$token" ]; then + curl -s -H "Accept: application/json" -H "X-Plex-Token: $token" "$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 +} + +# Extract JSON string value: extract_json_string '{"foo":"bar"}' "foo" -> bar +extract_json_string() { + echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"\\([^\"]*\\)\"/\\1/" +} + +# Extract JSON integer value: extract_json_int '{"foo":123}' "foo" -> 123 +extract_json_int() { + echo "$1" | grep -o "\"$2\":[0-9]*" | head -1 | sed "s/\"$2\"://" +} + +# Initialize metrics +plex_up=0 +plex_version="" +plex_sessions_total=0 +plex_sessions_playing=0 +plex_sessions_paused=0 +plex_transcode_sessions_total=0 +plex_transcode_video=0 +plex_transcode_audio=0 + +# Library metrics will be built dynamically +library_metrics="" + +# Check server identity (no auth required) +identity=$(api_request "/identity" false) +if echo "$identity" | grep -q '"machineIdentifier"'; then + plex_up=1 + plex_version=$(extract_json_string "$identity" "version") +fi + +# If server is up, get additional metrics (require auth) +if [ "$plex_up" -eq 1 ] && [ -f "$TOKEN_FILE" ]; then + # Get library sections + sections=$(api_request "/library/sections") + if echo "$sections" | grep -q '"Directory"'; then + # Extract library info - parse the Directory array + # Format: "Directory":[{"key":"1","type":"movie","title":"Movies",...},...] + directories=$(echo "$sections" | grep -o '"Directory":\[[^]]*\]' | sed 's/"Directory":\[//' | sed 's/\]$//') + + # Process each library entry + while IFS= read -r lib; do + if [ -n "$lib" ]; then + lib_key=$(extract_json_string "$lib" "key") + lib_type=$(extract_json_string "$lib" "type") + lib_title=$(extract_json_string "$lib" "title") + + if [ -n "$lib_key" ] && [ -n "$lib_type" ]; then + # Get library details for item count + lib_detail=$(api_request "/library/sections/${lib_key}/all?X-Plex-Container-Start=0&X-Plex-Container-Size=0") + lib_size=$(extract_json_int "$lib_detail" "totalSize") + if [ -z "$lib_size" ]; then + lib_size=$(extract_json_int "$lib_detail" "size") + fi + lib_size=${lib_size:-0} + + # Sanitize title for prometheus label (replace spaces/special chars) + safe_title=$(echo "$lib_title" | sed 's/[^a-zA-Z0-9]/_/g') + + library_metrics="${library_metrics}plex_library_items{library=\"${lib_title}\",type=\"${lib_type}\"} ${lib_size} +" + fi + fi + done <<< "$(echo "$directories" | sed 's/},{/}\n{/g')" + fi + + # Get active sessions + sessions=$(api_request "/status/sessions") + if echo "$sessions" | grep -q '"MediaContainer"'; then + plex_sessions_total=$(extract_json_int "$sessions" "size") + plex_sessions_total=${plex_sessions_total:-0} + + # Count playing vs paused by looking for Player state + if [ "$plex_sessions_total" -gt 0 ]; then + plex_sessions_playing=$(echo "$sessions" | grep -o '"state":"playing"' | wc -l | tr -d ' ') + plex_sessions_paused=$(echo "$sessions" | grep -o '"state":"paused"' | wc -l | tr -d ' ') + + # Count transcode sessions + plex_transcode_video=$(echo "$sessions" | grep -o '"videoDecision":"transcode"' | wc -l | tr -d ' ') + plex_transcode_audio=$(echo "$sessions" | grep -o '"audioDecision":"transcode"' | wc -l | tr -d ' ') + + # Total transcode is sessions with any transcoding + plex_transcode_sessions_total=$(echo "$sessions" | grep -o '"transcodeSession"' | wc -l | tr -d ' ') + fi + fi +fi + +# Write metrics +cat > "$TEMP_FILE" << EOF +# HELP plex_up Plex Media Server is up and responding +# TYPE plex_up gauge +plex_up ${plex_up} + +# HELP plex_version_info Plex Media Server version information +# TYPE plex_version_info gauge +plex_version_info{version="${plex_version}"} 1 + +# HELP plex_sessions_total Total number of active Plex sessions +# TYPE plex_sessions_total gauge +plex_sessions_total ${plex_sessions_total} + +# HELP plex_sessions_playing Number of sessions currently playing +# TYPE plex_sessions_playing gauge +plex_sessions_playing ${plex_sessions_playing} + +# HELP plex_sessions_paused Number of sessions currently paused +# TYPE plex_sessions_paused gauge +plex_sessions_paused ${plex_sessions_paused} + +# HELP plex_transcode_sessions_total Number of sessions being transcoded +# TYPE plex_transcode_sessions_total gauge +plex_transcode_sessions_total ${plex_transcode_sessions_total} + +# HELP plex_transcode_video_sessions Number of sessions transcoding video +# TYPE plex_transcode_video_sessions gauge +plex_transcode_video_sessions ${plex_transcode_video} + +# HELP plex_transcode_audio_sessions Number of sessions transcoding audio +# TYPE plex_transcode_audio_sessions gauge +plex_transcode_audio_sessions ${plex_transcode_audio} + +# HELP plex_library_items Number of items in each Plex library +# TYPE plex_library_items gauge +${library_metrics}EOF + +# Atomic move +mv "$TEMP_FILE" "$OUTPUT_FILE" -- 2.50.1 (Apple Git-155) From 9d2d744f34703192b97086099a26accc7d91ad41 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 15 Jan 2026 15:22:26 -0800 Subject: [PATCH 2/3] Fix heredoc EOF marker in plex-metrics script The EOF marker was on the same line as the library_metrics variable, causing the heredoc to not close properly. Co-Authored-By: Claude Opus 4.5 --- ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 index 5627c1d..1c95850 100644 --- a/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 +++ b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 @@ -159,7 +159,8 @@ plex_transcode_audio_sessions ${plex_transcode_audio} # HELP plex_library_items Number of items in each Plex library # TYPE plex_library_items gauge -${library_metrics}EOF +${library_metrics} +EOF # Atomic move mv "$TEMP_FILE" "$OUTPUT_FILE" -- 2.50.1 (Apple Git-155) From 026026572d42609167063224abcecf5df1a16d17 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 15 Jan 2026 15:26:56 -0800 Subject: [PATCH 3/3] Rewrite plex-metrics to use jq for JSON parsing The grep-based JSON parsing was fragile and broke on nested arrays (like Location inside Directory). Using jq properly handles the JSON structure. Co-Authored-By: Claude Opus 4.5 --- .../plex_metrics/templates/plex-metrics.sh.j2 | 73 +++++-------------- 1 file changed, 20 insertions(+), 53 deletions(-) diff --git a/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 index 1c95850..e2f8b2e 100644 --- a/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 +++ b/ansible/roles/plex_metrics/templates/plex-metrics.sh.j2 @@ -37,16 +37,6 @@ api_request() { fi } -# Extract JSON string value: extract_json_string '{"foo":"bar"}' "foo" -> bar -extract_json_string() { - echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"\\([^\"]*\\)\"/\\1/" -} - -# Extract JSON integer value: extract_json_int '{"foo":123}' "foo" -> 123 -extract_json_int() { - echo "$1" | grep -o "\"$2\":[0-9]*" | head -1 | sed "s/\"$2\"://" -} - # Initialize metrics plex_up=0 plex_version="" @@ -62,64 +52,41 @@ library_metrics="" # Check server identity (no auth required) identity=$(api_request "/identity" false) -if echo "$identity" | grep -q '"machineIdentifier"'; then +if echo "$identity" | jq -e '.MediaContainer.machineIdentifier' > /dev/null 2>&1; then plex_up=1 - plex_version=$(extract_json_string "$identity" "version") + plex_version=$(echo "$identity" | jq -r '.MediaContainer.version // ""') fi # If server is up, get additional metrics (require auth) if [ "$plex_up" -eq 1 ] && [ -f "$TOKEN_FILE" ]; then # Get library sections sections=$(api_request "/library/sections") - if echo "$sections" | grep -q '"Directory"'; then - # Extract library info - parse the Directory array - # Format: "Directory":[{"key":"1","type":"movie","title":"Movies",...},...] - directories=$(echo "$sections" | grep -o '"Directory":\[[^]]*\]' | sed 's/"Directory":\[//' | sed 's/\]$//') - # Process each library entry - while IFS= read -r lib; do - if [ -n "$lib" ]; then - lib_key=$(extract_json_string "$lib" "key") - lib_type=$(extract_json_string "$lib" "type") - lib_title=$(extract_json_string "$lib" "title") + # Process each library using jq + while IFS=$'\t' read -r lib_key lib_type lib_title; do + if [ -n "$lib_key" ] && [ -n "$lib_type" ]; then + # Get library details for item count + lib_detail=$(api_request "/library/sections/${lib_key}/all?X-Plex-Container-Start=0&X-Plex-Container-Size=0") + lib_size=$(echo "$lib_detail" | jq -r '.MediaContainer.totalSize // .MediaContainer.size // 0') - if [ -n "$lib_key" ] && [ -n "$lib_type" ]; then - # Get library details for item count - lib_detail=$(api_request "/library/sections/${lib_key}/all?X-Plex-Container-Start=0&X-Plex-Container-Size=0") - lib_size=$(extract_json_int "$lib_detail" "totalSize") - if [ -z "$lib_size" ]; then - lib_size=$(extract_json_int "$lib_detail" "size") - fi - lib_size=${lib_size:-0} - - # Sanitize title for prometheus label (replace spaces/special chars) - safe_title=$(echo "$lib_title" | sed 's/[^a-zA-Z0-9]/_/g') - - library_metrics="${library_metrics}plex_library_items{library=\"${lib_title}\",type=\"${lib_type}\"} ${lib_size} + library_metrics="${library_metrics}plex_library_items{library=\"${lib_title}\",type=\"${lib_type}\"} ${lib_size} " - fi - fi - done <<< "$(echo "$directories" | sed 's/},{/}\n{/g')" - fi + fi + done < <(echo "$sections" | jq -r '.MediaContainer.Directory[] | [.key, .type, .title] | @tsv' 2>/dev/null || true) # Get active sessions sessions=$(api_request "/status/sessions") - if echo "$sessions" | grep -q '"MediaContainer"'; then - plex_sessions_total=$(extract_json_int "$sessions" "size") - plex_sessions_total=${plex_sessions_total:-0} + if echo "$sessions" | jq -e '.MediaContainer' > /dev/null 2>&1; then + plex_sessions_total=$(echo "$sessions" | jq -r '.MediaContainer.size // 0') - # Count playing vs paused by looking for Player state - if [ "$plex_sessions_total" -gt 0 ]; then - plex_sessions_playing=$(echo "$sessions" | grep -o '"state":"playing"' | wc -l | tr -d ' ') - plex_sessions_paused=$(echo "$sessions" | grep -o '"state":"paused"' | wc -l | tr -d ' ') + # Count playing vs paused + plex_sessions_playing=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.Player.state == "playing")] | length') + plex_sessions_paused=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.Player.state == "paused")] | length') - # Count transcode sessions - plex_transcode_video=$(echo "$sessions" | grep -o '"videoDecision":"transcode"' | wc -l | tr -d ' ') - plex_transcode_audio=$(echo "$sessions" | grep -o '"audioDecision":"transcode"' | wc -l | tr -d ' ') - - # Total transcode is sessions with any transcoding - plex_transcode_sessions_total=$(echo "$sessions" | grep -o '"transcodeSession"' | wc -l | tr -d ' ') - fi + # Count transcode sessions + plex_transcode_video=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.TranscodeSession.videoDecision == "transcode")] | length') + plex_transcode_audio=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.TranscodeSession.audioDecision == "transcode")] | length') + plex_transcode_sessions_total=$(echo "$sessions" | jq -r '[.MediaContainer.Metadata[]? | select(.TranscodeSession)] | length') fi fi -- 2.50.1 (Apple Git-155)