tech_notes

A collection of notes on setups and implementations.
Log | Files | Refs | README

jellyfin.md (9433B)


      1 # Jellyfin
      2 
      3 We document the setup of [Jellyfin](https://jellyfin.org) running at [jellyfin.piribauer.ch](https://jellyfin.piribauer.ch), our media server for films.
      4 
      5 Authentication is delegated to Authentik, but unlike the other apps on the hub, Jellyfin does not support Authentik's forward-auth proxy flow — it needs its own login form.
      6 The official `jellyfin-plugin-sso` (OIDC) plugin is archived and unmaintained, so we follow Authentik's documented alternative instead: an **LDAP outpost** that exposes Authentik's user database over LDAP, paired with Jellyfin's own LDAP Authentication plugin.
      7 Jellyfin's login page stays exactly as it is; the plugin validates the entered username/password against the LDAP outpost in the background. There is no redirect to Authentik's login screen.
      8 
      9 We assume Docker, nginx, and Authentik are already running.
     10 
     11 ## Storage
     12 
     13 Media lives on a dedicated external HDD, mounted at `/mnt/media`, kept separate from the restic-backed `/home/julian` tree so the (large, replaceable) media library is never included in backups.
     14 
     15 ```console
     16 # mkfs.ext4 /dev/sda1
     17 # mkdir -p /mnt/media
     18 # mount /dev/sda1 /mnt/media
     19 ```
     20 
     21 An entry in `/etc/fstab` makes the mount persist across reboots.
     22 
     23 ## Docker Compose
     24 
     25 `/home/julian/jellyfin/docker-compose.yml`:
     26 
     27 ```yaml
     28 services:
     29   jellyfin:
     30     image: docker.io/jellyfin/jellyfin:latest
     31     container_name: jellyfin
     32     restart: unless-stopped
     33     user: "1000:1000"
     34     volumes:
     35       - ./config:/config
     36       - ./cache:/cache
     37       - /mnt/media:/media:ro
     38     ports:
     39       - "127.0.0.1:8096:8096"
     40     networks:
     41       default: {}
     42       authentik_default: {}
     43 
     44 networks:
     45   authentik_default:
     46     external: true
     47 ```
     48 
     49 Media is mounted read-only at the container path `/media` — not `/mnt/media`, which only exists on the host.
     50 Jellyfin is additionally attached to Authentik's `authentik_default` docker network so it can reach the LDAP outpost by its container name, without exposing anything extra on the host.
     51 
     52 ```console
     53 $ cd /home/julian/jellyfin
     54 $ docker compose up -d
     55 ```
     56 
     57 ## Nginx
     58 
     59 ```nginx
     60 server {
     61     server_name jellyfin.piribauer.ch;
     62 
     63     location / {
     64         proxy_pass          http://127.0.0.1:8096;
     65         proxy_http_version  1.1;
     66         proxy_set_header    Upgrade $http_upgrade;
     67         proxy_set_header    Connection "upgrade";
     68         proxy_set_header    Host $host;
     69         proxy_set_header    X-Real-IP $remote_addr;
     70         proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
     71         proxy_set_header    X-Forwarded-Proto $scheme;
     72         proxy_buffering     off;
     73     }
     74 
     75     listen 443 ssl;
     76     ssl_certificate     /etc/letsencrypt/live/piribauer.ch/fullchain.pem;
     77     ssl_certificate_key /etc/letsencrypt/live/piribauer.ch/privkey.pem;
     78     include             /etc/letsencrypt/options-ssl-nginx.conf;
     79     ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;
     80 }
     81 ```
     82 
     83 **Certbot warning:** `piribauer.ch` uses a single certificate shared across every subdomain (one SAN list). Adding a new site with
     84 
     85 ```console
     86 # certbot --nginx -d jellyfin.piribauer.ch --cert-name piribauer.ch --expand
     87 ```
     88 
     89 only works safely if **every** existing domain is also passed with `-d`. Omitting any of them causes certbot to *replace* the certificate rather than expand it, silently dropping the missing domains from the SAN list and breaking TLS for those subdomains — with no error at request time until a client actually notices the hostname mismatch. Always re-run with the full `-d` list for all subdomains (`piribauer.ch`, `www.piribauer.ch`, `auth`, `mail`, `www.mail`, `git`, `hub`, `calendar`, `familytree`, `technotes`, `jellyfin`) when expanding.
     90 
     91 ## First run
     92 
     93 On first start we complete Jellyfin's setup wizard at `https://jellyfin.piribauer.ch`, which creates an initial local admin account, and add a library:
     94 
     95 - Content type: Movies
     96 - Folder: `/media` (the container path, not `/mnt/media`)
     97 
     98 ## Authentik — LDAP outpost
     99 
    100 Authentik's LDAP outpost runs as an extra service in the existing Authentik compose stack, `/home/julian/authentik/docker-compose.yml`:
    101 
    102 ```yaml
    103   ldap-outpost:
    104     image: ghcr.io/goauthentik/ldap:2024.12.3
    105     container_name: authentik-ldap-outpost
    106     restart: unless-stopped
    107     environment:
    108       AUTHENTIK_HOST: http://server:9000
    109       AUTHENTIK_TOKEN: ${LDAP_OUTPOST_TOKEN}
    110     depends_on:
    111       server:
    112         condition: service_healthy
    113 ```
    114 
    115 `AUTHENTIK_HOST` uses the Compose service name `server`, resolved via Docker's internal DNS — not the container's display name.
    116 `LDAP_OUTPOST_TOKEN` is the outpost's token, generated when the outpost is created in the Authentik admin UI (**Applications → Outposts**) and stored in `/home/julian/authentik/.env`.
    117 
    118 We configure, in the Authentik admin UI:
    119 
    120 - A service account (e.g. `jellyfin_ldap`) used only to bind and search — never to log in as a person.
    121 - Two groups: `jellyfin_users` (who can log into Jellyfin) and `jellyfin_admins` (reserved for future automatic admin grants, currently unused — see below).
    122 - An **LDAP Provider** with `base_dn: dc=piribauer,dc=ch` and bind mode `direct`.
    123 - A dedicated **authorization flow** for the provider, rather than reusing the default one. The stock `default-provider-authorization-implicit-consent` flow requires an authenticated Django session (`authentication: require_authenticated`), which an LDAP direct bind never has — using it throws `FlowNonApplicableException`. The custom flow needs `authentication: require_outpost` and at least two stages, an **Identification Stage** followed by a **User Login Stage** (a flow with zero stages throws `EmptyFlowException`, and a login stage with nothing preceding it never gets a `pending_user` and denies access).
    124 - An **Application** bound to the provider. We leave it *without* any policy bindings — LDAP direct-bind requests run as `AnonymousUser`, and evaluating user/group policy bindings against `AnonymousUser` crashes the outpost. Access control instead comes from the LDAP search filter on the Jellyfin side (see below).
    125 - The **Outpost** itself, with the LDAP provider assigned to it.
    126 
    127 ## Jellyfin — LDAP Authentication plugin
    128 
    129 Installed from Jellyfin's plugin catalog (**Dashboard → Plugins → Catalog → LDAP Authentication**), then configured under **Dashboard → Plugins → LDAP Authentication**:
    130 
    131 - LDAP server: `authentik-ldap-outpost`, port `389`, no encryption (traffic stays inside the docker network)
    132 - Bind DN: `cn=jellyfin_ldap,ou=service-accounts,ou=goauthentik.io,dc=piribauer,dc=ch`
    133 
    134   Service accounts live under `ou=service-accounts,ou=goauthentik.io`, not `ou=users` like regular people — the exact path is visible on the user's object via the Authentik API (`path` field).
    135 - Base DN: `dc=piribauer,dc=ch`
    136 - User search filter: restricts to members of `jellyfin_users`, e.g. `(&(memberOf=cn=jellyfin_users,ou=groups,dc=piribauer,dc=ch)(|(uid={username})(cn={username})))`
    137 - Admin filter: references `jellyfin_admins` (inert until someone is added to that group)
    138 
    139 ## Pointing an existing local account at LDAP
    140 
    141 If a local Jellyfin account already exists (e.g. created by the setup wizard) and should authenticate via LDAP instead of its local password, there's no need to delete and recreate it — just repoint its auth provider:
    142 
    143 ```console
    144 $ curl -H "Authorization: MediaBrowser Token=<api-key>" \
    145     http://127.0.0.1:8096/Users/<user-id>
    146 ```
    147 
    148 Take the returned `Policy` object, set `AuthenticationProviderId` to `Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin`, and POST it back to `/Users/<user-id>/Policy`.
    149 
    150 ## Known issue: username casing
    151 
    152 If the local Jellyfin account's username differs from the LDAP username only in case (e.g. local `Julian` vs. LDAP `julian`), login fails with an HTTP 400 and the server log shows:
    153 
    154 ```
    155 System.ArgumentException: The new and old names must be different.
    156    at Jellyfin.Server.Implementations.Users.UserManager.RenameUser(...)
    157    at Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin.Authenticate(...)
    158 ```
    159 
    160 The LDAP plugin syncs the local username to match LDAP's casing on every login, but Jellyfin's rename check treats the two names as identical (case-insensitively) and refuses the "no-op" rename — even though the case actually differs. This affects a direct API/UI rename attempt just as much as the plugin's own sync.
    161 
    162 The fix is a two-step rename through an intermediate name, so no step is a case-only change:
    163 
    164 ```console
    165 $ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \
    166     -d '{"Name": "julian_tmp", ...}' http://127.0.0.1:8096/Users/<user-id>
    167 $ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \
    168     -d '{"Name": "julian", ...}' http://127.0.0.1:8096/Users/<user-id>
    169 ```
    170 
    171 (each `...` is the full user object as returned by `GET /Users/<user-id>`, with only `Name` changed). Once the local username exactly matches LDAP's casing, the plugin has nothing left to rename and login proceeds normally.
    172 
    173 ## Granting admin rights
    174 
    175 `jellyfin_admins` is wired into the LDAP plugin's admin filter but currently has no members, so admin rights aren't granted automatically. Until that group is populated, admin rights are set directly:
    176 
    177 ```console
    178 $ curl -X POST -H "Authorization: MediaBrowser Token=<api-key>" -H "Content-Type: application/json" \
    179     -d '{"IsAdministrator": true, ...}' http://127.0.0.1:8096/Users/<user-id>/Policy
    180 ```