Add Jellyfin media server deployment (#77)
## Summary - Add Jellyfin ansible role for native macOS deployment via Homebrew cask - Add jellyfin_metrics role for Prometheus textfile metrics collection - Add Caddy routing for jellyfin.ops.eblu.me - Add Alloy log collection for Jellyfin stdout/stderr - Add Grafana dashboard for Jellyfin monitoring ## Architecture Jellyfin runs natively on indri (not in k8s) for full VideoToolbox hardware transcoding support. The M1 Mac Mini can handle ~3 concurrent 4K HDR→SDR transcoding streams. ## Deployment and Testing - [ ] Deploy Jellyfin: `mise run provision-indri -- --tags jellyfin,jellyfin_metrics,caddy,alloy` - [ ] Sync Grafana dashboard: `argocd app sync grafana-config` - [ ] Complete Jellyfin setup wizard at https://jellyfin.ops.eblu.me - [ ] Generate API key and save to `~/.jellyfin-api-key` - [ ] Add media libraries (/Volumes/allisonflix/Movies, /Volumes/allisonflix/TV) - [ ] Enable VideoToolbox hardware transcoding - [ ] Verify metrics in Grafana dashboard - [ ] Verify logs in Loki: `{service="jellyfin"}` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/77
This commit is contained in:
parent
23b8897c1f
commit
bcc8685316
16 changed files with 1036 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
ansible/roles/jellyfin/defaults/main.yml
Normal file
23
ansible/roles/jellyfin/defaults/main.yml
Normal file
|
|
@ -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"
|
||||
6
ansible/roles/jellyfin/handlers/main.yml
Normal file
6
ansible/roles/jellyfin/handlers/main.yml
Normal file
|
|
@ -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
|
||||
30
ansible/roles/jellyfin/tasks/main.yml
Normal file
30
ansible/roles/jellyfin/tasks/main.yml
Normal file
|
|
@ -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
|
||||
33
ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2
Normal file
33
ansible/roles/jellyfin/templates/mcquack.jellyfin.plist.j2
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- {{ ansible_managed }} -->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>mcquack.jellyfin</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{ jellyfin_binary }}</string>
|
||||
<string>--service</string>
|
||||
<string>--datadir</string>
|
||||
<string>{{ jellyfin_data_dir }}</string>
|
||||
<string>--webdir</string>
|
||||
<string>{{ jellyfin_webdir }}</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{{ jellyfin_data_dir }}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{ jellyfin_log_dir }}/mcquack.jellyfin.err.log</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{ jellyfin_log_dir }}/mcquack.jellyfin.out.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
20
ansible/roles/jellyfin_metrics/defaults/main.yml
Normal file
20
ansible/roles/jellyfin_metrics/defaults/main.yml
Normal file
|
|
@ -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
|
||||
6
ansible/roles/jellyfin_metrics/handlers/main.yml
Normal file
6
ansible/roles/jellyfin_metrics/handlers/main.yml
Normal file
|
|
@ -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
|
||||
55
ansible/roles/jellyfin_metrics/tasks/main.yml
Normal file
55
ansible/roles/jellyfin_metrics/tasks/main.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- {{ ansible_managed }} -->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>mcquack.eblume.jellyfin-metrics</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{{ jellyfin_metrics_script }}</string>
|
||||
</array>
|
||||
<key>StartInterval</key>
|
||||
<integer>{{ jellyfin_metrics_interval }}</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{ jellyfin_metrics_log_dir }}/jellyfin-metrics.err.log</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{ jellyfin_metrics_log_dir }}/jellyfin-metrics.out.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
137
ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2
Normal file
137
ansible/roles/jellyfin_metrics/templates/jellyfin-metrics.sh.j2
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue