Skip to content

Instantly share code, notes, and snippets.

@dfsnow
Last active March 16, 2025 01:56
Show Gist options
  • Save dfsnow/aad4ec99afb413968c49efb03bdb1ab9 to your computer and use it in GitHub Desktop.
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
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 }'
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
{
"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"
}
@abe-kanofsky
Copy link

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.

@dfsnow
Copy link
Author

dfsnow commented Mar 16, 2025

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