--- apiVersion: apps/v1 kind: StatefulSet metadata: name: jellyfin namespace: jellyfin spec: # HA: two replicas coordinate transcode session ownership through Valkey and # resume each other's HLS segments off the shared RWX transcode PVC. Stable # pod names (jellyfin-0/1) are the lease owner identity, hence StatefulSet. replicas: 2 serviceName: jellyfin podManagementPolicy: Parallel updateStrategy: type: RollingUpdate selector: matchLabels: app: jellyfin template: metadata: labels: app: jellyfin spec: securityContext: # Group-write the shared RWX volumes and grant the render/video groups so # the runAsUser 1000 process can open the Intel DRI render node injected # by the device plugin. fsGroup: 1000 supplementalGroups: - 44 - 105 - 109 seccompProfile: type: RuntimeDefault affinity: # Spread the two replicas across nodes for node-level HA. Soft so a # single-GPU-node cluster still schedules both (i915 has 4 shared slots). podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: jellyfin topologyKey: kubernetes.io/hostname initContainers: # Seed the fork's PostgreSQL provider (database.xml) and Intel iGPU # hardware transcode settings (encoding.xml) before Jellyfin starts. # Runs as root to chown into the shared config volume; mirrors the fork # Helm chart's inject-db-config. Each file is written only when absent so # admin changes persisted to the shared RWX /config survive pod restarts. - name: inject-config image: busybox:1.37.0 command: - sh - -c - | mkdir -p /config/config chown 1000:1000 /config/config chmod 775 /config/config if [ ! -e /config/config/database.xml ]; then cat > /config/config/database.xml << 'DBEOF' Jellyfin-PostgreSQL NoLock DBEOF chown 1000:1000 /config/config/database.xml chmod 664 /config/config/database.xml fi # VAAPI on the Intel render node the device plugin injects # (/dev/dri/renderD128 — ffmpeg's default DRM node, reachable via # the render/video supplementalGroups). Without this the attached # iGPU is idle and every transcode runs in software. Omitted # elements fall back to the fork's EncodingOptions defaults. if [ ! -e /config/config/encoding.xml ]; then cat > /config/config/encoding.xml << 'ENCEOF' -1 vaapi /dev/dri/renderD128 true true false false false false false h264 hevc vc1 vp9 ENCEOF chown 1000:1000 /config/config/encoding.xml chmod 664 /config/config/encoding.xml fi resources: requests: cpu: 10m memory: 32Mi limits: cpu: 100m memory: 64Mi volumeMounts: - name: config mountPath: /config containers: - name: jellyfin image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/jellyfin-ha:v0.1.3 imagePullPolicy: IfNotPresent ports: - name: http containerPort: 8096 protocol: TCP env: # Pod identity for the Valkey transcode lease owner. The fork reads # JELLYFIN_INSTANCE_ID (falling back to MachineName); the stable # StatefulSet pod name gives each replica a unique lease identity so # takeover can target a dead replica. JELLYFIN_HA_POD_NAME is set for # parity with the fork Helm chart (nothing currently reads it). - name: JELLYFIN_INSTANCE_ID valueFrom: fieldRef: fieldPath: metadata.name - name: JELLYFIN_HA_POD_NAME valueFrom: fieldRef: fieldPath: metadata.name # Multiple replicas must not each answer UDP auto-discovery. - name: JELLYFIN_Network__AutoDiscovery value: "false" # Config dir must differ from the data root (Jellyfin sanity check). - name: JELLYFIN_CONFIG_DIR value: /config/config # Distributed transcode session store (jellyfin-ha additions). - name: Jellyfin__TranscodeStore__RedisConnectionString value: "valkey-jellyfin-valkey:6379,abortConnect=false" - name: Jellyfin__TranscodeStore__LeaseDurationSeconds value: "30" # PostgreSQL main DB via the CNPG-generated app secret, routed through # the PgBouncer pooler. Composed with $(VAR) expansion so the password # is never rendered into the manifest; CNPG passwords are URL-safe. - name: PGUSER valueFrom: secretKeyRef: name: jellyfin-postgres-app key: username - name: PGPASSWORD valueFrom: secretKeyRef: name: jellyfin-postgres-app key: password - name: PGDB valueFrom: secretKeyRef: name: jellyfin-postgres-app key: dbname - name: POSTGRES_CONNECTION_STRING value: "postgresql://$(PGUSER):$(PGPASSWORD)@jellyfin-postgres-pooler:5432/$(PGDB)" - name: DATABASE_URL value: "postgresql://$(PGUSER):$(PGPASSWORD)@jellyfin-postgres-pooler:5432/$(PGDB)" livenessProbe: httpGet: path: /health port: http initialDelaySeconds: 30 periodSeconds: 30 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /health port: http initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 resources: requests: cpu: "1" memory: 1Gi gpu.intel.com/i915: "1" limits: cpu: "4" memory: 6Gi # Intel iGPU (QSV/VA-API) slot. Requesting it pins the pod to a # GPU-labelled node and injects /dev/dri/renderD* automatically, so # no /dev/dri hostPath or privileged container is needed. VA-API is # pre-enabled via the seeded encoding.xml (see inject-config), so # transcodes use the iGPU on first boot with no manual UI step. gpu.intel.com/i915: "1" securityContext: runAsUser: 1000 runAsGroup: 1000 volumeMounts: - name: config mountPath: /config - name: transcode # Fork's real transcode temp path. RWX so a surviving pod reads the # in-flight .ts/.m3u8 segments of the pod it takes over. A per-pod # volume here silently breaks HA takeover. mountPath: /config/transcodes - name: cache mountPath: /cache - name: media-tv mountPath: /media/tv readOnly: true - name: media-movies mountPath: /media/movies readOnly: true # Kids subtrees surfaced as their own paths (same media PVCs, subPath # kids) so a "Kids TV"/"Kids Movies" library can be added here and its # titles are browsable and resume in this instance's own DB. The full # /media/{tv,movies} mounts above are unchanged. - name: media-tv mountPath: /media/tv-kids subPath: kids readOnly: true - name: media-movies mountPath: /media/movies-kids subPath: kids readOnly: true volumes: - name: config persistentVolumeClaim: claimName: jellyfin-config - name: transcode persistentVolumeClaim: claimName: jellyfin-transcode - name: media-tv persistentVolumeClaim: claimName: jellyfin-media-tv - name: media-movies persistentVolumeClaim: claimName: jellyfin-media-movies volumeClaimTemplates: # Per-pod scratch cache — RWO, disposable, one PVC per replica. - metadata: name: cache annotations: # Exclude the per-pod cache PVCs from the jellyfin-config k8up Schedule # (skipWithoutAnnotation is false cluster-wide). Cache is disposable and # RWO — it would also fail to mount into the backup pod while in use. k8up.io/backup: "false" spec: accessModes: - ReadWriteOnce resources: requests: storage: 30Gi storageClassName: cephrbd-fast-delete volumeMode: Filesystem