feat(harmony): add Jellyfin media server - #657
Conversation
Global, alongside Plex - mirrors plex.nix's shape (NixOS service, Meraki port-forward, reverse-proxied virtual host). Closes #539
There was a problem hiding this comment.
Pull request overview
Adds a new Den service aspect to run Jellyfin on the harmony NixOS host and expose it through the repo’s standard reverse-proxy/virtual-host plumbing, alongside existing media services.
Changes:
- Introduces
my.jellyfinaspect enablingservices.jellyfinand contributingvirtual-host(+ router port-forward as currently written). - Wires Jellyfin into
den.aspects.harmony.includeswithglobal = true.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| modules/aspects/my/jellyfin.nix | Adds a my.jellyfin aspect configuring NixOS Jellyfin and contributing virtual-host / port-forward quirks. |
| modules/aspects/hosts/harmony/harmony.nix | Enables the new Jellyfin aspect on the harmony host. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
harmony's i9-13900K exposes its UHD 770 iGPU at /dev/dri/renderD128; wire it up via intel-media-driver for hardware-accelerated encode.
Wires the ThePhaseless/jellyfin Terraform provider to add the Moonfin plugin repository and install its Moonbase plugin. Jellyfin can't be handed a pre-generated API key the way Sonarr/Radarr/Prowlarr can - it only issues keys to an already-authenticated session - so the provider credential is a primitive secret, minted by hand in Jellyfin's own dashboard after first-run setup rather than openssl-generated.
…rraform.io ThePhaseless/jellyfin isn't published to OpenTofu's own default registry (registry.opentofu.org), so the bare "ThePhaseless/jellyfin" source failed to resolve at init, blocking the whole harmony-tf apply - including the unrelated Cloudflare DNS record for jellyfin.silverlight-nex.us, which is why its ACME order also failed with an NXDOMAIN (harmony fell back to a self-signed cert for both jellyfin hostnames, sharing one SAN cert). Explicit registry.terraform.io hostname routes to the right registry.
…ists Every declared Terraform provider is configured during \`tofu plan\` regardless of whether any resource references it - with jellyfin-api-key still a placeholder, keeping the provider/resource blocks around took down the whole harmony-tf apply, including the unrelated Cloudflare DNS record this vhost's ACME order needs. Drops the provider block and the jellyfin_plugin/jellyfin_plugin_repository resources for now, keeping just the inert required_providers constraint and the secret - add the provider + resources back once the real API key is in place.
… key exists Restores the provider block and jellyfin_plugin/jellyfin_plugin_repository resources (Moonfin repo + Moonbase install) removed earlier to unblock DNS record creation - jellyfin-api-key now has a real value, so the provider can authenticate. Includes the rekeyed secrets from the manual bootstrap: Jellyfin's first-run setup, minting the API key, and `agenix generate -a && agenix rekey -a`.
# Conflicts: # secrets/generated/harmony-tf.env.age
… Authentication plugins Adds four more `jellyfin_plugin` resources alongside Moonbase, using the same terranix pattern: two official plugins (Fanart, Open Subtitles, from Jellyfin's own repo.jellyfin.org catalog) and two third-party ones with their own plugin repositories (Intro Skipper, SSO Authentication). SSO Authentication's actual OIDC configuration (the `jellyfin_plugin_configuration` resource and virtual-host's `oidc` block) is deliberately deferred - it needs `jellyfin-oidc-client-secret`, a new generated secret that doesn't exist until `agenix generate -a && agenix rekey -a` runs. Wiring it in now would reference an undefined Terraform variable and fail the entire harmony-tf apply, the same coupling issue jellyfin-api-key hit earlier in this PR. Only the plugin *install* is wired up for now; the plugin sits unconfigured until the secret exists and the OIDC resources are added back.
# Conflicts: # .github/copilot-instructions.md # README.md
Wires the OIDC bits deferred earlier: jellyfin's virtual-host gets an `oidc` block (native Authentik application + redirect URI, matching seerr.nix's pattern) and a `jellyfin_plugin_configuration` resource pushes the matching OidConfigs into the plugin itself. Both reference `jellyfin-oidc-client-secret`, now a real generated/rekeyed value. No RBAC (EnableAuthorization = false) - every Authentik-authenticated user gets a normal full-library account, since role mapping needs a custom Authentik scope this doesn't set up.
Reuses the existing jellyfin-api-key secret (flagged settings.homepage = "jellyfin" alongside settings.terraform) so Homepage can show library stats/now-playing, matching the pattern already used for Sonarr/Radarr/Prowlarr.
jellyfin_library.movies/tv-shows, best-guessed from Radarr's/Sonarr's own dataset paths (/metalminds/movies, /metalminds/shows) rather than confirmed against Jellyfin's actual library names - I couldn't query the real state (the decrypted jellyfin-api-key on harmony is root-only, no passwordless sudo). Only name/collection_type/paths are declared, no library_options, to limit what a wrong guess could touch. Needs a one-time `tofu import` per library, same as sonarr.nix's own root-folder resource: tofu import jellyfin_library.movies Movies tofu import jellyfin_library.tv-shows "TV Shows" (adjust the import key if the real names differ). Review the plan by hand once before letting the switch-triggered apply run unattended - the safety net only refuses destroy-containing plans, not ordinary attribute corrections.
…retry plan Every on-box terraform resource is reached through nginx (never a raw service port) and authentik.service backs both the Authentik provider itself and every forward-auth-gated service - but this unit had no ordering relative to either, only network-online.target. Confirmed live: a switch that restarts authentik.service (session/token config change) raced this unit, which started 6s before authentik.service even began its own restart, failing every Authentik-backed resource with a 502. Added nginx.service/authentik.service to `after` - deliberately not every individual on-box service's own unit, since that would need updating for every new terraform-consuming aspect. Ordering alone doesn't fully close the gap (a unit reporting "started" doesn't mean its listener is accepting connections - authentik.service's own gunicorn workers were still booting 4s after systemd called it started), so `tofu plan` now retries up to 5 times with a 10s backoff.
…epends_on
Two real bugs surfaced on harmony's first attempted apply of these
resources (ordering/retry fix from the previous commit worked - no
more Authentik/nginx 502s):
- Omitting `library_options` entirely crashes the provider ("Value
Conversion Error ... Received unknown value" - a provider bug, not
a config mistake). Every top-level field is now set explicitly to
Jellyfin's own stock defaults, since a partial block risks the same
crash on whatever's still left unset.
- jellyfin_plugin.moonbase/intro_skipper/sso_authentication raced
their own jellyfin_plugin_repository resources - repository_url is
a plain string, not a resource reference, so Terraform had no
implicit ordering and tried installing plugins before Jellyfin's
package catalog had picked up the brand-new repository ("GET
/Packages returned status 500", "No package named X found").
Explicit depends_on fixes the ordering.
IMPORTANT: the jellyfin_library plan still shows a CREATE, meaning
the one-time `tofu import` (documented in this same block's comment)
hasn't run yet. Run it before the next apply, or this will create
duplicate libraries on the real server.
Real library is named "Shows", not the guessed "TV Shows" - avoids the next plan wanting to rename it back to the wrong guess.
enable_chapter_image_extraction and save_local_metadata are both true on harmony's real libraries, not the guessed stock false - confirmed from the actual tofu plan diff after import.
jellyfin_library's UpdateLibraryOptions call has a second, deeper
provider bug than the earlier omit-crashes-on-create one: the
provider's own Read never populates 8 of 15 library_options fields
into state, so every plan shows them as additions regardless of what's
declared, and the resulting update call fails server-side with "Guid
can't be empty (Parameter 'id')" (confirmed in Jellyfin's own logs -
the provider isn't threading the library's internal item GUID through
the request). No value combination avoids this, so
lifecycle.ignore_changes on library_options stops Terraform from ever
attempting the update again post-import.
Also serializes all terraform operations (-parallelism=1, via
TF_CLI_ARGS_plan/_apply for the interactive wrapper and direct flags
for harmony-tf-apply.service) - confirmed live that Jellyfin's plugin
API isn't concurrency-safe: two parallel plugin installs raced
Jellyfin's own file I/O reading another plugin's icon
("jellyfin-plugin-fanart.png ... being used by another process"),
surfacing as a GET /Packages 500.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 11 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- harmony-tf/.terraform.lock.hcl: Generated file
Suppressed comments (1)
modules/terranix.nix:481
- The wrapper unconditionally overwrites any existing TF_CLI_ARGS_plan/TF_CLI_ARGS_apply values, which can unexpectedly drop user-specified flags. Also, the preceding comment says “ALL resource operations”, but destroy isn’t serialized. Consider appending
-parallelism=1to any existing values and also setting TF_CLI_ARGS_destroy for consistency.
export TF_CLI_ARGS_apply="-parallelism=1"
export TF_CLI_ARGS_plan="-parallelism=1"
Copilot review: unconditionally assigning TF_CLI_ARGS_plan/_apply would silently drop any flags a caller already set via those same env vars. Append -parallelism=1 to whatever's already there instead. Also add TF_CLI_ARGS_destroy, since the comment already claimed "ALL resource operations" but destroy wasn't actually covered.
jellyfin_plugin.sso_authentication's real, already-working plugin (GUID 505ce9d1d91642fa86ca673ef241d7df) somehow ended up with name = "SSO-Auth" in state - the REPOSITORY's own display name, not the plugin's "SSO Authentication". Since name forces replacement on mismatch, harmony-tf-apply.service correctly refused to auto-apply a plan that would destroy and recreate an already-working plugin. lifecycle.ignore_changes on name sidesteps this the same way library_options did earlier - no value here avoids the drift, since the bug is in what the provider records, not what's declared.
Confirmed live: jellyfin_plugin.moonbase timed out waiting 30s for
Jellyfin to register a just-installed plugin ("plugin \"Moonbase\" did
not appear within 30s"), while two sibling resources in the SAME apply
succeeded seconds later - ordinary transient slowness, not a
structural bug. The apply step had no retry at all, unlike plan, and a
saved plan can't just be re-applied after a partial failure (state has
moved past what it was computed against) - so retrying needs a fresh
plan, not a rerun of the same plan file.
Restructures plan+destroy-check+apply into a plan_and_apply function
(return 0 success, 1 retry-safe failure, 2 destroy-containing plan,
never retried) called from an outer retry loop. Verified the control
flow (destroy short-circuits immediately, transient failure retries
and recovers) with a simulated run, and syntax-checked the actual
rendered script text.
…match
Confirmed in Jellyfin's own logs ("Loaded assembly Moonfin.Server ...
Loaded plugin: Moonfin 2.0.3.0"): the Moonfin repository's manifest and
the install call both call it "Moonbase", but Jellyfin registers the
LOADED plugin as "Moonfin". Since name/repository_url force replacement
on any mismatch, and `tofu import` doesn't populate either correctly,
every create attempt kept re-triggering Jellyfin's "needs restart"
update path against a name that would never appear - not a timing
race like it looked, an unwinnable name mismatch. Same fix as
sso_authentication: lifecycle.ignore_changes.
Also corrects sso_authentication's own comment - the real cause is
this same loaded-name-vs-manifest-name pattern ("Loaded assembly
SSO-Auth ... Loaded plugin: SSO-Auth"), not the coincidental
repository-name guess from the original fix.
Confirmed live, identically on all 3 retries: "Provider produced
inconsistent result after apply ... .configuration_json: inconsistent
values for sensitive attribute ... This is a bug in the provider"
(Terraform's own error text, not mine). The update request itself does
reach Jellyfin - /sso/OID/{start,redirect}/authentik both return 400
(a recognized provider rejecting a bare request), not 404 - but the
provider reports back a different configuration_json string than what
was sent, likely because Jellyfin round-trips this plugin's config
through its own XML-backed store and re-serializes it differently on
read-back. Since this fails Terraform's own consistency check (not
Jellyfin's API), state never commits and every apply retries the
identical, doomed step forever - same "no value avoids this" class of
bug as library_options/name drift elsewhere in this file.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes a safety-critical unattended Terraform auto-apply path (where the destroy-guard is now weakened by a set -e/function interaction) and touches secrets/OIDC infrastructure, warranting human review.
Review details
Files not reviewed (1)
- harmony-tf/.terraform.lock.hcl: Generated file
- Files reviewed: 5/11 changed files
- Comments generated: 2
- Review effort level: Balanced
…on set -e Copilot review: plan_and_apply is called as `plan_and_apply || cycle_status=$?`, and bash disables set -e for a compound command's ENTIRE execution - including everything inside a function it calls - whenever that compound command sits on the left of `||`. The has_destroy assignment's own comment assumed set -e would still catch a tofu show/jq failure and abort; it doesn't anymore. Verified live with a bash simulation: the old pattern silently fell through to `tofu apply` on a show/jq failure (apply_called=1, cycle_status=0, as if nothing went wrong) - exactly what the destroy guard exists to prevent. `if ! has_destroy=...; then return 1; fi` tests the substitution's exit status directly, independent of -e semantics. feat(harmony): enable websockets on the Jellyfin virtual host Copilot review: Jellyfin's web client keeps a WebSocket open to /socket for real-time features (now playing, remote control, SyncPlay). Without `websockets = true;`, nginx's recommendedProxySettings strips the Connection header needed for the upgrade handshake - same situation as Sonarr's own SignalR connection (sonarr.nix). Verified `proxyWebsockets` reaches the actual generated nginx vhost config.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes the unattended production Terraform auto-apply automation (subtle bash and systemd ordering) and integrates a young third-party provider with five documented workarounds, warranting final human review despite the author's live verification.
Review details
Files not reviewed (1)
- harmony-tf/.terraform.lock.hcl: Generated file
- Files reviewed: 5/11 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Summary
my.jellyfin(modules/aspects/my/jellyfin.nix): NixOSservices.jellyfinwith VAAPI hardware transcoding (harmony's i9-13900K UHD 770 iGPU), and a reverse-proxied virtual host (Homepage tile with a live widget, reusingjellyfin-api-key). NoopenFirewall/Meraki port-forward - Jellyfin is reached exclusively through nginx's loopback proxy, same as Sonarr/Radarr/Prowlarr.global = true), alongside Plex.ThePhaseless/jellyfinTerraform provider (registry.terraform.io, not mirrored on OpenTofu's default registry): Moonbase (Moonfin's companion plugin), Fanart, Open Subtitles, Intro Skipper, and SSO Authentication. The provider authenticates with a primitivejellyfin-api-keysecret (minted by hand in Jellyfin's own dashboard, since Jellyfin - unlike Sonarr/Radarr/Prowlarr - only ever issues API keys to an already-authenticated session, never accepts a pre-generated one).virtual-host.oidc, same mechanism as seerr.nix) plus ajellyfin_plugin_configurationresource pushing the matchingOidConfigsinto the plugin, both backed by a generatedjellyfin-oidc-client-secret. No RBAC (EnableAuthorization = false) - every Authentik-authenticated user gets a normal full-library account, since role mapping needs a custom Authentik scope this doesn't set up.jellyfin_libraryresources for the real, already-existing Movies/Shows libraries (imported viatofu import, not created).modules/terranix.nix:harmony-tf-apply.servicenow orders afternginx.service/authentik.service(not justnetwork-online.target) and retries the whole plan→apply cycle (not just plan) with a fresh plan each attempt, since a saved plan can't be re-applied once state has moved past it.The ThePhaseless/jellyfin provider is young (0.3.0) and this surfaced five distinct bugs in it
All worked around in
jellyfin.nix, each with an inline comment at the point of the fix:library_optionsentirely crashes the provider on create ("Received unknown value, however the target type cannot handle unknown values").library_options(any value) crashes on update instead: the provider'sReadnever populates 8 of 15 fields into state, so a diff is unavoidable, and updating hitsGuid can't be empty (Parameter 'id')server-side. Fixed withlifecycle.ignore_changespost-import.jellyfin_pluginresources race their ownjellyfin_plugin_repositorywhen both are created in the same apply (GET /Packages500, "No package named X found"). Fixed with explicitdepends_on.name/repository_urlforce replacement on mismatch, and neither is populated correctly bytofu import, this neededlifecycle.ignore_changeson both plugins.jellyfin_plugin_configuration's ownUpdatereports back a differentconfiguration_jsonstring than what was sent - Terraform's own error text says "This is a bug in the provider." Fixed the same way once I'd confirmed the actual config had landed correctly (/sso/OID/{start,redirect}/authentikreturning 400, not 404).None of these are things a different
library_options/namevalue could have avoided - state itself was structurally wrong or inconsistent, solifecycle.ignore_changeswas the actual fix each time, not a config change.Moonfin
Moonfin is a client app (mobile/desktop/web) end users install themselves against a Jellyfin server — nothing to deploy in Nix for it. Its companion server plugin ("Moonbase") is what the terranix wiring above installs.
Closes #539
Test plan
nix fmton changed filesnix evalconfirmsservices.jellyfin.enable, hardware transcoding options, and thejellyfin.harmony.silverlight-nex.usnginx vhostnix evalconfirms 8096 is NOT innetworking.firewall.allowedTCPPortsnix evalconfirms the Homepage widget renders correctly inservices.homepage-dashboard.servicesnixos-rebuild switchsucceeds cleanly,harmony-tf-apply.serviceactive (not failed), Jellyfin running with hardware transcoding, real Let's Encrypt cert, all five plugins installed, both libraries imported, SSO Authentication configured against Authentik🤖 Generated with Claude Code