diff --git a/apps/base/cheeztv/cephrgw-config-backup.yaml b/apps/base/cheeztv/cephrgw-config-backup.yaml
new file mode 100644
index 0000000..a8cf1f1
--- /dev/null
+++ b/apps/base/cheeztv/cephrgw-config-backup.yaml
@@ -0,0 +1,32 @@
+---
+# Second Ceph RGW (S3) bucket owned by the existing cheeztv backup user
+# (cnpg-cheeztv-backup, defined in cnpg_backup.yaml) — one user, two buckets:
+# the CNPG barman bucket plus this one, which k8up uses to hold restic backups
+# of the cheeztv-config PVC. The BucketAccess emits read-write S3 creds into a
+# Secret the k8up Schedule consumes.
+apiVersion: ceph.unkin.net/v1alpha1
+kind: Bucket
+metadata:
+ name: cheeztv-config-backup
+ namespace: cheeztv
+spec:
+ placementTarget: ec
+ bucketName: cheeztv-config-backup
+ ownerRef: cnpg-cheeztv-backup
+ versioning: false
+ tags:
+ app: cheeztv
+ purpose: config-backup
+ retainOnDelete: true
+---
+apiVersion: ceph.unkin.net/v1alpha1
+kind: BucketAccess
+metadata:
+ name: cheeztv-config-backup
+ namespace: cheeztv
+spec:
+ bucketRef: cheeztv-config-backup
+ level: read-write
+ # Operator writes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ S3_ENDPOINT,
+ # BUCKET_NAME) into this Secret; the k8up Schedule reads the access keys.
+ secretName: cheeztv-config-backup-s3
diff --git a/apps/base/cheeztv/cnpg_backup.yaml b/apps/base/cheeztv/cnpg_backup.yaml
new file mode 100644
index 0000000..d303b14
--- /dev/null
+++ b/apps/base/cheeztv/cnpg_backup.yaml
@@ -0,0 +1,45 @@
+---
+# Ceph RGW (S3) backup target for the cheeztv CNPG cluster, provisioned by the
+# in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG reads the
+# S3 credential Secret from its own namespace.
+apiVersion: ceph.unkin.net/v1alpha1
+kind: ObjectStoreUser
+metadata:
+ name: cnpg-cheeztv-backup
+ namespace: cheeztv
+spec:
+ displayName: "CNPG backup owner (cheeztv)"
+ uid: cnpg-cheeztv-backup
+ maxBuckets: 5
+ secretName: cnpg-cheeztv-backup-s3
+ retainOnDelete: true
+---
+apiVersion: ceph.unkin.net/v1alpha1
+kind: Bucket
+metadata:
+ name: cnpg-cheeztv
+ namespace: cheeztv
+spec:
+ placementTarget: ec
+ bucketName: cnpg-cheeztv
+ ownerRef: cnpg-cheeztv-backup
+ versioning: false
+ tags:
+ app: cheeztv
+ purpose: cnpg-backup
+ retainOnDelete: true
+---
+# Nightly base backup on top of always-on WAL archiving. Scheduled off-peak and
+# staggered from the other CNPG clusters (6-field cron, seconds first).
+apiVersion: postgresql.cnpg.io/v1
+kind: ScheduledBackup
+metadata:
+ name: cnpg-cheeztv-nightly
+ namespace: cheeztv
+spec:
+ schedule: "0 35 3 * * *"
+ immediate: false
+ backupOwnerReference: self
+ method: barmanObjectStore
+ cluster:
+ name: cheeztv-postgres
diff --git a/apps/base/cheeztv/cnpg_cluster.yaml b/apps/base/cheeztv/cnpg_cluster.yaml
new file mode 100644
index 0000000..7f06440
--- /dev/null
+++ b/apps/base/cheeztv/cnpg_cluster.yaml
@@ -0,0 +1,126 @@
+---
+# Main Jellyfin database. The cheeztv-ha fork's experimental EF Core provider
+# moves the entire Jellyfin DB (incl. library items) off SQLite into PostgreSQL,
+# which is what makes a shared-nothing multi-replica deployment possible. No
+# bootstrap secret is given, so CNPG generates the cheeztv-postgres-app secret
+# (username/password/dbname) that the StatefulSet composes its DSN from.
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+ name: cheeztv-postgres
+ namespace: cheeztv
+spec:
+ # Exclude the operator-managed data PVCs (cheeztv-postgres-N) from the
+ # cheeztv-config k8up Schedule (skipWithoutAnnotation is false cluster-wide,
+ # so unannotated PVCs are swept in). Postgres has its own barmanObjectStore
+ # backup below; restic must not touch the raw RWO data volumes.
+ inheritedMetadata:
+ annotations:
+ k8up.io/backup: "false"
+ affinity:
+ podAntiAffinityType: preferred
+ backup:
+ retentionPolicy: 30d
+ barmanObjectStore:
+ # Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it).
+ destinationPath: s3://cnpg-cheeztv
+ endpointURL: https://s3.ceph.unkin.net
+ endpointCA:
+ name: vault-ca-cert
+ key: ca.crt
+ s3Credentials:
+ accessKeyId:
+ name: cnpg-cheeztv-backup-s3
+ key: AWS_ACCESS_KEY_ID
+ secretAccessKey:
+ name: cnpg-cheeztv-backup-s3
+ key: AWS_SECRET_ACCESS_KEY
+ serverName: cheeztv
+ data:
+ compression: bzip2
+ jobs: 2
+ wal:
+ compression: zstd
+ maxParallel: 2
+ bootstrap:
+ initdb:
+ database: cheeztv
+ encoding: UTF8
+ localeCType: C
+ localeCollate: C
+ owner: cheeztv
+ enablePDB: true
+ enableSuperuserAccess: false
+ failoverDelay: 0
+ # PG 17 — accepted by the fork's Npgsql/EF Core provider (needs PG14+); the
+ # provider generates its own migrations on first start.
+ imageName: ghcr.io/cloudnative-pg/postgresql:17-system-trixie
+ instances: 3
+ logLevel: info
+ maxSyncReplicas: 0
+ minSyncReplicas: 0
+ monitoring:
+ customQueriesConfigMap:
+ - key: queries
+ name: cnpg-default-monitoring
+ disableDefaultQueries: false
+ enablePodMonitor: false
+ postgresql:
+ parameters:
+ archive_mode: "on"
+ archive_timeout: 5min
+ dynamic_shared_memory_type: posix
+ effective_cache_size: 256MB
+ full_page_writes: "on"
+ log_destination: csvlog
+ log_directory: /controller/log
+ log_filename: postgres
+ log_rotation_age: "0"
+ log_rotation_size: "0"
+ log_truncate_on_rotation: "false"
+ logging_collector: "on"
+ max_connections: "200"
+ max_parallel_workers: "16"
+ max_replication_slots: "16"
+ max_worker_processes: "16"
+ shared_buffers: 128MB
+ shared_memory_type: mmap
+ ssl_max_protocol_version: TLSv1.3
+ ssl_min_protocol_version: TLSv1.3
+ wal_keep_size: 256MB
+ wal_level: logical
+ wal_log_hints: "on"
+ wal_receiver_timeout: 5s
+ wal_sender_timeout: 5s
+ syncReplicaElectionConstraint:
+ enabled: false
+ primaryUpdateMethod: restart
+ primaryUpdateStrategy: unsupervised
+ probes:
+ liveness:
+ isolationCheck:
+ connectionTimeout: 1000
+ enabled: true
+ requestTimeout: 1000
+ replicationSlots:
+ highAvailability:
+ enabled: true
+ slotPrefix: _cnpg_
+ synchronizeReplicas:
+ enabled: true
+ updateInterval: 30
+ resources:
+ limits:
+ cpu: "1"
+ memory: 1Gi
+ requests:
+ cpu: 50m
+ memory: 512Mi
+ smartShutdownTimeout: 180
+ startDelay: 3600
+ stopDelay: 1800
+ storage:
+ resizeInUseVolumes: true
+ size: 10Gi
+ storageClass: cephrbd-fast-delete
+ switchoverDelay: 3600
diff --git a/apps/base/cheeztv/cnpg_pooler.yaml b/apps/base/cheeztv/cnpg_pooler.yaml
new file mode 100644
index 0000000..91f79c1
--- /dev/null
+++ b/apps/base/cheeztv/cnpg_pooler.yaml
@@ -0,0 +1,36 @@
+---
+# PgBouncer pooler in front of the cheeztv-postgres cluster. Jellyfin connects
+# here (cheeztv-postgres-pooler:5432) rather than the -rw service so EF Core's
+# connection churn is absorbed by the pool.
+apiVersion: postgresql.cnpg.io/v1
+kind: Pooler
+metadata:
+ name: cheeztv-postgres-pooler
+ namespace: cheeztv
+spec:
+ cluster:
+ name: cheeztv-postgres
+ instances: 2
+ pgbouncer:
+ parameters:
+ default_pool_size: "50"
+ max_client_conn: "200"
+ paused: false
+ poolMode: session
+ template:
+ metadata:
+ labels:
+ app: cheeztv-pooler
+ spec:
+ affinity:
+ podAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - labelSelector:
+ matchExpressions:
+ - key: app
+ operator: In
+ values:
+ - cheeztv-pooler
+ topologyKey: kubernetes.io/hostname
+ containers: []
+ type: rw
diff --git a/apps/base/cheeztv/gateway.yaml b/apps/base/cheeztv/gateway.yaml
new file mode 100644
index 0000000..9ceec36
--- /dev/null
+++ b/apps/base/cheeztv/gateway.yaml
@@ -0,0 +1,81 @@
+---
+# Friendly public hostname for the kids instance, cheeztv.unkin.net. Same
+# internal-Traefik + external-dns pattern as logviewer.unkin.net: external-dns
+# publishes the A record at the internal LB VIP (198.18.200.4) and cert-manager
+# mints cheeztv-tls (CN cheeztv.unkin.net) off the Vault-PKI cluster issuer.
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ labels:
+ traefik.io/instance: internal
+ annotations:
+ cert-manager.io/cluster-issuer: vault-issuer
+ cert-manager.io/common-name: cheeztv.unkin.net
+ cert-manager.io/private-key-size: "4096"
+ external-dns.alpha.kubernetes.io/hostname: cheeztv.unkin.net
+ external-dns.alpha.kubernetes.io/target: 198.18.200.4
+ name: cheeztv
+ namespace: cheeztv
+spec:
+ gatewayClassName: traefik-internal
+ listeners:
+ - allowedRoutes:
+ namespaces:
+ from: Same
+ hostname: cheeztv.unkin.net
+ name: http
+ port: 80
+ protocol: HTTP
+ - allowedRoutes:
+ namespaces:
+ from: Same
+ hostname: cheeztv.unkin.net
+ name: https
+ port: 443
+ protocol: HTTPS
+ tls:
+ certificateRefs:
+ - group: ""
+ kind: Secret
+ name: cheeztv-tls
+ mode: Terminate
+---
+# Cluster hostname variant, mirroring fafflix's jellyfin.k8s.syd1.au.unkin.net
+# gateway 1:1 (internal Traefik, external-dns at 198.18.200.4). Own cert secret
+# so each hostname gets its own leaf.
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ labels:
+ traefik.io/instance: internal
+ annotations:
+ cert-manager.io/cluster-issuer: vault-issuer
+ cert-manager.io/common-name: cheeztv.k8s.syd1.au.unkin.net
+ cert-manager.io/private-key-size: "4096"
+ external-dns.alpha.kubernetes.io/hostname: cheeztv.k8s.syd1.au.unkin.net
+ external-dns.alpha.kubernetes.io/target: 198.18.200.4
+ name: cheeztv-internal
+ namespace: cheeztv
+spec:
+ gatewayClassName: traefik-internal
+ listeners:
+ - allowedRoutes:
+ namespaces:
+ from: Same
+ hostname: cheeztv.k8s.syd1.au.unkin.net
+ name: http
+ port: 80
+ protocol: HTTP
+ - allowedRoutes:
+ namespaces:
+ from: Same
+ hostname: cheeztv.k8s.syd1.au.unkin.net
+ name: https
+ port: 443
+ protocol: HTTPS
+ tls:
+ certificateRefs:
+ - group: ""
+ kind: Secret
+ name: cheeztv-internal-tls
+ mode: Terminate
diff --git a/apps/base/cheeztv/httproute.yaml b/apps/base/cheeztv/httproute.yaml
new file mode 100644
index 0000000..325adba
--- /dev/null
+++ b/apps/base/cheeztv/httproute.yaml
@@ -0,0 +1,98 @@
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: http-redirect
+ namespace: cheeztv
+spec:
+ hostnames:
+ - cheeztv.unkin.net
+ parentRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: cheeztv
+ sectionName: http
+ rules:
+ - filters:
+ - type: RequestRedirect
+ requestRedirect:
+ scheme: https
+ statusCode: 301
+ matches:
+ - path:
+ type: PathPrefix
+ value: /
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: cheeztv-route
+ namespace: cheeztv
+spec:
+ hostnames:
+ - cheeztv.unkin.net
+ parentRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: cheeztv
+ sectionName: https
+ rules:
+ - backendRefs:
+ - group: ""
+ kind: Service
+ name: cheeztv
+ port: 8096
+ weight: 1
+ matches:
+ - path:
+ type: PathPrefix
+ value: /
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: http-redirect-internal
+ namespace: cheeztv
+spec:
+ hostnames:
+ - cheeztv.k8s.syd1.au.unkin.net
+ parentRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: cheeztv-internal
+ sectionName: http
+ rules:
+ - filters:
+ - type: RequestRedirect
+ requestRedirect:
+ scheme: https
+ statusCode: 301
+ matches:
+ - path:
+ type: PathPrefix
+ value: /
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: cheeztv-route-internal
+ namespace: cheeztv
+spec:
+ hostnames:
+ - cheeztv.k8s.syd1.au.unkin.net
+ parentRefs:
+ - group: gateway.networking.k8s.io
+ kind: Gateway
+ name: cheeztv-internal
+ sectionName: https
+ rules:
+ - backendRefs:
+ - group: ""
+ kind: Service
+ name: cheeztv
+ port: 8096
+ weight: 1
+ matches:
+ - path:
+ type: PathPrefix
+ value: /
diff --git a/apps/base/cheeztv/kustomization.yaml b/apps/base/cheeztv/kustomization.yaml
new file mode 100644
index 0000000..26efe34
--- /dev/null
+++ b/apps/base/cheeztv/kustomization.yaml
@@ -0,0 +1,26 @@
+---
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - namespace.yaml
+ - cnpg_cluster.yaml
+ - cnpg_pooler.yaml
+ - cnpg_backup.yaml
+ - cephrgw-config-backup.yaml
+ - vaultauth.yaml
+ - vaultstaticsecret.yaml
+ - schedule.yaml
+ - pvc-config.yaml
+ - pvc-transcode.yaml
+ - pv-media-tv.yaml
+ - pv-media-movies.yaml
+ - pvc-media-tv.yaml
+ - pvc-media-movies.yaml
+ - statefulset.yaml
+ - pdb.yaml
+ - service.yaml
+ - valkey.yaml
+ - vmpodscrape.yaml
+ - gateway.yaml
+ - httproute.yaml
diff --git a/apps/base/cheeztv/namespace.yaml b/apps/base/cheeztv/namespace.yaml
new file mode 100644
index 0000000..e1c6a8c
--- /dev/null
+++ b/apps/base/cheeztv/namespace.yaml
@@ -0,0 +1,5 @@
+---
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: cheeztv
diff --git a/apps/base/cheeztv/pdb.yaml b/apps/base/cheeztv/pdb.yaml
new file mode 100644
index 0000000..b42649e
--- /dev/null
+++ b/apps/base/cheeztv/pdb.yaml
@@ -0,0 +1,13 @@
+---
+# Keep at least one Jellyfin replica serving through voluntary disruptions
+# (node drains, rollouts) so active streams can fail over rather than drop.
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: cheeztv
+ namespace: cheeztv
+spec:
+ minAvailable: 1
+ selector:
+ matchLabels:
+ app: cheeztv
diff --git a/apps/base/cheeztv/pv-media-movies.yaml b/apps/base/cheeztv/pv-media-movies.yaml
new file mode 100644
index 0000000..d66d4bf
--- /dev/null
+++ b/apps/base/cheeztv/pv-media-movies.yaml
@@ -0,0 +1,30 @@
+---
+# Static PV for the shared MOVIES CephFS subvolume. Same rootPath as arrstack's
+# movies PV so radarr writes and cheeztv reads the identical library tree; each
+# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: cheeztv-media-movies
+spec:
+ capacity:
+ storage: 1Ti
+ accessModes:
+ - ReadWriteMany
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: ""
+ volumeMode: Filesystem
+ claimRef:
+ namespace: cheeztv
+ name: cheeztv-media-movies
+ csi:
+ driver: cephfs.csi.ceph.com
+ volumeHandle: cheeztv-media-movies-static
+ nodeStageSecretRef:
+ name: csi-cephfs-secret
+ namespace: csi-cephfs
+ volumeAttributes:
+ staticVolume: "true"
+ clusterID: cephfs_csi_ssd_ec_4_1
+ fsName: cephfs
+ rootPath: /volumes/csi_ssd_ec_4_1/media-movies/e95d8ace-c736-465a-acc3-0c3e46dcede9
diff --git a/apps/base/cheeztv/pv-media-tv.yaml b/apps/base/cheeztv/pv-media-tv.yaml
new file mode 100644
index 0000000..a30a968
--- /dev/null
+++ b/apps/base/cheeztv/pv-media-tv.yaml
@@ -0,0 +1,30 @@
+---
+# Static PV for the shared TV CephFS subvolume. Same rootPath as arrstack's
+# TV PV so sonarr writes and cheeztv reads the identical library tree; each
+# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: cheeztv-media-tv
+spec:
+ capacity:
+ storage: 1Ti
+ accessModes:
+ - ReadWriteMany
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: ""
+ volumeMode: Filesystem
+ claimRef:
+ namespace: cheeztv
+ name: cheeztv-media-tv
+ csi:
+ driver: cephfs.csi.ceph.com
+ volumeHandle: cheeztv-media-tv-static
+ nodeStageSecretRef:
+ name: csi-cephfs-secret
+ namespace: csi-cephfs
+ volumeAttributes:
+ staticVolume: "true"
+ clusterID: cephfs_csi_ssd_ec_4_1
+ fsName: cephfs
+ rootPath: /volumes/csi_ssd_ec_4_1/media-tv/4692957d-f5df-4f72-b9c9-56e4ee6d1333
diff --git a/apps/base/cheeztv/pvc-config.yaml b/apps/base/cheeztv/pvc-config.yaml
new file mode 100644
index 0000000..7b2e4bb
--- /dev/null
+++ b/apps/base/cheeztv/pvc-config.yaml
@@ -0,0 +1,17 @@
+---
+# Jellyfin config: metadata images, plugins, subtitles and config XML. Shared
+# ReadWriteMany across replicas (all pods read/write the same library metadata);
+# the main library DB now lives in PostgreSQL, not here (on CephFS, raid5-delete).
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: cheeztv-config
+ namespace: cheeztv
+spec:
+ accessModes:
+ - ReadWriteMany
+ resources:
+ requests:
+ storage: 20Gi
+ storageClassName: cephfs-raid5-delete
+ volumeMode: Filesystem
diff --git a/apps/base/cheeztv/pvc-media-movies.yaml b/apps/base/cheeztv/pvc-media-movies.yaml
new file mode 100644
index 0000000..d2ae938
--- /dev/null
+++ b/apps/base/cheeztv/pvc-media-movies.yaml
@@ -0,0 +1,24 @@
+---
+# Movie library, shared read-many across replicas. Statically bound to the
+# cheeztv-media-movies PV (shared CephFS subvolume also used by arrstack/radarr).
+# storageClassName "" + volumeName disables dynamic provisioning and binds the
+# pre-created static PV.
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: cheeztv-media-movies
+ namespace: cheeztv
+ annotations:
+ # Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is
+ # false cluster-wide, so unannotated PVCs are swept in). Only cheeztv-config
+ # is backed up; the media library is not restic-backup material.
+ k8up.io/backup: "false"
+spec:
+ accessModes:
+ - ReadWriteMany
+ resources:
+ requests:
+ storage: 1Ti
+ storageClassName: ""
+ volumeName: cheeztv-media-movies
+ volumeMode: Filesystem
diff --git a/apps/base/cheeztv/pvc-media-tv.yaml b/apps/base/cheeztv/pvc-media-tv.yaml
new file mode 100644
index 0000000..4976bb1
--- /dev/null
+++ b/apps/base/cheeztv/pvc-media-tv.yaml
@@ -0,0 +1,24 @@
+---
+# TV library, shared read-many across replicas. Statically bound to the
+# cheeztv-media-tv PV (shared CephFS subvolume also used by arrstack/sonarr).
+# storageClassName "" + volumeName disables dynamic provisioning and binds the
+# pre-created static PV.
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: cheeztv-media-tv
+ namespace: cheeztv
+ annotations:
+ # Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is
+ # false cluster-wide, so unannotated PVCs are swept in). Only cheeztv-config
+ # is backed up; the media library is not restic-backup material.
+ k8up.io/backup: "false"
+spec:
+ accessModes:
+ - ReadWriteMany
+ resources:
+ requests:
+ storage: 1Ti
+ storageClassName: ""
+ volumeName: cheeztv-media-tv
+ volumeMode: Filesystem
diff --git a/apps/base/cheeztv/pvc-transcode.yaml b/apps/base/cheeztv/pvc-transcode.yaml
new file mode 100644
index 0000000..bb47d13
--- /dev/null
+++ b/apps/base/cheeztv/pvc-transcode.yaml
@@ -0,0 +1,23 @@
+---
+# Shared transcode scratch. ReadWriteMany is the hard requirement for the HA
+# fork: a taking-over pod must read the in-flight HLS segments written by the
+# pod it replaces. Scratch data (delete reclaim); raid5 avoids the raid6
+# double-parity write penalty on the many small HLS segment writes.
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: cheeztv-transcode
+ namespace: cheeztv
+ annotations:
+ # Exclude from the cheeztv-config k8up Schedule (skipWithoutAnnotation is
+ # false cluster-wide, so unannotated PVCs are swept in). Transcode is RWX
+ # scratch — nothing to back up.
+ k8up.io/backup: "false"
+spec:
+ accessModes:
+ - ReadWriteMany
+ resources:
+ requests:
+ storage: 100Gi
+ storageClassName: cephfs-raid5-delete
+ volumeMode: Filesystem
diff --git a/apps/base/cheeztv/schedule.yaml b/apps/base/cheeztv/schedule.yaml
new file mode 100644
index 0000000..8c5a2b7
--- /dev/null
+++ b/apps/base/cheeztv/schedule.yaml
@@ -0,0 +1,58 @@
+---
+# k8up Schedule: restic backups of the cheeztv-config PVC (library metadata,
+# plugins, config XML) to the dedicated Ceph RGW config-backup bucket. S3 creds
+# come from the cephrgw BucketAccess Secret (cheeztv-config-backup-s3); the
+# restic repo password comes from Vault via the cheeztv-k8up-restic Secret.
+#
+# s3.ceph.unkin.net presents the internal unkin.net CA, which the k8up/restic
+# image does not trust by default, so the reflected vault-ca-cert Secret is
+# mounted into every job pod and pointed at via backend.tlsOptions.caCert.
+apiVersion: k8up.io/v1
+kind: Schedule
+metadata:
+ name: cheeztv-config
+ namespace: cheeztv
+spec:
+ backend:
+ repoPasswordSecretRef:
+ name: cheeztv-k8up-restic
+ key: password
+ s3:
+ endpoint: https://s3.ceph.unkin.net
+ bucket: cheeztv-config-backup
+ accessKeyIDSecretRef:
+ name: cheeztv-config-backup-s3
+ key: AWS_ACCESS_KEY_ID
+ secretAccessKeySecretRef:
+ name: cheeztv-config-backup-s3
+ key: AWS_SECRET_ACCESS_KEY
+ tlsOptions:
+ caCert: /etc/k8up/ca/ca.crt
+ volumeMounts:
+ - name: vault-ca
+ mountPath: /etc/k8up/ca
+ readOnly: true
+ backup:
+ schedule: "0 2 * * *"
+ failedJobsHistoryLimit: 3
+ successfulJobsHistoryLimit: 3
+ volumes:
+ - name: vault-ca
+ secret:
+ secretName: vault-ca-cert
+ prune:
+ schedule: "0 3 * * 0"
+ retention:
+ keepDaily: 14
+ keepWeekly: 8
+ keepMonthly: 12
+ volumes:
+ - name: vault-ca
+ secret:
+ secretName: vault-ca-cert
+ check:
+ schedule: "0 4 * * 0"
+ volumes:
+ - name: vault-ca
+ secret:
+ secretName: vault-ca-cert
diff --git a/apps/base/cheeztv/service.yaml b/apps/base/cheeztv/service.yaml
new file mode 100644
index 0000000..625ece9
--- /dev/null
+++ b/apps/base/cheeztv/service.yaml
@@ -0,0 +1,18 @@
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: cheeztv
+ namespace: cheeztv
+spec:
+ internalTrafficPolicy: Cluster
+ ports:
+ - name: http
+ port: 8096
+ protocol: TCP
+ targetPort: http
+ selector:
+ app: cheeztv
+ # Pin each client to one replica to reduce transcode-session churn/takeover.
+ sessionAffinity: ClientIP
+ type: ClusterIP
diff --git a/apps/base/cheeztv/statefulset.yaml b/apps/base/cheeztv/statefulset.yaml
new file mode 100644
index 0000000..9133dc0
--- /dev/null
+++ b/apps/base/cheeztv/statefulset.yaml
@@ -0,0 +1,253 @@
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: cheeztv
+ namespace: cheeztv
+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 (cheeztv-0/1) are the lease owner identity, hence StatefulSet.
+ replicas: 2
+ serviceName: cheeztv
+ podManagementPolicy: Parallel
+ updateStrategy:
+ type: RollingUpdate
+ selector:
+ matchLabels:
+ app: cheeztv
+ template:
+ metadata:
+ labels:
+ app: cheeztv
+ 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: cheeztv
+ 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: cheeztv
+ 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 (cheeztv-ha additions).
+ - name: Jellyfin__TranscodeStore__RedisConnectionString
+ value: "valkey-cheeztv-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: cheeztv-postgres-app
+ key: username
+ - name: PGPASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: cheeztv-postgres-app
+ key: password
+ - name: PGDB
+ valueFrom:
+ secretKeyRef:
+ name: cheeztv-postgres-app
+ key: dbname
+ - name: POSTGRES_CONNECTION_STRING
+ value: "postgresql://$(PGUSER):$(PGPASSWORD)@cheeztv-postgres-pooler:5432/$(PGDB)"
+ - name: DATABASE_URL
+ value: "postgresql://$(PGUSER):$(PGPASSWORD)@cheeztv-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
+ # Kids-only instance: mount just the tvshows/kids subtree of the
+ # shared TV subvolume (subPath kids) so cheeztv's library never
+ # exposes the adult tree. Path matches fafflix's /media/tv-kids
+ # mount so the same episode resolves identically across instances.
+ mountPath: /media/tv
+ subPath: kids
+ readOnly: true
+ - name: media-movies
+ # Kids-only instance: mount just the movies/kids subtree of the
+ # shared movies subvolume (subPath kids).
+ mountPath: /media/movies
+ subPath: kids
+ readOnly: true
+ volumes:
+ - name: config
+ persistentVolumeClaim:
+ claimName: cheeztv-config
+ - name: transcode
+ persistentVolumeClaim:
+ claimName: cheeztv-transcode
+ - name: media-tv
+ persistentVolumeClaim:
+ claimName: cheeztv-media-tv
+ - name: media-movies
+ persistentVolumeClaim:
+ claimName: cheeztv-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 cheeztv-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
diff --git a/apps/base/cheeztv/valkey.yaml b/apps/base/cheeztv/valkey.yaml
new file mode 100644
index 0000000..e8716ce
--- /dev/null
+++ b/apps/base/cheeztv/valkey.yaml
@@ -0,0 +1,47 @@
+---
+# HA Valkey managed by valkey-operator as Jellyfin's distributed transcode-session
+# store. shards:1 + replicas:2 is one primary with two replicas in a single shard
+# group (three ValkeyNodes total): losing the primary triggers an automatic
+# failover to a replica, so a node/pod loss no longer drops the shared transcode
+# lease state the two Jellyfin replicas coordinate through. The operator always
+# runs Valkey cluster-mode-enabled with protected-mode off and leaves the built-in
+# `default` user passwordless, so clients connect with no auth/TLS (parity with the
+# old open redis); StackExchange.Redis seeds off the single headless service and
+# auto-discovers the topology plus failovers, so the connection string is
+# unchanged. scheduling.node.spread.shard:Required renders pod anti-affinity that
+# keeps the three nodes on distinct hosts (kubernetes.io/hostname), so one host
+# loss removes at most one node. podDisruptionBudget.mode:Cluster lets the operator
+# manage a quorum-aware PDB. Persistence stays omitted so /data is an emptyDir:
+# lease state is ephemeral (30s TTLs), replication+failover already provide
+# redundancy and a replaced node re-syncs from the primary, and an operator-managed
+# PVC cannot carry the k8up.io/backup:"false" annotation the namespace's
+# cluster-wide k8up Schedule needs to skip in-use RWO volumes.
+apiVersion: valkey.io/v1alpha1
+kind: ValkeyCluster
+metadata:
+ name: cheeztv-valkey
+ namespace: cheeztv
+spec:
+ shards: 1
+ replicas: 2
+ image: docker.io/valkey/valkey:9.0.0
+ # redis_exporter sidecar (:9121, port name `metrics`) on every ValkeyNode pod;
+ # the operator manages a dedicated _exporter ACL user for it. Image version
+ # pinned; scraped by valkey-exporter VMPodScrape in vmpodscrape.yaml.
+ exporter:
+ enabled: true
+ image: docker.io/oliver006/redis_exporter:v1.89.0
+ scheduling:
+ node:
+ spread:
+ shard:
+ mode: Required
+ podDisruptionBudget:
+ mode: Cluster
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
diff --git a/apps/base/cheeztv/vaultauth.yaml b/apps/base/cheeztv/vaultauth.yaml
new file mode 100644
index 0000000..b263a01
--- /dev/null
+++ b/apps/base/cheeztv/vaultauth.yaml
@@ -0,0 +1,20 @@
+---
+apiVersion: secrets.hashicorp.com/v1beta1
+kind: VaultAuth
+metadata:
+ name: default
+ namespace: cheeztv
+ annotations:
+ argocd.argoproj.io/sync-wave: "0"
+spec:
+ allowedNamespaces:
+ - cheeztv
+ kubernetes:
+ audiences:
+ - vault
+ role: default
+ serviceAccount: default
+ tokenExpirationSeconds: 600
+ method: kubernetes
+ mount: k8s/au/syd1
+ vaultConnectionRef: vso-system/default
diff --git a/apps/base/cheeztv/vaultstaticsecret.yaml b/apps/base/cheeztv/vaultstaticsecret.yaml
new file mode 100644
index 0000000..1ea18fd
--- /dev/null
+++ b/apps/base/cheeztv/vaultstaticsecret.yaml
@@ -0,0 +1,24 @@
+---
+# restic repository password for the k8up cheeztv-config backups. Seeded at
+# kv/kubernetes/namespace/cheeztv/default/k8up-restic (key: password); the
+# default k8s role's templated policy already grants read here, so no
+# terraform-vault change is needed. VSO syncs it into the cheeztv-k8up-restic
+# Secret that the Schedule references via backend.repoPasswordSecretRef.
+apiVersion: secrets.hashicorp.com/v1beta1
+kind: VaultStaticSecret
+metadata:
+ name: cheeztv-k8up-restic
+ namespace: cheeztv
+ annotations:
+ argocd.argoproj.io/sync-wave: "0"
+spec:
+ destination:
+ create: true
+ name: cheeztv-k8up-restic
+ overwrite: true
+ hmacSecretData: true
+ mount: kv
+ path: kubernetes/namespace/cheeztv/default/k8up-restic
+ refreshAfter: 5m
+ type: kv-v2
+ vaultAuthRef: default
diff --git a/apps/base/cheeztv/vmpodscrape.yaml b/apps/base/cheeztv/vmpodscrape.yaml
new file mode 100644
index 0000000..e972007
--- /dev/null
+++ b/apps/base/cheeztv/vmpodscrape.yaml
@@ -0,0 +1,19 @@
+---
+# Scrape the operator-injected redis_exporter sidecar (:9121, port name
+# `metrics`) on the cheeztv-valkey ValkeyNode pods. The valkey-operator gives
+# its pods fixed labels only (no pod-label passthrough on the ValkeyCluster CR),
+# so select on the operator-managed labels. Picked up by the observability
+# VMAgent (selectAllByDefault).
+apiVersion: operator.victoriametrics.com/v1beta1
+kind: VMPodScrape
+metadata:
+ name: valkey-exporter
+ namespace: cheeztv
+spec:
+ selector:
+ matchLabels:
+ app.kubernetes.io/managed-by: valkey-operator
+ app.kubernetes.io/component: valkey-node
+ podMetricsEndpoints:
+ - port: metrics
+ path: /metrics
diff --git a/apps/base/jellyfin/statefulset.yaml b/apps/base/jellyfin/statefulset.yaml
index e4120c3..8943d53 100644
--- a/apps/base/jellyfin/statefulset.yaml
+++ b/apps/base/jellyfin/statefulset.yaml
@@ -213,6 +213,18 @@ spec:
- 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:
diff --git a/apps/overlays/au-syd1/cheeztv/kustomization.yaml b/apps/overlays/au-syd1/cheeztv/kustomization.yaml
new file mode 100644
index 0000000..b2c0e39
--- /dev/null
+++ b/apps/overlays/au-syd1/cheeztv/kustomization.yaml
@@ -0,0 +1,6 @@
+---
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - ../../../base/cheeztv
diff --git a/argocd/applicationsets/media.yaml b/argocd/applicationsets/media.yaml
index e5c1037..f958b85 100644
--- a/argocd/applicationsets/media.yaml
+++ b/argocd/applicationsets/media.yaml
@@ -11,6 +11,7 @@ spec:
revision: HEAD
directories:
- path: apps/overlays/*/jellyfin
+ - path: apps/overlays/*/cheeztv
- path: apps/overlays/*/arrstack
template:
metadata:
diff --git a/argocd/projects/media.yaml b/argocd/projects/media.yaml
index 4b3f29f..00f179d 100644
--- a/argocd/projects/media.yaml
+++ b/argocd/projects/media.yaml
@@ -11,6 +11,8 @@ spec:
destinations:
- namespace: 'jellyfin'
server: https://kubernetes.default.svc
+ - namespace: 'cheeztv'
+ server: https://kubernetes.default.svc
- namespace: 'arrstack'
server: https://kubernetes.default.svc
clusterResourceWhitelist: