feat(infra): add Jellyfin media server configuration and logging support

Add Jellyfin service to ansible inventory with hardware
transcoding and Casdoor SSO configuration. Configure
Alloy syslog listener to capture Jellyfin logs to Loki.
Update documentation with new service mapping and S3
bucket credential retrieval instructions.
This commit is contained in:
2026-05-04 15:33:25 -04:00
parent b9ce14ff77
commit f818b7917d
7 changed files with 392 additions and 6 deletions

View File

@@ -63,7 +63,7 @@ prometheus.scrape "hass" {
// Lobechat Docker syslog // Lobechat Docker syslog
loki.source.syslog "lobechat_logs" { loki.source.syslog "lobechat_logs" {
listener { listener {
address = "127.0.0.1:{{lobechat_syslog_port}}" address = "127.0.0.1:{{ lobechat_syslog_port }}"
protocol = "tcp" protocol = "tcp"
syslog_format = "{{ syslog_format }}" syslog_format = "{{ syslog_format }}"
labels = { labels = {
@@ -75,6 +75,21 @@ loki.source.syslog "lobechat_logs" {
forward_to = [loki.write.default.receiver] forward_to = [loki.write.default.receiver]
} }
// Jellyfin Docker syslog
loki.source.syslog "jellyfin_logs" {
listener {
address = "127.0.0.1:{{ jellyfin_syslog_port }}"
protocol = "tcp"
syslog_format = "{{ syslog_format }}"
labels = {
job = "jellyfin",
hostname = "{{inventory_hostname}}",
environment = "{{deployment_environment}}",
}
}
forward_to = [loki.write.default.receiver]
}
loki.source.syslog "searxng_logs" { loki.source.syslog "searxng_logs" {
listener { listener {
address = "127.0.0.1:{{searxng_syslog_port}}" address = "127.0.0.1:{{searxng_syslog_port}}"

View File

@@ -7,6 +7,7 @@ services:
- anythingllm - anythingllm
- docker - docker
- gitea - gitea
- jellyfin
- lobechat - lobechat
- memcached - memcached
- nextcloud - nextcloud
@@ -236,4 +237,31 @@ searxng_oauth2_redirect_url: "https://searxng.ouranos.helu.ca/oauth2/callback"
# OAuth2 Credentials (from vault) # OAuth2 Credentials (from vault)
searxng_oauth2_client_id: "{{ vault_searxng_oauth_client_id }}" searxng_oauth2_client_id: "{{ vault_searxng_oauth_client_id }}"
searxng_oauth2_client_secret: "{{ vault_searxng_oauth_client_secret }}" searxng_oauth2_client_secret: "{{ vault_searxng_oauth_client_secret }}"
searxng_oauth2_cookie_secret: "{{ vault_searxng_oauth_cookie_secret }}" searxng_oauth2_cookie_secret: "{{ vault_searxng_oauth_cookie_secret }}"
# Jellyfin Configuration
jellyfin_user: jellyfin
jellyfin_group: jellyfin
jellyfin_uid: 521
jellyfin_gid: 521
jellyfin_directory: /srv/jellyfin
jellyfin_port: 22086
jellyfin_syslog_port: 51426
# Storage paths
jellyfin_config_dir: /srv/jellyfin/config
jellyfin_cache_dir: /srv/jellyfin/cache
jellyfin_media_dir: /mnt/media
# Hardware transcoding (NVIDIA GPU passthrough)
jellyfin_enable_hwtranscode: true
# External access URL
jellyfin_published_server_url: "https://jellyfin.ouranos.helu.ca"
# SSO / OIDC Configuration (Casdoor)
jellyfin_sso_enabled: true
jellyfin_casdoor_client_id: "{{ vault_jellyfin_casdoor_client_id }}"
jellyfin_casdoor_client_secret: "{{ vault_jellyfin_casdoor_client_secret }}"
jellyfin_casdoor_issuer: "https://id.ouranos.helu.ca"
jellyfin_casdoor_redirect_uri: "https://jellyfin.ouranos.helu.ca/api/plugin/sso/callback"

149
ansible/jellyfin/README.md Normal file
View File

@@ -0,0 +1,149 @@
---
# Jellyfin Deployment for Ouranos
Jellyfin media server deployed on Rosalind Incus container.
## Overview
Jellyfin is an open-source media server for organizing, streaming, and managing media content. This deployment includes:
- Docker containerized deployment
- NVIDIA GPU passthrough for hardware-accelerated transcoding
- Prometheus metrics collection
- Syslog integration with Grafana Alloy
- Casdoor OIDC SSO support (via plugin)
## Deployment
### Prerequisites
1. Rosalind Incus container must be running with Docker installed
2. `/mnt/media` must be accessible from the Incus host
3. NVIDIA GPU must be passed through to the Rosalind container
4. Casdoor application must be configured for Jellyfin OIDC
### Installation
```bash
# From ansible directory
cd /home/robert/git/ouranos/ansible
# Deploy Jellyfin to Rosalind
ansible-playbook jellyfin/deploy.yml --limit rosalind.incus
```
### Updating
```bash
# Update Jellyfin container
ansible-playbook jellyfin/deploy.yml --limit rosalind.incus
```
## Configuration
### Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `jellyfin_user` | Service username | `jellyfin` |
| `jellyfin_group` | Service group name | `jellyfin` |
| `jellyfin_uid` | Service UID | `521` |
| `jellyfin_gid` | Service GID | `521` |
| `jellyfin_directory` | Base directory | `/srv/jellyfin` |
| `jellyfin_port` | HTTP port | `22086` |
| `jellyfin_syslog_port` | Syslog port | `51426` |
| `jellyfin_config_dir` | Config directory | `/srv/jellyfin/config` |
| `jellyfin_cache_dir` | Cache directory | `/srv/jellyfin/cache` |
| `jellyfin_media_dir` | Media bind mount | `/mnt/media` |
| `jellyfin_published_server_url` | External URL | `https://jellyfin.ouranos.helu.ca` |
### SSO Configuration
Jellyfin uses the `jellyfin-plugin-sso` community plugin for Casdoor OIDC authentication:
1. **Create Casdoor Application**:
- Application type: OIDC
- Callback URL: `https://jellyfin.ouranos.helu.ca/api/plugin/sso/callback`
- Enable PKCE
2. **Plugin Configuration**:
- Install manifest in `/config/plugins`
- Configure with Casdoor OIDC endpoints
3. **Casdoor Endpoints**:
- Authorization: `https://id.ouranos.helu.ca/oauth2/authorize`
- Token: `https://id.ouranos.helu.ca/oauth2/token`
- Userinfo: `https://id.ouranos.helu.ca/oauth2/userinfo`
## Monitoring
### Prometheus Metrics
Jellyfin exposes metrics at `http://localhost:8096/metrics`. These are collected by Prospero's Prometheus via:
- cAdvisor container metrics
- Process exporter
### Grafana Dashboard
Add a new data source in Grafana:
- Type: Prometheus
- URL: `http://prospero.incus:9090`
### Logs
View Jellyfin logs:
```bash
# Via Docker
docker logs -f jellyfin
# Via systemd
journalctl -u jellyfin -f
# Via Grafana Loki
https://loki.ouranos.helu.ca/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22jellyfin%22,%7B%22job%22%3A%22jellyfin%22%7D%5D
```
## Troubleshooting
### Container won't start
```bash
# Check Docker status
docker ps -a | grep jellyfin
# Check logs
docker logs jellyfin
# Verify GPU passthrough
ls -la /dev/dri/
```
### Transcoding fails
1. Verify GPU is accessible: `nvidia-smi`
2. Check container has device access: `docker inspect jellyfin | grep Devices`
3. Review logs for transcoding errors
### SSO not working
1. Verify plugin is installed in `/config/plugins`
2. Check Casdoor application configuration
3. Verify redirect URLs match exactly
4. Browser console for OAuth errors
## Files
| Path | Description |
|------|-------------|
| `/srv/jellyfin/docker-compose.yml` | Generated Docker Compose config |
| `/etc/systemd/system/jellyfin.service` | Systemd wrapper service |
| `/srv/jellyfin/config` | Jellyfin configuration |
| `/srv/jellyfin/cache` | Transcode cache |
| `/srv/jellyfin/logs` | Application logs (via syslog) |
## References
- [Jellyfin Official Docs](https://jellyfin.org/docs/)
- [Jellyfin Docker Image](https://hub.docker.com/r/jellyfin/jellyfin)
- [SSO Plugin GitHub](https://github.com/9p4/jellyfin-plugin-sso)

107
ansible/jellyfin/deploy.yml Normal file
View File

@@ -0,0 +1,107 @@
---
- name: Deploy Jellyfin
hosts: ubuntu
become: true
vars:
ansible_python_interpreter: /usr/bin/python3
tasks:
- name: Check if host has jellyfin service
ansible.builtin.set_fact:
has_jellyfin: "{{ 'jellyfin' in services | default([]) }}"
- name: Skip hosts without jellyfin service
ansible.builtin.meta: end_host
when: not has_jellyfin
- name: Create jellyfin group
ansible.builtin.group:
name: "{{ jellyfin_group }}"
gid: "{{ jellyfin_gid }}"
- name: Create jellyfin user
ansible.builtin.user:
name: "{{ jellyfin_user }}"
comment: "Jellyfin service account"
group: "{{ jellyfin_group }}"
uid: "{{ jellyfin_uid }}"
home: "{{ jellyfin_directory }}"
system: true
shell: /bin/bash
- name: Add keeper_user to jellyfin group
ansible.builtin.user:
name: "{{ keeper_user }}"
groups: "{{ jellyfin_group }}"
append: true
- name: Create Jellyfin directories
ansible.builtin.file:
path: "{{ item }}"
owner: "{{ jellyfin_user }}"
group: "{{ jellyfin_group }}"
state: directory
mode: '0750'
loop:
- "{{ jellyfin_directory }}"
- "{{ jellyfin_config_dir }}"
- "{{ jellyfin_cache_dir }}"
- name: Check if Docker is installed
ansible.builtin.stat:
path: /var/run/docker.sock
register: docker_socket
- name: Deploy Docker Compose configuration
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ jellyfin_directory }}/docker-compose.yml"
owner: "{{ jellyfin_user }}"
group: "{{ jellyfin_group }}"
mode: '0644'
notify:
- Stop Jellyfin
- Pull Jellyfin image
- Start Jellyfin
- name: Create systemd service for Docker Compose
ansible.builtin.template:
src: jellyfin.service.j2
dest: /etc/systemd/system/jellyfin.service
mode: '0644'
notify:
- Reload systemd
- Enable Jellyfin
- Start Jellyfin
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Stop Jellyfin
ansible.builtin.command:
cmd: "docker compose down"
chdir: "{{ jellyfin_directory }}"
become_user: "{{ jellyfin_user }}"
become: true
- name: Pull Jellyfin image
ansible.builtin.command:
cmd: "docker compose pull"
chdir: "{{ jellyfin_directory }}"
become_user: "{{ jellyfin_user }}"
become: true
- name: Start Jellyfin
ansible.builtin.command:
cmd: "docker compose up -d"
chdir: "{{ jellyfin_directory }}"
become_user: "{{ jellyfin_user }}"
become: true
- name: Enable Jellyfin
ansible.builtin.systemd:
name: jellyfin
enabled: true
state: started
daemon_reload: true

View File

@@ -0,0 +1,34 @@
---
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
user: "{{ jellyfin_uid }}:{{ jellyfin_gid }}"
ports:
- "{{ jellyfin_port }}:8096/tcp"
- "7359:7359/udp"
volumes:
- "{{ jellyfin_config_dir }}:/config"
- "{{ jellyfin_cache_dir }}:/cache"
- "{{ jellyfin_media_dir }}:/media:ro"
restart: unless-stopped
devices:
- /dev/dri:/dev/dri
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8096/dashboard"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: syslog
options:
syslog-address: "udp://prospero.incus:1514"
tag: "jellyfin"
environment:
- PUID={{ jellyfin_uid }}
- PGID={{ jellyfin_gid }}
- TZ=America/Toronto
- JELLYFIN_PublishedServerUrl={{ jellyfin_published_server_url }}
extra_hosts:
- "host.docker.internal:host-gateway"

View File

@@ -0,0 +1,18 @@
---
[Unit]
Description=Jellyfin Docker Compose Service
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ jellyfin_directory }}
User={{ jellyfin_user }}
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target

View File

@@ -126,6 +126,7 @@ Witty and resourceful moon for PHP, Go, and Node.js runtimes.
- LobeChat AI chat interface (port 22081) - LobeChat AI chat interface (port 22081)
- Nextcloud file sharing and collaboration (port 22083) - Nextcloud file sharing and collaboration (port 22083)
- AnythingLLM document AI workspace (port 22084) - AnythingLLM document AI workspace (port 22084)
- Jellyfin media server (port 22086, NVIDIA transcoding, Casdoor SSO)
- Nextcloud data on dedicated Incus storage volume - Nextcloud data on dedicated Incus storage volume
- Open WebUI LLM interface (port 22088, PostgreSQL backend on Portia - Open WebUI LLM interface (port 22088, PostgreSQL backend on Portia
- Home Assistant (port 8123) - Home Assistant (port 8123)
@@ -269,6 +270,7 @@ Titania provides TLS termination and reverse proxy for all services.
| `grafana.ouranos.helu.ca` | prospero.incus:443 (SSL) | Grafana | | `grafana.ouranos.helu.ca` | prospero.incus:443 (SSL) | Grafana |
| `hass.ouranos.helu.ca` | oberon.incus:8123 | Home Assistant | | `hass.ouranos.helu.ca` | oberon.incus:8123 | Home Assistant |
| `id.ouranos.helu.ca` | titania.incus:22081 | Casdoor SSO | | `id.ouranos.helu.ca` | titania.incus:22081 | Casdoor SSO |
| `jellyfin.ouranos.helu.ca` | rosalind.incus:22086 | Jellyfin |
| `icarlos.ouranos.helu.ca` | puck.incus:22681 | Icarlos (Django) | | `icarlos.ouranos.helu.ca` | puck.incus:22681 | Icarlos (Django) |
| `jupyterlab.ouranos.helu.ca` | puck.incus:22071 | JupyterLab (OAuth2-Proxy) | | `jupyterlab.ouranos.helu.ca` | puck.incus:22071 | JupyterLab (OAuth2-Proxy) |
| `kairos.ouranos.helu.ca` | puck.incus:22581 | Kairos (Django) | | `kairos.ouranos.helu.ca` | puck.incus:22581 | Kairos (Django) |
@@ -449,13 +451,45 @@ ansible-vault encrypt new_secrets.yml
Terraform provisions Incus S3 buckets for services requiring object storage: Terraform provisions Incus S3 buckets for services requiring object storage:
| Service | Host | Purpose | | Name | Description |
|---------|------|---------| |---------------------|----------------------------------|
| **Casdoor** | Titania | User avatars and SSO resource storage | | `casdoor` | Casdoor file storage bucket |
| **LobeChat** | Rosalind | File uploads and attachments | | `daedalus` | Daedalus file storage bucket |
| `lobechat` | Lobechat file storage bucket |
| `mnemosyne-content` | Mnemosyne content storage bucket |
| `spelunker` | Spelunker file storage bucket |
> S3 credentials (access key, secret key, endpoint) are stored as sensitive Terraform outputs and managed in Ansible Vault with the `vault_*_s3_*` prefix. > S3 credentials (access key, secret key, endpoint) are stored as sensitive Terraform outputs and managed in Ansible Vault with the `vault_*_s3_*` prefix.
### Retrieving S3 Bucket Credentials
The bucket credentials are declared as **sensitive** Terraform outputs, so a plain
`terraform output` will mask them. Use the `-json` (or `-raw`) flag to reveal the
values:
```bash
cd terraform
# List all outputs (sensitive values shown as <sensitive>)
terraform output
# Show a specific bucket's credentials as JSON
terraform output -json casdoor_s3_credentials
terraform output -json daedalus_s3_credentials
terraform output -json lobechat_s3_credentials
terraform output -json mnemosyne_s3_credentials
terraform output -json spelunker_s3_credentials
# Extract a single field (e.g. access_key) with jq
terraform output -json casdoor_s3_credentials | jq -r .access_key
terraform output -json casdoor_s3_credentials | jq -r .secret_key
terraform output -json casdoor_s3_credentials | jq -r .endpoint
```
Each `*_s3_credentials` output contains `bucket`, `access_key`, `secret_key`, and
`endpoint`. Copy these into `inventory/group_vars/all/vault.yml` as
`vault_<service>_s3_access_key`, `vault_<service>_s3_secret_key`, etc.
--- ---
## Ansible Automation ## Ansible Automation
@@ -498,6 +532,7 @@ Services with standalone deploy playbooks (not in `site.yml`):
| `gitea_mcp/deploy.yml` | Miranda | Gitea MCP Server | | `gitea_mcp/deploy.yml` | Miranda | Gitea MCP Server |
| `gitea_runner/deploy.yml` | Puck | Gitea CI/CD runner | | `gitea_runner/deploy.yml` | Puck | Gitea CI/CD runner |
| `grafana_mcp/deploy.yml` | Miranda | Grafana MCP Server | | `grafana_mcp/deploy.yml` | Miranda | Grafana MCP Server |
| `jellyfin/deploy.yml` | Rosalind | Jellyfin media server |
| `jupyterlab/deploy.yml` | Puck | JupyterLab + OAuth2-Proxy | | `jupyterlab/deploy.yml` | Puck | JupyterLab + OAuth2-Proxy |
| `kernos/deploy.yml` | Caliban | Kernos MCP shell server | | `kernos/deploy.yml` | Caliban | Kernos MCP shell server |
| `lobechat/deploy.yml` | Rosalind | LobeChat AI chat | | `lobechat/deploy.yml` | Rosalind | LobeChat AI chat |