diff --git a/ansible/alloy/rosalind/config.alloy.j2 b/ansible/alloy/rosalind/config.alloy.j2 index 220a4fc..3841c86 100644 --- a/ansible/alloy/rosalind/config.alloy.j2 +++ b/ansible/alloy/rosalind/config.alloy.j2 @@ -63,7 +63,7 @@ prometheus.scrape "hass" { // Lobechat Docker syslog loki.source.syslog "lobechat_logs" { listener { - address = "127.0.0.1:{{lobechat_syslog_port}}" + address = "127.0.0.1:{{ lobechat_syslog_port }}" protocol = "tcp" syslog_format = "{{ syslog_format }}" labels = { @@ -75,6 +75,21 @@ loki.source.syslog "lobechat_logs" { 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" { listener { address = "127.0.0.1:{{searxng_syslog_port}}" diff --git a/ansible/inventory/host_vars/rosalind.incus.yml b/ansible/inventory/host_vars/rosalind.incus.yml index 2676088..de5efd8 100644 --- a/ansible/inventory/host_vars/rosalind.incus.yml +++ b/ansible/inventory/host_vars/rosalind.incus.yml @@ -7,6 +7,7 @@ services: - anythingllm - docker - gitea + - jellyfin - lobechat - memcached - nextcloud @@ -236,4 +237,31 @@ searxng_oauth2_redirect_url: "https://searxng.ouranos.helu.ca/oauth2/callback" # OAuth2 Credentials (from vault) searxng_oauth2_client_id: "{{ vault_searxng_oauth_client_id }}" searxng_oauth2_client_secret: "{{ vault_searxng_oauth_client_secret }}" -searxng_oauth2_cookie_secret: "{{ vault_searxng_oauth_cookie_secret }}" \ No newline at end of file +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" diff --git a/ansible/jellyfin/README.md b/ansible/jellyfin/README.md new file mode 100644 index 0000000..14eafac --- /dev/null +++ b/ansible/jellyfin/README.md @@ -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) \ No newline at end of file diff --git a/ansible/jellyfin/deploy.yml b/ansible/jellyfin/deploy.yml new file mode 100644 index 0000000..24e5ebe --- /dev/null +++ b/ansible/jellyfin/deploy.yml @@ -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 \ No newline at end of file diff --git a/ansible/jellyfin/docker-compose.yml.j2 b/ansible/jellyfin/docker-compose.yml.j2 new file mode 100644 index 0000000..b0cdcfe --- /dev/null +++ b/ansible/jellyfin/docker-compose.yml.j2 @@ -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" \ No newline at end of file diff --git a/ansible/jellyfin/jellyfin.service.j2 b/ansible/jellyfin/jellyfin.service.j2 new file mode 100644 index 0000000..55bfc47 --- /dev/null +++ b/ansible/jellyfin/jellyfin.service.j2 @@ -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 \ No newline at end of file diff --git a/docs/ouranos.md b/docs/ouranos.md index e0b5e71..bbf0db3 100644 --- a/docs/ouranos.md +++ b/docs/ouranos.md @@ -126,6 +126,7 @@ Witty and resourceful moon for PHP, Go, and Node.js runtimes. - LobeChat AI chat interface (port 22081) - Nextcloud file sharing and collaboration (port 22083) - AnythingLLM document AI workspace (port 22084) +- Jellyfin media server (port 22086, NVIDIA transcoding, Casdoor SSO) - Nextcloud data on dedicated Incus storage volume - Open WebUI LLM interface (port 22088, PostgreSQL backend on Portia - 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 | | `hass.ouranos.helu.ca` | oberon.incus:8123 | Home Assistant | | `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) | | `jupyterlab.ouranos.helu.ca` | puck.incus:22071 | JupyterLab (OAuth2-Proxy) | | `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: -| Service | Host | Purpose | -|---------|------|---------| -| **Casdoor** | Titania | User avatars and SSO resource storage | -| **LobeChat** | Rosalind | File uploads and attachments | +| Name | Description | +|---------------------|----------------------------------| +| `casdoor` | Casdoor file storage bucket | +| `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. +### 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 ) +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__s3_access_key`, `vault__s3_secret_key`, etc. + --- ## Ansible Automation @@ -498,6 +532,7 @@ Services with standalone deploy playbooks (not in `site.yml`): | `gitea_mcp/deploy.yml` | Miranda | Gitea MCP Server | | `gitea_runner/deploy.yml` | Puck | Gitea CI/CD runner | | `grafana_mcp/deploy.yml` | Miranda | Grafana MCP Server | +| `jellyfin/deploy.yml` | Rosalind | Jellyfin media server | | `jupyterlab/deploy.yml` | Puck | JupyterLab + OAuth2-Proxy | | `kernos/deploy.yml` | Caliban | Kernos MCP shell server | | `lobechat/deploy.yml` | Rosalind | LobeChat AI chat |