Skip to content

feat(harmony): add Jellyfin media server - #657

Merged
OscarMarshall merged 25 commits into
mainfrom
claude/issue-539-238b7e
Aug 22, 2026
Merged

feat(harmony): add Jellyfin media server#657
OscarMarshall merged 25 commits into
mainfrom
claude/issue-539-238b7e

Conversation

@OscarMarshall

@OscarMarshall OscarMarshall commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds my.jellyfin (modules/aspects/my/jellyfin.nix): NixOS services.jellyfin with VAAPI hardware transcoding (harmony's i9-13900K UHD 770 iGPU), and a reverse-proxied virtual host (Homepage tile with a live widget, reusing jellyfin-api-key). No openFirewall/Meraki port-forward - Jellyfin is reached exclusively through nginx's loopback proxy, same as Sonarr/Radarr/Prowlarr.
  • Wires it into harmony (global = true), alongside Plex.
  • Installs Jellyfin plugins declaratively via terranix + the ThePhaseless/jellyfin Terraform 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 primitive jellyfin-api-key secret (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).
  • SSO Authentication is fully configured and live: a native Authentik OIDC application (virtual-host.oidc, same mechanism as seerr.nix) plus a jellyfin_plugin_configuration resource pushing the matching OidConfigs into the plugin, both backed by a generated jellyfin-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.
  • Declares jellyfin_library resources for the real, already-existing Movies/Shows libraries (imported via tofu import, not created).
  • modules/terranix.nix: harmony-tf-apply.service now orders after nginx.service/authentik.service (not just network-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.
  • Updates README.md/.github/copilot-instructions.md's service listings to include Jellyfin.

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:

  1. Omitting library_options entirely crashes the provider on create ("Received unknown value, however the target type cannot handle unknown values").
  2. Declaring library_options (any value) crashes on update instead: the provider's Read never populates 8 of 15 fields into state, so a diff is unavoidable, and updating hits Guid can't be empty (Parameter 'id') server-side. Fixed with lifecycle.ignore_changes post-import.
  3. jellyfin_plugin resources race their own jellyfin_plugin_repository when both are created in the same apply (GET /Packages 500, "No package named X found"). Fixed with explicit depends_on.
  4. Jellyfin registers a loaded plugin under its own assembly/project name, which can differ from the "friendly" name its manifest advertises for catalog installs (confirmed in Jellyfin's own logs for both Moonbase→"Moonfin" and SSO Authentication→"SSO-Auth"). Since name/repository_url force replacement on mismatch, and neither is populated correctly by tofu import, this needed lifecycle.ignore_changes on both plugins.
  5. jellyfin_plugin_configuration's own Update reports back a different configuration_json string 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}/authentik returning 400, not 404).

None of these are things a different library_options/name value could have avoided - state itself was structurally wrong or inconsistent, so lifecycle.ignore_changes was 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 fmt on changed files
  • nix eval confirms services.jellyfin.enable, hardware transcoding options, and the jellyfin.harmony.silverlight-nex.us nginx vhost
  • nix eval confirms 8096 is NOT in networking.firewall.allowedTCPPorts
  • nix eval confirms the Homepage widget renders correctly in services.homepage-dashboard.services
  • Deployed to harmony: nixos-rebuild switch succeeds cleanly, harmony-tf-apply.service active (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

Global, alongside Plex - mirrors plex.nix's shape (NixOS service,
Meraki port-forward, reverse-proxied virtual host).

Closes #539
Copilot AI lite review requested due to automatic review settings August 9, 2026 08:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.jellyfin aspect enabling services.jellyfin and contributing virtual-host (+ router port-forward as currently written).
  • Wires Jellyfin into den.aspects.harmony.includes with global = 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.

Comment thread modules/aspects/my/jellyfin.nix Outdated
Comment thread modules/aspects/my/jellyfin.nix
Comment thread modules/aspects/hosts/harmony/harmony.nix
OscarMarshall and others added 17 commits August 9, 2026 02:21
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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=1 to 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 11 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • harmony-tf/.terraform.lock.hcl: Generated file

Comment thread modules/aspects/my/jellyfin.nix
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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

Comment thread modules/terranix.nix Outdated
Comment thread modules/aspects/my/jellyfin.nix
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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

@OscarMarshall
OscarMarshall merged commit c901270 into main Aug 22, 2026
9 checks passed
@OscarMarshall
OscarMarshall deleted the claude/issue-539-238b7e branch August 22, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Jellyfin

2 participants