diff --git a/milky-way/environments/stage00/orion-system/main.jsonnet b/milky-way/environments/stage00/orion-system/main.jsonnet index bff5212..e50e592 100644 --- a/milky-way/environments/stage00/orion-system/main.jsonnet +++ b/milky-way/environments/stage00/orion-system/main.jsonnet @@ -15,6 +15,7 @@ local openclaw = import 'milky-way/lib/openclaw.libsonnet'; local qbittorrent = import 'milky-way/lib/qbittorrent.libsonnet'; local sonarr = import 'milky-way/lib/sonarr.libsonnet'; local prowlarr = import 'milky-way/lib/prowlarr.libsonnet'; +local jellyfin = import 'milky-way/lib/jellyfin.libsonnet'; local buildarr = import 'milky-way/lib/buildarr.libsonnet'; local utils = import 'milky-way/lib/utils.libsonnet'; local wgConf = import 'milky-way/lib/wireguard-conf.libsonnet'; @@ -175,6 +176,16 @@ local secrets = import 'milky-way/secrets/k8s-secret-values.jsonnet'; tailscaleHostname = "prowlarr", ), + // Jellyfin: media server for the library the *arr stack builds on the shared mdata volume. + // Reads /data/library/... (same PVC, same /data mount as sonarr/qbittorrent), so it serves the + // exact tree Sonarr hardlinks completed downloads into. SQLite config + metadata cache on its + // own iSCSI RWO PVC. WebUI via Tailscale L7 ingress; first-run setup is interactive (no API key + // to pin, so no Secret / buildarr wiring). + jellyfin: jellyfin.new( + tailscaleHostname = "jellyfin", + mediaVolumeClaimName = this.mdataPvc.metadata.name, + ), + // Buildarr: declaratively asserts the inter-app links the *arr apps store in SQLite (and which the // SONARR__/PROWLARR__ env overrides can't reach) -- Sonarr's qBittorrent download client and // Prowlarr's Sonarr application (which auto-syncs Prowlarr's indexers into Sonarr). Plumbing only: diff --git a/milky-way/lib/images.libsonnet b/milky-way/lib/images.libsonnet index 016178e..9015c10 100644 --- a/milky-way/lib/images.libsonnet +++ b/milky-way/lib/images.libsonnet @@ -64,6 +64,14 @@ local images = { fullyQualifiedRepository: "lscr.io/linuxserver/prowlarr", defaultDigest: { hash: "sha256:7ab5769616c1929247c8e7944453253f0b777fac2724c3bc9976ae2ff4023257", tagHint: "2.4.0.5397-ls150" }, }, + // Jellyfin media server (LinuxServer.io): plays the library the *arr stack builds on the + // shared mdata volume. Same multi-arch INDEX digest convention as the *arr/qbittorrent pins + // above (k3s resolves the per-node arch); tagHint is the readable LinuxServer version. + // Re-resolve with `docker buildx imagetools inspect lscr.io/linuxserver/jellyfin:latest`. + jellyfin: { + fullyQualifiedRepository: "lscr.io/linuxserver/jellyfin", + defaultDigest: { hash: "sha256:bb8ff21a879498dbdead9efe4d3de2070dbda2b9fb35b9a43501055f6e526384", tagHint: "10.11.11ubu2404-ls37" }, + }, // Buildarr: declaratively reconciles *arr state (used here only to wire Sonarr<->Prowlarr<-> // qBittorrent together). The image bundles the sonarr/radarr/prowlarr plugins. The hash is the // multi-arch INDEX digest (same as the *arr/qbittorrent pins above; k3s resolves the per-node diff --git a/milky-way/lib/jellyfin.libsonnet b/milky-way/lib/jellyfin.libsonnet new file mode 100644 index 0000000..6456103 --- /dev/null +++ b/milky-way/lib/jellyfin.libsonnet @@ -0,0 +1,142 @@ +local utils = import 'milky-way/lib/utils.libsonnet'; +local images = import 'milky-way/lib/images.libsonnet'; + +// Jellyfin (LinuxServer.io): the media SERVER that plays the library the *arr stack builds. It +// reads the same shared `mdata` RWX-NFS PVC that qbittorrent/sonarr mount at /data, so the +// library tree it serves (/data/library/{Animations,TV Shows,...}) is exactly the one Sonarr +// hardlinks completed downloads into -- same PVC, same /data mount path, one filesystem. +// +// Unlike the *arr apps there's no API-key-on-boot to pin (Jellyfin mints credentials during its +// first-run web setup, which is interactive), so this lib carries NO Secret and no servarr-style +// env overrides -- just PUID/PGID/TZ. Jellyfin is also not an *arr app, so buildarr does not +// manage it. +// +// Storage: /config holds a SQLite library DB plus a metadata/artwork cache that Jellyfin rewrites +// at runtime. SQLite over NFS is unsafe (locking/corruption), so /config lives on iSCSI (RWO) -- +// and an RWO PVC means the old pod must release the volume before a new one mounts it, hence +// strategy: Recreate (same contract as sonarr). It's sized larger than the *arr config PVCs +// because that artwork/metadata cache grows with the library. The media volume is mounted +// read-write so Jellyfin can manage media and (optionally) store metadata/trickplay alongside it. +{ + new( + tailscaleHostname, // required, unique tailnet-wide -> https://..ts.net + mediaVolumeClaimName, // required -> external shared RWX PVC (the `mdata` PVC in main.jsonnet) + name='jellyfin', + namespace='default', + image=images.jellyfin.fullyQualifiedImageReferencePinned, + port=8096, // Jellyfin's HTTP WebUI/API port + timezone='America/Los_Angeles', + configStorageClassName='my-custom-zfs-generic-iscsi', // RWO; SQLite must not be on NFS + configStorageSize='10Gi', // larger than the *arr config PVCs: holds the metadata/artwork cache + mediaMountPath='/data', // whole shared volume mounted here (matches qbittorrent/sonarr's /data) + ):: { + local this = self, + + configPvc: { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: name + '-config', namespace: namespace }, + spec: { + accessModes: ['ReadWriteOncePod'], + storageClassName: configStorageClassName, + resources: { requests: { storage: configStorageSize } }, + }, + }, + + deployment: { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: name, namespace: namespace }, + spec: { + replicas: 1, + strategy: { type: 'Recreate' }, // RWO config PVC: old pod must release before new mounts + selector: { matchLabels: { app: name } }, + template: { + metadata: { labels: {} + this.deployment.spec.selector.matchLabels }, + spec: { + tolerations: [ + { key: 'ephemeral', operator: 'Exists', effect: 'NoSchedule' }, + ], + containers: [ + { + name: name, + image: image, + env: [ + { name: 'PUID', value: '1000' }, + { name: 'PGID', value: '1000' }, + { name: 'TZ', value: timezone }, + ], + ports: [{ name: 'webui', containerPort: port }], + volumeMounts: [ + { name: 'config', mountPath: '/config' }, + { name: 'media', mountPath: mediaMountPath }, // read-write: Jellyfin may manage media / store metadata alongside + ], + // /health is Jellyfin's unauthenticated health endpoint (returns "Healthy"), so + // it's a safe readiness signal even before the interactive first-run setup. + readinessProbe: { + httpGet: { path: '/health', port: 'webui' }, + initialDelaySeconds: 15, + periodSeconds: 15, + }, + resources: { + requests: { memory: '256Mi', cpu: '100m' }, + limits: { memory: '2Gi', cpu: '2' }, + }, + }, + ], + volumes: [ + { name: 'config', persistentVolumeClaim: { claimName: this.configPvc.metadata.name } }, + { name: 'media', persistentVolumeClaim: { claimName: mediaVolumeClaimName } }, + ], + }, + }, + }, + }, + + service: { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: name, namespace: namespace }, + spec: { + selector: {} + this.deployment.spec.template.metadata.labels, + ports: [ + { + name: 'webui', + port: port, + targetPort: utils.assertEqualAndReturn(this.deployment.spec.template.spec.containers[0].ports[0].name, 'webui'), + }, + ], + type: 'ClusterIP', + }, + }, + + // Tailnet-only L7 ingress (no funnel), mirroring sonarr/prowlarr/qbittorrent. + ingress: { + apiVersion: 'networking.k8s.io/v1', + kind: 'Ingress', + metadata: { + name: name, + namespace: namespace, + annotations: { 'tailscale.com/funnel': 'false' }, + }, + spec: { + ingressClassName: 'tailscale', + tls: [{ hosts: [tailscaleHostname] }], + rules: [{ + http: { + paths: [{ + path: '/', + pathType: 'Prefix', + backend: { + service: { + name: this.service.metadata.name, + port: { number: utils.assertEqualAndReturn(this.service.spec.ports[0].port, port) }, + }, + }, + }], + }, + }], + }, + }, + }, +}