Last active
March 16, 2025 01:56
-
-
Save dfsnow/aad4ec99afb413968c49efb03bdb1ab9 to your computer and use it in GitHub Desktop.
Export Jellyfin playback statistics to Prometheus and Grafana. See https://sno.ws/jellyfin-stats for more info
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
modules: | |
jellyfin: | |
headers: | |
# The Token value here needs to be an API key generated from the | |
# Jellyfin admin panel. It's hard-coded here but I'm sure there's | |
# a better way | |
Authorization: MediaBrowser Token=ADD_TOKEN_HERE | |
Content-Type: application/json | |
accept: application/json | |
# This will return all active sessions regardless of | |
# whether something is playing. You can use a combination | |
# of label and value filters in Grafana to only get actively | |
# playing sessions | |
metrics: | |
- name: jellyfin | |
type: object | |
help: User playback metrics from Jellyfin | |
# Only look at sessions with a NowPlayingItem key, per @abe-kanofsky | |
path: '{ [?(@.NowPlayingItem)] }' | |
labels: | |
user_name: '{ .UserName }' | |
# User PromQL label_join and label_replace to concatenate | |
# these values into a nice item description | |
item_type: '{ .NowPlayingItem.Type }' | |
item_name: '{ .NowPlayingItem.Name }' | |
item_path: '{ .NowPlayingItem.Path }' | |
series_name: '{ .NowPlayingItem.SeriesName }' | |
episode_index: 'e{ .NowPlayingItem.IndexNumber }' | |
season_index: 's{ .NowPlayingItem.ParentIndexNumber }' | |
client_name: '{ .Client }' | |
device_name: '{ .DeviceName }' | |
# Include the unique session ID in case the above info isn't unique | |
session_id: '{ .Id }' | |
values: | |
is_paused: '{ .PlayState.IsPaused }' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
scrape_configs: | |
- job_name: json | |
metrics_path: /probe | |
params: | |
# The name of the module defined by json-exporter-config.yaml | |
module: [jellyfin] | |
static_configs: | |
- targets: | |
# Use the Sessions endpoint to see actively playing items | |
- https://jellyfin.example.com/Sessions | |
relabel_configs: | |
- source_labels: [__address__] | |
target_label: __param_target | |
- source_labels: [__param_target] | |
target_label: instance | |
- target_label: __address__ | |
replacement: HOSTNAME:9115 # The exporter's hostname:port |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"datasource": { | |
"type": "prometheus", | |
"uid": "D5GQA944k" | |
}, | |
"description": "", | |
"fieldConfig": { | |
"defaults": { | |
"custom": { | |
"lineWidth": 0, | |
"fillOpacity": 70, | |
"spanNulls": false, | |
"insertNulls": false, | |
"hideFrom": { | |
"tooltip": false, | |
"viz": false, | |
"legend": false | |
} | |
}, | |
"color": { | |
"mode": "continuous-GrYlRd" | |
}, | |
"mappings": [], | |
"thresholds": { | |
"mode": "absolute", | |
"steps": [ | |
{ | |
"color": "green", | |
"value": null | |
}, | |
{ | |
"color": "red", | |
"value": 80 | |
} | |
] | |
} | |
}, | |
"overrides": [] | |
}, | |
"gridPos": { | |
"h": 6, | |
"w": 13, | |
"x": 0, | |
"y": 9 | |
}, | |
"id": 348, | |
"options": { | |
"mergeValues": true, | |
"showValue": "auto", | |
"alignValue": "left", | |
"rowHeight": 0.9, | |
"legend": { | |
"showLegend": true, | |
"displayMode": "list", | |
"placement": "bottom" | |
}, | |
"tooltip": { | |
"mode": "single", | |
"sort": "none" | |
} | |
}, | |
"pluginVersion": "10.1.1", | |
"targets": [ | |
{ | |
"datasource": { | |
"type": "prometheus", | |
"uid": "D5GQA944k" | |
}, | |
"editorMode": "code", | |
"expr": "sum by (user_name, item_index_clean, item_type) (label_replace(label_join(label_join(jellyfin_is_paused{job=\"json\"}, \"item_index\", \"\", \"season_index\", \"episode_index\"), \"full_name\", \" - \", \"series_name\", \"item_index\", \"item_name\"), \"item_index_clean\", \"$1\", \"full_name\", \"^[- ]*(.*?)[- ]*$\"))", | |
"instant": false, | |
"legendFormat": "__auto", | |
"range": true, | |
"refId": "A" | |
} | |
], | |
"title": "Jellyfin", | |
"transformations": [ | |
{ | |
"id": "seriesToRows", | |
"options": {} | |
}, | |
{ | |
"id": "extractFields", | |
"options": { | |
"keepTime": false, | |
"replace": false, | |
"source": "Metric" | |
} | |
}, | |
{ | |
"id": "filterByValue", | |
"options": { | |
"filters": [ | |
{ | |
"config": { | |
"id": "isNotNull", | |
"options": {} | |
}, | |
"fieldName": "user_name" | |
} | |
], | |
"match": "any", | |
"type": "include" | |
} | |
}, | |
{ | |
"id": "calculateField", | |
"options": { | |
"alias": "Item", | |
"mode": "reduceRow", | |
"reduce": { | |
"include": [ | |
"item_index_clean" | |
], | |
"reducer": "firstNotNull" | |
} | |
} | |
}, | |
{ | |
"id": "calculateField", | |
"options": { | |
"alias": "Name", | |
"mode": "reduceRow", | |
"reduce": { | |
"include": [ | |
"user_name" | |
], | |
"reducer": "firstNotNull" | |
} | |
} | |
}, | |
{ | |
"id": "calculateField", | |
"options": { | |
"alias": "Temp", | |
"mode": "reduceRow", | |
"reduce": { | |
"include": [ | |
"Item" | |
], | |
"reducer": "firstNotNull" | |
}, | |
"replaceFields": false | |
} | |
}, | |
{ | |
"id": "filterByValue", | |
"options": { | |
"filters": [ | |
{ | |
"config": { | |
"id": "regex", | |
"options": { | |
"value": "(Movie|Episode)" | |
} | |
}, | |
"fieldName": "item_type" | |
} | |
], | |
"match": "any", | |
"type": "include" | |
} | |
}, | |
{ | |
"id": "filterByValue", | |
"options": { | |
"filters": [ | |
{ | |
"config": { | |
"id": "notEqual", | |
"options": { | |
"value": 1 | |
} | |
}, | |
"fieldName": "Value" | |
} | |
], | |
"match": "any", | |
"type": "include" | |
} | |
}, | |
{ | |
"id": "partitionByValues", | |
"options": { | |
"fields": [ | |
"Name" | |
] | |
} | |
}, | |
{ | |
"id": "filterFieldsByName", | |
"options": { | |
"include": { | |
"pattern": "Temp|Time (.*)" | |
} | |
} | |
}, | |
{ | |
"id": "renameByRegex", | |
"options": { | |
"regex": "Temp (.*)", | |
"renamePattern": "$1" | |
} | |
} | |
], | |
"type": "state-timeline" | |
} |
Another thing: modules.jellyfin.metrics[0].path
should be '{ [?(@.NowPlayingItem)] }
to look at only sessions that actually have a NowPlayingItem
key. Otherwise, the exporter also tries to read sessions without that key and spits out a pretty verbose error each time it encounters one, causing the log file to blow up.
Nice @abe-kanofsky, these are excellent suggestions! I'll update the gist and blogpost to reflect them.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is super useful stuff!
Just wanna mention an issue I ran into with this setup. It seems that it's possible for Jellyfin to report a user as having more than one session with the same client, device, and
NowPlayingItem
. When this happens, the JSON exporter doesn't know what to do because it has no way to differentiate the two session JSON objects given the labels it's configured to look at. This causes all requests to theprobe
endpoint to return an error like this:I fixed this by adding
session_id: '{ .Id }'
to themodules.jellyfin.metrics[0].labels
object in the JSON exporter config which is enough to allow it to defferentiate between the different sessions, sinceId
is always unique even when the user, client, device, andNowPlayingItem
are the same. After making this change, I resarted the exporter and it worked normally again.