--- apiVersion: apps/v1 kind: StatefulSet metadata: name: fafflix namespace: fafflix 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 (fafflix-0/1) are the lease owner identity, hence StatefulSet. replicas: 2 serviceName: fafflix podManagementPolicy: Parallel updateStrategy: type: RollingUpdate selector: matchLabels: app: fafflix template: metadata: labels: app: fafflix 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: fafflix 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 # Render the SSO/LDAP plugin configs into the shared config volume, # substituting the client secret and LDAP bind password from the # VSO-synced oauth-credentials Secret (never committed). Plugin configs # are fully managed here so they are overwritten every start; the login # button branding is written only when absent so admin edits survive. - name: inject-plugin-config image: busybox:1.37.0 command: - sh - -c - | mkdir -p /config/plugins/configurations /config/config chown 1000:1000 /config/plugins /config/plugins/configurations /config/config esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; } cs=$(esc "${CLIENT_SECRET}") lp=$(esc "${LDAP_BIND_PASSWORD}") sed "s|@@CLIENT_SECRET@@|${cs}|" /templates/SSO-Auth.xml > /config/plugins/configurations/SSO-Auth.xml sed "s|@@LDAP_BIND_PASSWORD@@|${lp}|" /templates/LDAP-Auth.xml > /config/plugins/configurations/LDAP-Auth.xml chown 1000:1000 /config/plugins/configurations/SSO-Auth.xml /config/plugins/configurations/LDAP-Auth.xml chmod 600 /config/plugins/configurations/SSO-Auth.xml /config/plugins/configurations/LDAP-Auth.xml if [ ! -e /config/config/branding.xml ]; then cp /templates/branding.xml /config/config/branding.xml chown 1000:1000 /config/config/branding.xml chmod 664 /config/config/branding.xml fi env: - name: CLIENT_SECRET valueFrom: secretKeyRef: name: oauth-credentials key: client_secret optional: true - name: LDAP_BIND_PASSWORD valueFrom: secretKeyRef: name: oauth-credentials key: ldap_bind_password optional: true resources: requests: cpu: 10m memory: 32Mi limits: cpu: 100m memory: 64Mi volumeMounts: - name: config mountPath: /config - name: plugin-config mountPath: /templates readOnly: true containers: - name: fafflix image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/jellyfin-ha:v0.2.0 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 (fafflix-ha additions). - name: Jellyfin__TranscodeStore__RedisConnectionString value: "valkey-fafflix-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: fafflix-postgres-app key: username - name: PGPASSWORD valueFrom: secretKeyRef: name: fafflix-postgres-app key: password - name: PGDB valueFrom: secretKeyRef: name: fafflix-postgres-app key: dbname - name: POSTGRES_CONNECTION_STRING value: "postgresql://$(PGUSER):$(PGPASSWORD)@fafflix-postgres-pooler:5432/$(PGDB)" - name: DATABASE_URL value: "postgresql://$(PGUSER):$(PGPASSWORD)@fafflix-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 # Adult instance: mount the tvshows/adult subtree of the shared TV # subvolume (subPath adult) as fafflix's primary TV library. Same # rootPath the cheeztv instance reads under subPath kids, so an # episode resolves identically across instances. mountPath: /media/tv subPath: adult readOnly: true - name: media-tv # Also mount the kids TV subtree (subPath kids) so fafflix can # resume playback of kids content started on cheeztv — same # underlying subvolume, different subtree, distinct mount path. mountPath: /media/tv-kids subPath: kids readOnly: true - name: media-movies # Adult instance: mount the movies/adult subtree of the shared # movies subvolume (subPath adult) as fafflix's primary movie # library. mountPath: /media/movies subPath: adult readOnly: true - name: media-movies # Also mount the kids movies subtree (subPath kids) for cross-resume # of kids content started on cheeztv. mountPath: /media/movies-kids subPath: kids readOnly: true volumes: - name: plugin-config configMap: name: fafflix-plugin-config - name: config persistentVolumeClaim: claimName: fafflix-config - name: transcode persistentVolumeClaim: claimName: fafflix-transcode - name: media-tv persistentVolumeClaim: claimName: fafflix-media-tv - name: media-movies persistentVolumeClaim: claimName: fafflix-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 fafflix-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