docs: document the HA layer and the v12.0 fork base
The README and fork notes still described upstream and a pre-10.11 snapshot, so there was nothing accurate to hand an operator setting this up. - rewrite the README as an HA setup and configuration guide - add architecture, contributing and transcoding design notes - rewrite FORK-DIFF.md against the v12.0 base
This commit is contained in:
@@ -1,64 +1,551 @@
|
||||
<h1 align="center">Jellyfin</h1>
|
||||
<h3 align="center">The Free Software Media System</h3>
|
||||
# jellyfin-ha
|
||||
|
||||
**A fork of [Jellyfin](https://github.com/jellyfin/jellyfin) adding high-availability transcoding support for multi-pod Kubernetes deployments.**
|
||||
|
||||
[](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)
|
||||
[](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
[](https://github.com/jellyfin/jellyfin)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/jellyfin/jellyfin"><img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/></a>
|
||||
<a href="https://github.com/jellyfin/jellyfin/releases"><img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/></a>
|
||||
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"><img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/></a>
|
||||
<a href="https://hub.docker.com/r/jellyfin/jellyfin"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/></a>
|
||||
<br/>
|
||||
<a href="https://opencollective.com/jellyfin"><img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/></a>
|
||||
<a href="https://features.jellyfin.org"><img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/></a>
|
||||
<a href="https://matrix.to/#/#jellyfinorg:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/></a>
|
||||
<a href="https://github.com/jellyfin/jellyfin/releases.atom"><img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /></a>
|
||||
<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"><img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /></a>
|
||||
</p>
|
||||
## What is this?
|
||||
|
||||
Jellyfin's default assumption is that exactly one server instance is running at a time. Transcode state is held entirely in-memory — when the process dies, so do all active HLS streams. For homelab deployments that want Kubernetes-managed redundancy (rolling restarts, node drain, pod rescheduling), that's a problem.
|
||||
|
||||
This fork adds a thin HA layer on top of unmodified Jellyfin core:
|
||||
|
||||
- **`ITranscodeSessionStore`** — a new interface for durable, distributed transcode session tracking
|
||||
- **`RedisTranscodeSessionStore`** — a Redis-backed implementation using atomic Lua takeover scripts and TTL-based lease expiry
|
||||
- **`NullTranscodeSessionStore`** — a no-op fallback so single-instance deployments work with zero configuration change
|
||||
- **Lease-aware `DeleteTranscodeFileTask`** — coordinates cleanup across replicas so a restarting pod doesn't delete segments another pod is actively streaming
|
||||
- **`SessionManager` HA recovery** — safe takeover of live HLS streams when a pod takes over after lease expiry
|
||||
- **PostgreSQL database provider** — alternative to SQLite for shared-database HA setups (experimental, under `src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL`)
|
||||
|
||||
---
|
||||
|
||||
Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support.
|
||||
## Architecture
|
||||
|
||||
There are no strings attached, no premium licenses or features, and no hidden agendas: just a team that wants to build something better and work together to achieve it. We welcome anyone who is interested in joining us in our quest!
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Jellyfin │ │ Jellyfin │
|
||||
│ Pod A │ │ Pod B │
|
||||
│ │ │ │
|
||||
│ ┌─────────┐ │ │ ┌─────────┐ │
|
||||
│ │Transcode│ │ │ │Transcode│ │
|
||||
│ │Manager │ │ │ │Manager │ │
|
||||
│ └────┬────┘ │ │ └────┬────┘ │
|
||||
└──────┼──────┘ └──────┼──────┘
|
||||
│ │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌───────▼───────┐
|
||||
│ Redis │ ← ITranscodeSessionStore
|
||||
│ (lease store)│ TTL-based ownership
|
||||
└───────────────┘
|
||||
|
||||
For further details, please see [our documentation page](https://jellyfin.org/docs/). To receive the latest updates, get help with Jellyfin, and join the community, please visit [one of our communication channels](https://jellyfin.org/docs/general/getting-help). For more information about the project, please see our [about page](https://jellyfin.org/docs/general/about).
|
||||
┌─────────────────────┐
|
||||
│ Shared NAS / NFS │ ← HLS segments + manifests
|
||||
│ (shared storage) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
<strong>Want to get started?</strong><br/>
|
||||
Check out our <a href="https://jellyfin.org/downloads">downloads page</a> or our <a href="https://jellyfin.org/docs/general/installation/">installation guide</a>, then see our <a href="https://jellyfin.org/docs/general/quick-start">quick start guide</a>. You can also <a href="https://jellyfin.org/docs/general/installation/source">build from source</a>.<br/>
|
||||
**How takeover works:**
|
||||
|
||||
<strong>Something not working right?</strong><br/>
|
||||
Open an <a href="https://jellyfin.org/docs/general/contributing/issues">Issue</a> on GitHub.<br/>
|
||||
|
||||
<strong>Want to contribute?</strong><br/>
|
||||
Check out our <a href="https://jellyfin.org/contribute">contributing choose-your-own-adventure</a> to see where you can help, then see our <a href="https://jellyfin.org/docs/general/contributing/">contributing guide</a> and our <a href="https://jellyfin.org/docs/general/community-standards">community standards</a>.<br/>
|
||||
|
||||
<strong>New idea or improvement?</strong><br/>
|
||||
Check out our <a href="https://features.jellyfin.org/?view=most-wanted">feature request hub</a>.<br/>
|
||||
|
||||
<strong>Don't see Jellyfin in your language?</strong><br/>
|
||||
Check out our <a href="https://translate.jellyfin.org">Weblate instance</a> to help translate Jellyfin and its subprojects.<br/>
|
||||
|
||||
<a href="https://translate.jellyfin.org/engage/jellyfin/?utm_source=widget">
|
||||
<img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-web/multi-auto.svg" alt="Detailed Translation Status"/>
|
||||
</a>
|
||||
1. Pod A starts an HLS transcode and writes a `TranscodeSession` to Redis with a 30-second lease.
|
||||
2. Pod A renews the lease every `LeaseDurationSeconds / 2` seconds.
|
||||
3. If Pod A dies, the lease expires in Redis after 30 seconds.
|
||||
4. Pod B receives a client request for the same play session, calls `TryTakeoverAsync`, and atomically claims ownership via a Lua script.
|
||||
5. Pod B resumes FFmpeg from the last durable segment index. The client sees a brief stutter, not an error.
|
||||
|
||||
---
|
||||
|
||||
## Jellyfin Server
|
||||
## Quick Start
|
||||
|
||||
This repository contains the code for Jellyfin's backend server. Note that this is only one of many projects under the Jellyfin GitHub [organization](https://github.com/jellyfin/) on GitHub. If you want to contribute, you can start by checking out our [documentation](https://jellyfin.org/docs/general/contributing/index.html) to see what to work on.
|
||||
### Single instance (no Redis)
|
||||
|
||||
## Server Development
|
||||
No configuration required. `NullTranscodeSessionStore` is used automatically. Behavior is identical to upstream Jellyfin.
|
||||
|
||||
These instructions will help you get set up with a local development environment in order to contribute to this repository. Before you start, please be sure to completely read our [guidelines on development contributions](https://jellyfin.org/docs/general/contributing/development.html). Note that this project is supported on all major operating systems except FreeBSD, which is still incompatible.
|
||||
```bash
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /var/lib/jellyfin \
|
||||
--webdir /usr/share/jellyfin/web
|
||||
```
|
||||
|
||||
### HA mode with Redis
|
||||
|
||||
Set the `Jellyfin:TranscodeStore:RedisConnectionString` configuration key. You can pass it as an environment variable, a `DOTNET_` prefixed env var, or in a JSON config file.
|
||||
|
||||
**Environment variable:**
|
||||
|
||||
```bash
|
||||
export Jellyfin__TranscodeStore__RedisConnectionString="redis:6379"
|
||||
export Jellyfin__TranscodeStore__LeaseDurationSeconds="30"
|
||||
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /var/lib/jellyfin \
|
||||
--webdir /usr/share/jellyfin/web
|
||||
```
|
||||
|
||||
**`appsettings.json` section:**
|
||||
|
||||
```json
|
||||
{
|
||||
"Jellyfin": {
|
||||
"TranscodeStore": {
|
||||
"RedisConnectionString": "redis:6379,abortConnect=false",
|
||||
"LeaseDurationSeconds": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `RedisConnectionString` is set, `RedisTranscodeSessionStore` is registered in DI. If the Redis connection fails at startup, the server throws and refuses to start — this is intentional so you don't silently fall back to broken HA behavior.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `Jellyfin:TranscodeStore:RedisConnectionString` | _(empty)_ | StackExchange.Redis connection string. Empty = single-instance mode. |
|
||||
| `Jellyfin:TranscodeStore:LeaseDurationSeconds` | `30` | How long a pod's transcode lease is valid before another pod may take over. |
|
||||
|
||||
### Redis connection string examples
|
||||
|
||||
```
|
||||
# Standalone Redis
|
||||
redis:6379
|
||||
|
||||
# With password
|
||||
redis:6379,password=secret
|
||||
|
||||
# With TLS
|
||||
redis.example.com:6380,ssl=true,abortConnect=false
|
||||
|
||||
# Redis Sentinel
|
||||
sentinel-host:26379,serviceName=mymaster
|
||||
```
|
||||
|
||||
Standard [StackExchange.Redis connection string format](https://stackexchange.github.io/StackExchange.Redis/Configuration) is accepted.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
> **This project is designed to run as a container.** Running it as a bare `dotnet` process is fine for development and testing, but the HA benefits only materialize when you have multiple replicas managed by a container orchestrator. Docker Compose gets you Redis + Jellyfin wired together locally. Kubernetes (k3s, k8s, or a managed cloud cluster) gets you the actual pod-death-and-recovery story.
|
||||
>
|
||||
> Don't have a Kubernetes cluster yet? [DigitalOcean Kubernetes](https://www.digitalocean.com/?refcode=b9012919f7ff&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge) is the fastest path to a managed cluster if you don't want to run your own nodes.
|
||||
|
||||
---
|
||||
|
||||
### Option 1 — Local HA with Docker Compose
|
||||
|
||||
The simplest way to test the full HA stack locally: two Jellyfin replicas sharing a Redis instance and a local volume for transcode output.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
jellyfin-1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.runtime
|
||||
environment:
|
||||
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
|
||||
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
|
||||
JELLYFIN_HA_POD_NAME: "jellyfin-1"
|
||||
volumes:
|
||||
- ./data/config:/config
|
||||
- ./data/media:/media:ro
|
||||
- transcode-tmp:/transcode
|
||||
ports:
|
||||
- "8096:8096"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
jellyfin-2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.runtime
|
||||
environment:
|
||||
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
|
||||
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
|
||||
JELLYFIN_HA_POD_NAME: "jellyfin-2"
|
||||
volumes:
|
||||
- ./data/config:/config
|
||||
- ./data/media:/media:ro
|
||||
- transcode-tmp:/transcode
|
||||
ports:
|
||||
- "8097:8096"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
transcode-tmp:
|
||||
```
|
||||
|
||||
Build the image first (the `dotnet publish` step runs outside Docker for I/O performance):
|
||||
|
||||
```bash
|
||||
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--output ./publish-output
|
||||
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Both replicas share the `transcode-tmp` volume and register sessions in Redis. Kill one container mid-stream (`docker kill jellyfin-1`) and the other takes over within `LeaseDurationSeconds`.
|
||||
|
||||
---
|
||||
|
||||
### Option 2 — Kubernetes (k3s / k8s)
|
||||
|
||||
This is the intended production deployment. You need:
|
||||
|
||||
1. A Kubernetes cluster (k3s, kubeadm, EKS, GKE, DigitalOcean Kubernetes, etc.)
|
||||
2. A Redis instance (in-cluster or managed)
|
||||
3. A `ReadWriteMany` storage class for shared transcode scratch space (NFS, Longhorn RWX, Ceph RBD, or a cloud-managed RWX PVC)
|
||||
|
||||
#### Redis (in-cluster, standalone)
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
```
|
||||
|
||||
#### Redis connection secret
|
||||
|
||||
```bash
|
||||
kubectl create secret generic jellyfin-redis \
|
||||
--namespace jellyfin \
|
||||
--from-literal=connection-string="redis.jellyfin.svc.cluster.local:6379,abortConnect=false"
|
||||
```
|
||||
|
||||
#### Shared transcode PVC (RWX)
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: jellyfin-transcode
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: longhorn # or nfs-client, csi-driver-nfs, etc.
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
```
|
||||
|
||||
#### Jellyfin Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: jellyfin
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: jellyfin
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: jellyfin
|
||||
spec:
|
||||
containers:
|
||||
- name: jellyfin
|
||||
image: your-registry/jellyfin-ha:latest
|
||||
ports:
|
||||
- containerPort: 8096
|
||||
env:
|
||||
- name: Jellyfin__TranscodeStore__RedisConnectionString
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jellyfin-redis
|
||||
key: connection-string
|
||||
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
|
||||
value: "30"
|
||||
- name: JELLYFIN_HA_POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
- name: media
|
||||
mountPath: /media
|
||||
readOnly: true
|
||||
- name: transcode
|
||||
mountPath: /transcode
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8096
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8096
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: jellyfin-config # RWO is fine — config is single-writer
|
||||
- name: media
|
||||
nfs:
|
||||
server: your-nas.local
|
||||
path: /media
|
||||
- name: transcode
|
||||
persistentVolumeClaim:
|
||||
claimName: jellyfin-transcode # Must be RWX
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: jellyfin
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: jellyfin
|
||||
ports:
|
||||
- port: 8096
|
||||
targetPort: 8096
|
||||
```
|
||||
|
||||
#### Important: storage requirements
|
||||
|
||||
| Volume | Access mode | Why |
|
||||
|--------|-------------|-----|
|
||||
| Config (`/config`) | `ReadWriteOnce` | One writer, SQLite DB lives here |
|
||||
| Media (`/media`) | `ReadOnlyMany` | All pods read the same library |
|
||||
| Transcode (`/transcode`) | **`ReadWriteMany`** | Pods read each other's HLS segments during takeover |
|
||||
|
||||
The transcode volume is the critical one. If it's `ReadWriteOnce`, pod takeover will fail because Pod B cannot read the `.ts` segments Pod A wrote. Use NFS, Longhorn with RWX enabled, or a cloud-managed RWX storage class.
|
||||
|
||||
#### Building the image
|
||||
|
||||
```bash
|
||||
# Publish (run on host, not inside Docker)
|
||||
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--output ./publish-output
|
||||
|
||||
# Build for amd64 (required for most clusters)
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--provenance=false \
|
||||
-f Dockerfile.runtime \
|
||||
-t your-registry/jellyfin-ha:latest \
|
||||
--push .
|
||||
```
|
||||
|
||||
> Note: `--provenance=false` is required if your cluster runs containerd (k3s, most kubeadm setups). Without it, Docker adds OCI attestation manifests that containerd cannot resolve.
|
||||
|
||||
---
|
||||
|
||||
### Option 3 — Bare dotnet (development only)
|
||||
|
||||
For local development and testing without containers. HA mode still works — you just run two terminal sessions pointing at the same Redis and a shared local directory.
|
||||
|
||||
**Terminal 1:**
|
||||
|
||||
```bash
|
||||
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
|
||||
export JELLYFIN_HA_POD_NAME="dev-pod-1"
|
||||
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /tmp/jellyfin-1/data \
|
||||
--cachedir /tmp/jellyfin-1/cache \
|
||||
--transcodes /tmp/jellyfin-shared/transcode \
|
||||
--webdir /usr/share/jellyfin/web \
|
||||
--port 8096
|
||||
```
|
||||
|
||||
**Terminal 2:**
|
||||
|
||||
```bash
|
||||
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
|
||||
export JELLYFIN_HA_POD_NAME="dev-pod-2"
|
||||
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /tmp/jellyfin-2/data \
|
||||
--cachedir /tmp/jellyfin-2/cache \
|
||||
--transcodes /tmp/jellyfin-shared/transcode \
|
||||
--webdir /usr/share/jellyfin/web \
|
||||
--port 8097
|
||||
```
|
||||
|
||||
Both instances share `/tmp/jellyfin-shared/transcode`. Kill one process mid-stream to test takeover. Start a local Redis with `redis-server` or `docker run -p 6379:6379 redis:7-alpine`.
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL (experimental)
|
||||
|
||||
This fork includes a PostgreSQL database provider under `src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL`. It is experimental — the SQLite provider remains the default and the recommended choice for most deployments.
|
||||
|
||||
To use PostgreSQL, set the migration provider at startup and run migrations:
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add InitialCreate \
|
||||
--project "src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL" \
|
||||
-- --migration-provider Jellyfin-PostgreSQL
|
||||
```
|
||||
|
||||
See `src/Jellyfin.Database/readme.md` for full migration instructions.
|
||||
|
||||
---
|
||||
|
||||
## Building and Testing
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before the project can be built, you must first install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet) on your system.
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
dotnet build Jellyfin.Server/Jellyfin.Server.csproj
|
||||
```
|
||||
|
||||
### Run all tests
|
||||
|
||||
```bash
|
||||
dotnet test Jellyfin.sln \
|
||||
--configuration Release \
|
||||
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
|
||||
```
|
||||
|
||||
### Run HA-specific tests
|
||||
|
||||
The transcode session store and HA recovery tests live in:
|
||||
|
||||
- `tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs`
|
||||
- `tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs`
|
||||
|
||||
```bash
|
||||
dotnet test tests/Jellyfin.Server.Implementations.Tests \
|
||||
--configuration Release \
|
||||
--filter "FullyQualifiedName~TranscodeSession"
|
||||
```
|
||||
|
||||
### Run with code coverage
|
||||
|
||||
```bash
|
||||
dotnet test Jellyfin.sln \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
MediaBrowser.Controller/MediaEncoding/
|
||||
ITranscodeSessionStore.cs ← Interface (DI contract)
|
||||
TranscodeSession.cs ← Session record model
|
||||
TranscodeStoreOptions.cs ← Configuration options
|
||||
NullTranscodeSessionStore.cs ← No-op, single-instance fallback
|
||||
|
||||
Emby.Server.Implementations/MediaEncoding/
|
||||
RedisTranscodeSessionStore.cs ← Redis-backed HA implementation
|
||||
|
||||
src/Jellyfin.Database/
|
||||
Jellyfin.Database.Providers.PostgreSQL/ ← Experimental PostgreSQL provider
|
||||
|
||||
tests/
|
||||
Jellyfin.Server.Implementations.Tests/MediaEncoding/
|
||||
RedisTranscodeSessionStoreTests.cs
|
||||
Jellyfin.MediaEncoding.Tests/Fakes/
|
||||
InMemoryTranscodeSessionStore.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a personal experiment, not an officially maintained fork. Issues and PRs are welcome but response time may vary.
|
||||
|
||||
If you're interested in getting proper HA transcoding into upstream Jellyfin, that conversation belongs in the [upstream repo](https://github.com/jellyfin/jellyfin). The changes here are deliberately narrow and designed to be upstream-friendly if there's maintainer interest.
|
||||
|
||||
**Code conventions** follow the upstream Jellyfin rules:
|
||||
- `async`/`await` everywhere — no `.Result` or `.Wait()`
|
||||
- All public members need XML doc comments
|
||||
- Use `Directory.Packages.props` for NuGet versions — never add `Version=` to a `<PackageReference>`
|
||||
- `.NET 10` required
|
||||
- Warnings are treated as errors
|
||||
|
||||
---
|
||||
|
||||
## Relationship to upstream
|
||||
|
||||
This fork tracks [jellyfin/jellyfin](https://github.com/jellyfin/jellyfin) release tags and is currently based on `v12.0`. The HA additions are intentionally isolated to:
|
||||
|
||||
1. New interfaces and models in `MediaBrowser.Controller`
|
||||
2. New implementations in `Emby.Server.Implementations`
|
||||
3. DI wiring in `Jellyfin.Server/CoreAppHost.cs`
|
||||
4. New test projects
|
||||
|
||||
No core Jellyfin logic was modified — only extended via existing DI extension points.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0, same as upstream Jellyfin. See [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
*Upstream README preserved below for reference.*
|
||||
|
||||
---
|
||||
|
||||
Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 6 development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2022) and [Visual Studio Code](https://code.visualstudio.com/Download).
|
||||
|
||||
@@ -177,5 +664,7 @@ Since this is a common scenario, there is also a separate launch profile defined
|
||||
This project is supported by:
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a>
|
||||
|
||||
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
> **Last updated: 2026-03-04**
|
||||
|
||||
# Jellyfin Server Architecture
|
||||
|
||||
High-level overview of the Jellyfin server structure, layer responsibilities, and key subsystems.
|
||||
|
||||
## Runtime
|
||||
|
||||
| Component | Value |
|
||||
|---|---|
|
||||
| Framework | .NET 10 / ASP.NET Core 10 |
|
||||
| Target | `net10.0` |
|
||||
| Entry point | `Jellyfin.Server` |
|
||||
| Version | `12.0.0` (see `SharedVersion.cs`) |
|
||||
|
||||
---
|
||||
|
||||
## Layer Diagram
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ HTTP Clients │
|
||||
│ (Jellyfin Web, mobile apps, 3rd-party) │
|
||||
└────────────────────────┬──────────────────────────────────┘
|
||||
│ REST / WebSocket
|
||||
┌────────────────────────▼──────────────────────────────────┐
|
||||
│ Jellyfin.Api │
|
||||
│ ASP.NET Core controllers, middleware, auth, Swashbuckle │
|
||||
└────────────────────────┬──────────────────────────────────┘
|
||||
│ Interfaces (ILibraryManager, etc.)
|
||||
┌────────────────────────▼──────────────────────────────────┐
|
||||
│ MediaBrowser.Controller │
|
||||
│ Core domain interfaces — no implementation here │
|
||||
└────────────────────────┬──────────────────────────────────┘
|
||||
│ Implementations
|
||||
┌────────────────────────▼──────────────────────────────────┐
|
||||
│ Emby.Server.Implementations / Jellyfin.Server.Impl │
|
||||
│ Library manager, item repos, scheduled tasks, HTTP server│
|
||||
└────────┬───────────────────────────────┬──────────────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ Jellyfin.Data │ │ MediaBrowser │
|
||||
│ EF Core DbCtx │ │ MediaEncoding │
|
||||
│ SQLite via │ │ FFmpeg, HLS, │
|
||||
│ Microsoft.Data │ │ Trickplay │
|
||||
│ .Sqlite │ └─────────────────┘
|
||||
└─────────────────┘
|
||||
│
|
||||
┌────────▼─────────────────────────────────────────────────┐
|
||||
│ MediaBrowser.Model │
|
||||
│ Pure DTOs, enums, no logic (shared by all layers) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Responsibilities
|
||||
|
||||
### `Jellyfin.Server`
|
||||
|
||||
Entry point. Handles:
|
||||
- CLI argument parsing (`CommandLineParser`)
|
||||
- Serilog configuration (console, file, Graylog sinks)
|
||||
- DI container wiring (`ApplicationHost`)
|
||||
- ASP.NET Core host startup
|
||||
|
||||
### `Jellyfin.Api`
|
||||
|
||||
All HTTP surface. Handles:
|
||||
- ASP.NET Core controllers (`Controllers/`)
|
||||
- Authentication middleware (`Auth/`)
|
||||
- Swashbuckle/OpenAPI configuration
|
||||
- Request/response formatting (camelCase + PascalCase JSON)
|
||||
- WebSocket listeners (`WebSocketListeners/`)
|
||||
|
||||
Controllers inherit from `BaseJellyfinApiController` which sets default route, produces JSON, and provides typed `Ok<T>()` helpers.
|
||||
|
||||
### `MediaBrowser.Controller`
|
||||
|
||||
Core domain interfaces. Key examples:
|
||||
- `ILibraryManager` — media library operations
|
||||
- `IMediaEncoder` — FFmpeg wrapper
|
||||
- `IProviderManager` — metadata provider coordination
|
||||
- `IUserManager` — user management
|
||||
- `IPlaybackManager` — playback session tracking
|
||||
|
||||
**No implementations live here.** This keeps the domain decoupled from infrastructure.
|
||||
|
||||
### `Emby.Server.Implementations`
|
||||
|
||||
Primary implementation assembly. Contains:
|
||||
- `ApplicationHost.cs` — DI wiring and startup
|
||||
- `Data/` — SQLite queries and EF Core repositories
|
||||
- `Library/` — `LibraryManager`, `LibraryMonitor`
|
||||
- `Images/` — image processing pipeline (SkiaSharp)
|
||||
- `HttpServer/` — HTTP server wiring
|
||||
|
||||
### `Jellyfin.Server.Implementations`
|
||||
|
||||
Secondary implementation assembly split from `Emby.Server.Implementations`. Contains newer implementations using EF Core patterns.
|
||||
|
||||
### `Jellyfin.Data`
|
||||
|
||||
EF Core data models and `DbContext`. Migrations managed here.
|
||||
|
||||
### `MediaBrowser.Model`
|
||||
|
||||
Pure data-transfer objects (DTOs) and enums. No logic. Consumed by all layers and by external clients. Changes here are API-breaking.
|
||||
|
||||
### `MediaBrowser.Providers`
|
||||
|
||||
Online metadata providers:
|
||||
- TMDB (movies, TV)
|
||||
- MusicBrainz (audio)
|
||||
- OMDB
|
||||
- TV Maze, TheTVDB
|
||||
|
||||
Uses `IMetadataProvider<T>` interface from `MediaBrowser.Controller`.
|
||||
|
||||
### `MediaBrowser.MediaEncoding`
|
||||
|
||||
FFmpeg process management, HLS streaming, keyframe extraction, subtitle transcoding, trickplay image generation.
|
||||
|
||||
### `Emby.Naming`
|
||||
|
||||
Media file path parsing — resolves series/season/episode structure, detects extras, parses video codecs from filenames.
|
||||
|
||||
### `MediaBrowser.LocalMetadata` / `MediaBrowser.XbmcMetadata`
|
||||
|
||||
Local NFO/XML metadata providers (Kodi-compatible `.nfo` sidecar files).
|
||||
|
||||
### `src/Jellyfin.CodeAnalysis`
|
||||
|
||||
Custom Roslyn analyzer. Runs only in Debug builds. Enforces project-specific rules.
|
||||
|
||||
---
|
||||
|
||||
## Key Subsystems
|
||||
|
||||
### Authentication
|
||||
|
||||
- Session-based API keys (stored in SQLite)
|
||||
- Quick Connect (pairing flow)
|
||||
- Auth middleware in `Jellyfin.Api/Auth/`
|
||||
- Policies defined in `Jellyfin.Api/Constants/Policies.cs`
|
||||
|
||||
### Library Scanning
|
||||
|
||||
1. `LibraryMonitor` watches filesystem for changes
|
||||
2. `LibraryManager` resolves paths → `BaseItem` subclasses
|
||||
3. `Emby.Naming` parses filenames → metadata hints
|
||||
4. `IProviderManager` fetches remote metadata and saves locally
|
||||
5. Results persisted to SQLite via EF Core
|
||||
|
||||
### Transcoding
|
||||
|
||||
1. Client requests a stream via `MediaInfoController` or `DynamicHlsController`
|
||||
2. `MediaInfoHelper` determines if transcoding is needed (codec matrix)
|
||||
3. `MediaEncoder` spawns an FFmpeg subprocess with computed arguments
|
||||
4. HLS segments or direct stream served via `AudioController` / `VideosController`
|
||||
|
||||
### Metrics
|
||||
|
||||
prometheus-net serves metrics at `/metrics`. Key meters:
|
||||
- `prometheus-net.AspNetCore` — HTTP request duration/count
|
||||
- `prometheus-net.DotNetRuntime` — GC, thread pool, JIT metrics
|
||||
- Custom counters can be added via `Metrics.CreateCounter(...)` in any service
|
||||
|
||||
### Logging
|
||||
|
||||
Serilog pipeline:
|
||||
- Console sink (structured)
|
||||
- File sink (rolling, default `%APPDATA%/jellyfin/logs/`)
|
||||
- Graylog GELF sink (optional, configured via `logging.json`)
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
SQLite database at `{DataDir}/data/jellyfin.db`. Accessed via:
|
||||
- EF Core (`Jellyfin.Data.JellyfinDbContext`) for new data access
|
||||
- `Microsoft.Data.Sqlite` direct queries for legacy paths
|
||||
|
||||
**All EF Core operations must use async methods** (`ToListAsync`, `FirstOrDefaultAsync`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Test Layout
|
||||
|
||||
```
|
||||
tests/
|
||||
Jellyfin.Api.Tests/ Controller + middleware unit tests
|
||||
Jellyfin.Common.Tests/ MediaBrowser.Common utilities
|
||||
Jellyfin.Controller.Tests/ Interface contracts and helpers
|
||||
Jellyfin.MediaEncoding.Tests/ FFmpeg argument building
|
||||
Jellyfin.Naming.Tests/ File path parsing
|
||||
Jellyfin.Providers.Tests/ Provider logic
|
||||
Jellyfin.Server.Integration.Tests/ Full-stack HTTP tests + OpenAPI spec gen
|
||||
Jellyfin.Server.Tests/ Server startup and DI tests
|
||||
```
|
||||
|
||||
Test stack: xUnit + AutoFixture + Moq + FsCheck. See `.github/instructions/testing.instructions.md`.
|
||||
@@ -0,0 +1,164 @@
|
||||
> **Last updated: 2026-03-04**
|
||||
|
||||
# Contributing to Jellyfin Server
|
||||
|
||||
This guide covers everything you need to develop, build, test, and submit changes to the Jellyfin server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Notes |
|
||||
|---|---|---|
|
||||
| .NET SDK | 10.0.x | See `global.json` — `rollForward: latestMinor` |
|
||||
| Git | any recent | `git clone` with submodules not required |
|
||||
| FFmpeg | 7.x | Required for transcoding tests; install via devcontainer or manually |
|
||||
| Docker | optional | For devcontainer workflow |
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew install dotnet
|
||||
```
|
||||
|
||||
### Linux (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
wget https://dot.net/v1/dotnet-install.sh && bash dotnet-install.sh --channel 10.0
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Download the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10) installer.
|
||||
|
||||
### DevContainer (recommended for new contributors)
|
||||
|
||||
Open the repo in VS Code and accept the "Reopen in Container" prompt. The devcontainer installs:
|
||||
- .NET 10
|
||||
- FFmpeg
|
||||
- All recommended VS Code extensions
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build the server entry point
|
||||
dotnet build Jellyfin.Server/Jellyfin.Server.csproj
|
||||
|
||||
# Build the entire solution (all projects)
|
||||
dotnet build Jellyfin.sln
|
||||
```
|
||||
|
||||
Debug builds activate all code analyzers (StyleCop, BannedApiAnalyzers, IDisposableAnalyzers, MultithreadingAnalyzer). **Expect build failures if your code has missing XML docs or uses banned APIs.**
|
||||
|
||||
---
|
||||
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
-- --datadir /tmp/jellyfin-data --webdir /tmp/jellyfin-web --nowebclient
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:8096` by default.
|
||||
|
||||
---
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# Run all tests (cross-platform matrix: Linux, macOS, Windows)
|
||||
dotnet test Jellyfin.sln --configuration Release --verbosity minimal
|
||||
|
||||
# Run a single test project
|
||||
dotnet test tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
|
||||
|
||||
# Run tests matching a name filter
|
||||
dotnet test Jellyfin.sln --filter "ClassName=MyServiceTests"
|
||||
|
||||
# Run with code coverage
|
||||
dotnet test Jellyfin.sln \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings
|
||||
```
|
||||
|
||||
Coverage output: `merged/Cobertura.xml` (merged by ReportGenerator in CI).
|
||||
|
||||
### Regenerate OpenAPI Spec
|
||||
|
||||
After adding or changing any API endpoint:
|
||||
|
||||
```bash
|
||||
dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj \
|
||||
-c Release \
|
||||
--filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
```
|
||||
|
||||
Commit the updated `openapi.json` — the CI diff job will flag unintentional breaking changes.
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
All style rules are enforced by the compiler in Debug builds. Key rules:
|
||||
|
||||
- **Nullable enabled** — mark nullable types with `?`, never silence with `null!` without a comment
|
||||
- **Warnings as errors** — fix every warning; do not suppress with `#pragma warning disable`
|
||||
- **XML docs** — every `public` type and member must have `/// <summary>`
|
||||
- **No `Task.Result`** — always `await` instead
|
||||
- **Central NuGet versions** — versions in `Directory.Packages.props` only, never in `.csproj`
|
||||
- **File-scoped namespaces** — use `namespace Jellyfin.Example;` (not block-scoped)
|
||||
|
||||
See `.github/instructions/csharp.instructions.md` for the full ruleset.
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Fork the repo and create a feature branch from `main`
|
||||
2. Make your changes; ensure `dotnet build` and `dotnet test` pass locally
|
||||
3. Fill out the PR template (`.github/pull_request_template.md`):
|
||||
- **Changes**: 1–5 sentence summary
|
||||
- **Issues**: tag with `Fixes #NNN`
|
||||
4. CI runs automatically:
|
||||
- `ci-tests.yml` — tests on Linux, macOS, Windows
|
||||
- `ci-openapi.yml` — OpenAPI diff
|
||||
- `ci-codeql-analysis.yml` — security scan
|
||||
5. A maintainer will review and merge
|
||||
|
||||
### Title format
|
||||
|
||||
Use the imperative mood:
|
||||
- ✅ `Add lyrics endpoint for audio items`
|
||||
- ✅ `Fix null reference in LibraryController`
|
||||
- ❌ `Added lyrics endpoint`
|
||||
- ❌ `Fixed null reference`
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Package Dependency
|
||||
|
||||
1. Add the version to `Directory.Packages.props`:
|
||||
```xml
|
||||
<PackageVersion Include="SomePackage" Version="1.2.3" />
|
||||
```
|
||||
2. Add the reference to the relevant `.csproj` (no `Version=` attribute):
|
||||
```xml
|
||||
<PackageReference Include="SomePackage" />
|
||||
```
|
||||
|
||||
**Never** specify both a version in `Directory.Packages.props` AND in the `.csproj` — that causes `NU1008`.
|
||||
|
||||
---
|
||||
|
||||
## Project Conventions
|
||||
|
||||
See `.github/instructions/` for detailed instructions per concern:
|
||||
|
||||
| Topic | File |
|
||||
|---|---|
|
||||
| C# style | `csharp.instructions.md` |
|
||||
| API controllers | `api.instructions.md` |
|
||||
| Tests | `testing.instructions.md` |
|
||||
| CI/CD workflows | `ci-cd.instructions.md` |
|
||||
| Documentation | `docs.instructions.md` |
|
||||
@@ -0,0 +1,124 @@
|
||||
# Fork Diff: `unkin/jellyfin-ha-src` vs `jellyfin/jellyfin`
|
||||
|
||||
> **Base:** `v12.0` (`6c073e19ddf604b2369c638716164fdab4c952dc`)
|
||||
> **Head:** `origin/main`
|
||||
> **Summary:** 56 files added · 16 upstream files modified · 15 upstream files deleted
|
||||
|
||||
---
|
||||
|
||||
## What changed and why
|
||||
|
||||
This fork adds a **high-availability layer** so Jellyfin can run as more than one
|
||||
replica against shared storage. The design principle: extend through dependency
|
||||
injection, touch as little upstream code as possible. No core media, library,
|
||||
auth or plugin logic is rewritten.
|
||||
|
||||
| Bucket | Files | Lines added |
|
||||
|--------|-------|-------------|
|
||||
| HA contracts and models (`MediaBrowser.Controller`) | 8 | ~330 |
|
||||
| Redis implementations (`Emby.Server.Implementations`) | 2 | ~350 |
|
||||
| PostgreSQL database provider | 6 | ~4,980 |
|
||||
| SQLite to PostgreSQL migration tool | 6 | ~720 |
|
||||
| Modified upstream files | 16 | ~420 |
|
||||
| Tests | 14 | ~2,790 |
|
||||
| Helm chart | 17 | ~1,370 |
|
||||
| CI and container build | 3 | ~175 |
|
||||
|
||||
---
|
||||
|
||||
## New components
|
||||
|
||||
### Transcode session store
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs` | DI contract for durable transcode and live stream session tracking |
|
||||
| `MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs` | Session record: owning pod, lease expiry, manifest and segment paths, last durable segment |
|
||||
| `MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs` | Record of an open live stream and the pod holding it |
|
||||
| `MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs` | `RedisConnectionString` and `LeaseDurationSeconds` |
|
||||
| `MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs` | No-op store used when no Redis connection is configured |
|
||||
| `Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs` | Redis store; sessions under `jellyfin:transcode:{playSessionId}`, live streams under `jellyfin:livestream:{liveStreamId}:{sessionId}`, key TTL mirrors the lease |
|
||||
|
||||
Lease takeover runs as a single Lua script, so concurrent pods cannot both claim
|
||||
an expired lease:
|
||||
|
||||
```lua
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return 0 end
|
||||
local session = cjson.decode(raw)
|
||||
if session['LeaseExpiresUtc'] > tonumber(ARGV[1]) then return 0 end
|
||||
session['OwnerPod'] = ARGV[2]
|
||||
-- update expiry and SET with PX in the same script
|
||||
return 1
|
||||
```
|
||||
|
||||
### Scan-leader lease
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MediaBrowser.Controller/ScheduledTasks/IScanLeaderLease.cs` | DI contract for the leader lease |
|
||||
| `MediaBrowser.Controller/ScheduledTasks/ScanLeaderOptions.cs` | `Enabled`, `LeaseDurationSeconds`, `GatedTaskKeys` |
|
||||
| `MediaBrowser.Controller/ScheduledTasks/NullScanLeaderLease.cs` | Always-leader default, preserving single-instance behaviour |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/RedisScanLeaderLease.cs` | Redis TTL lease; an unreachable Redis is treated as holding the lease |
|
||||
|
||||
Gated by default: `RefreshLibrary`, `RefreshPeople`, `RefreshChapterImages`,
|
||||
`AudioNormalization`, `TaskExtractMediaSegments`, `KeyframeExtraction`,
|
||||
`CleanupUserDataTask`, `OptimizeDatabaseTask`. Only timer-driven runs are gated;
|
||||
manual and API-triggered runs always execute locally.
|
||||
|
||||
### PostgreSQL provider
|
||||
|
||||
`src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/` is an EF Core
|
||||
provider parallel to the SQLite one, selected with
|
||||
`DatabaseType: Jellyfin-PostgreSQL`. It carries a single initial migration
|
||||
generated against the `v12.0` model. SQLite remains the default, so existing
|
||||
deployments are unaffected.
|
||||
|
||||
`tools/Jellyfin.DbMigrator/` moves an existing `jellyfin.db` into PostgreSQL,
|
||||
optionally uploading a pre-migration copy of the SQLite file to S3.
|
||||
|
||||
---
|
||||
|
||||
## Modified upstream files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `Jellyfin.Server/CoreAppHost.cs` (+46) | Registers the Redis or null transcode store and scan-leader lease from startup config |
|
||||
| `Jellyfin.Api/Controllers/DynamicHlsController.cs` (+133/-9) | Registers the play session, runs lease renewal, and shortens segments when resuming a stored session |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` (+52/-6) | Keeps files belonging to an active session in the store |
|
||||
| `Emby.Server.Implementations/Session/SessionManager.cs` (+37/-4) | Persists and deletes the durable live stream record |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs` (+28/-1) | Skips timer-driven gated tasks without the leader lease |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/TaskManager.cs` (+12/-2) | Passes the lease and options to each worker |
|
||||
| `Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs` (+53) | Registers the PostgreSQL provider and a pooled `NpgsqlDataSource` |
|
||||
| `Jellyfin.Server/Program.cs` (+5) | Exposes `IServerConfigurationManager` to the startup migration container |
|
||||
| `MediaBrowser.Model/Configuration/EncodingOptions.cs` (+16) | Adds `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount` |
|
||||
| `Directory.Packages.props`, `Jellyfin.sln`, four `.csproj` files | New packages, projects and references |
|
||||
| `tests/.../SessionManagerTests.cs`, `tests/.../IdlePlaybackTests.cs` | Pass the new constructor argument |
|
||||
|
||||
---
|
||||
|
||||
## Deleted upstream files
|
||||
|
||||
`.github/workflows/*` (15 files). The fork is hosted on Gitea and builds on
|
||||
Woodpecker via `.woodpecker/ci.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Rebasing onto a newer upstream release
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/jellyfin/jellyfin.git
|
||||
git fetch upstream --tags
|
||||
|
||||
# files this fork owns outright
|
||||
git diff v12.0...HEAD --name-only --diff-filter=A
|
||||
|
||||
# files that need re-porting onto the new release
|
||||
git diff v12.0...HEAD --name-only --diff-filter=M
|
||||
|
||||
# upstream churn on a touched file since this base
|
||||
git log v12.0..<new-tag> --oneline -- <path>
|
||||
```
|
||||
|
||||
Re-apply the modified-file changes onto the new upstream code rather than
|
||||
reverting upstream's changes to make a patch apply.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Jellyfin HA transcoding fork: Redis-backed session failover + experimental PostgreSQL provider
|
||||
|
||||
I've been working on a fork of Jellyfin focused on one specific problem: making HLS transcoding survive pod restarts in a multi-replica Kubernetes deployment.
|
||||
|
||||
## What it does
|
||||
|
||||
Right now, Jellyfin assumes transcode state lives in one server process. If that pod dies, active transcodes die with it. This fork adds a small HA layer so transcode ownership can survive a pod restart:
|
||||
|
||||
- A new `ITranscodeSessionStore` abstraction for durable transcode session tracking
|
||||
- A `RedisTranscodeSessionStore` implementation with lease-based ownership
|
||||
- Atomic pod takeover using a Redis Lua script when a lease expires
|
||||
- Lease-aware cleanup so one pod does not delete segments another pod still needs
|
||||
- A `NullTranscodeSessionStore` fallback, so single-instance deployments behave exactly like upstream with no config changes
|
||||
|
||||
I also added an experimental PostgreSQL provider for shared-database deployments, since SQLite is not a good fit once multiple replicas are involved.
|
||||
|
||||
## What the HA flow looks like
|
||||
|
||||
- Pod A starts an HLS transcode and registers the session in Redis
|
||||
- Pod A renews the lease while it owns the session
|
||||
- If Pod A dies, the lease expires
|
||||
- Pod B receives the next request, atomically claims the expired lease, and resumes from the last completed segment on shared storage
|
||||
- The client sees a short buffer pause instead of a hard failure
|
||||
|
||||
## How to run it
|
||||
|
||||
There are three practical modes:
|
||||
|
||||
### 1. Single instance
|
||||
|
||||
No config needed. It falls back to the no-op store automatically.
|
||||
|
||||
### 2. Local HA test
|
||||
|
||||
Run two Jellyfin instances against:
|
||||
|
||||
- the same Redis
|
||||
- the same shared transcode directory
|
||||
|
||||
That is enough to test failover behavior locally.
|
||||
|
||||
### 3. Kubernetes / k3s
|
||||
|
||||
This is the intended deployment model. You need:
|
||||
|
||||
- 2+ Jellyfin replicas
|
||||
- Redis
|
||||
- shared RWX storage for transcode output
|
||||
- shared media storage
|
||||
- ideally PostgreSQL if you want a proper shared DB setup
|
||||
|
||||
The key config is:
|
||||
|
||||
```text
|
||||
Jellyfin:TranscodeStore:RedisConnectionString
|
||||
Jellyfin:TranscodeStore:LeaseDurationSeconds
|
||||
```
|
||||
|
||||
Repo and write-up:
|
||||
|
||||
- Source: https://github.com/ZoltyMat/jellyfin-ha
|
||||
- Full change summary vs upstream: https://github.com/ZoltyMat/jellyfin-ha/blob/main/docs/FORK-DIFF.md
|
||||
- Write-up with diagrams and k8s manifests: https://blog.zolty.systems/posts/jellyfin-ha-kubernetes
|
||||
|
||||
## What would be required to merge upstream
|
||||
|
||||
I do not expect this to be merged as-is without discussion. If there is interest, I think the realistic path is to split it into small pieces:
|
||||
|
||||
1. Introduce `ITranscodeSessionStore`, `TranscodeSession`, and `NullTranscodeSessionStore` only
|
||||
2. Add the DI wiring with no behavior change unless configured
|
||||
3. Add HLS session registration and lease renewal hooks
|
||||
4. Add lease-aware cleanup in `DeleteTranscodeFileTask`
|
||||
5. Add takeover logic in the HLS/session path
|
||||
6. Discuss whether Redis should be the first supported distributed store, or whether the interface should land before any concrete implementation
|
||||
7. Treat PostgreSQL as a separate discussion entirely
|
||||
|
||||
I think the HA transcode work has a better chance of review if it is separated from the PostgreSQL provider and migration tooling.
|
||||
|
||||
## Why I'm posting it
|
||||
|
||||
I'm not trying to maintain a permanent hard fork. I built this to see whether Jellyfin could be made to behave well in a replicated environment without rewriting major subsystems. The answer seems to be yes, but it needs maintainers to decide whether this kind of deployment is something upstream wants to support.
|
||||
|
||||
If there's interest, I'm happy to break the work into smaller PRs, clean up anything that does not match project direction, and rework the design around maintainer feedback.
|
||||
@@ -0,0 +1,473 @@
|
||||
# HA Transcoding Design — Phase 5.1.1 Audit
|
||||
|
||||
> **Status**: Design audit only. No functional code changes in this document.
|
||||
> **Purpose**: Map the exact transcode lifecycle before Phase 5.2 code changes begin.
|
||||
> **Last updated**: 2026-03-07
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Sequence Diagram: Full Transcode Lifecycle](#sequence-diagram-full-transcode-lifecycle)
|
||||
2. [Key In-Memory State Fields](#key-in-memory-state-fields)
|
||||
3. [Why `playSessionId` Is Insufficient](#why-playsessionid-is-insufficient)
|
||||
4. [Why `DeleteTranscodeFileTask` Is Unsafe for Shared Storage](#why-deletetranscodfiletask-is-unsafe-for-shared-storage)
|
||||
5. [How `SessionManager._activeLiveStreamSessions` Works](#how-sessionmanager_activelivestreamsessions-works)
|
||||
6. [NFSv3 Lock Recovery on Pod Death](#nfsv3-lock-recovery-on-pod-death)
|
||||
7. [Minimum Recovery State](#minimum-recovery-state)
|
||||
8. [HA Failure Scenario Walk-Through](#ha-failure-scenario-walk-through)
|
||||
9. [Open Questions Before Phase 5.2](#open-questions-before-phase-52)
|
||||
10. [Cross-References](#cross-references)
|
||||
|
||||
---
|
||||
|
||||
## Sequence Diagram: Full Transcode Lifecycle
|
||||
|
||||
The following describes the path from a client HLS manifest request through
|
||||
FFmpeg startup to segment delivery and session cleanup.
|
||||
|
||||
```
|
||||
Client DynamicHlsController StreamingHelpers TranscodeManager
|
||||
| | | |
|
||||
| GET /Videos/{id}/live.m3u8 | | |
|
||||
|------------------------------->| | |
|
||||
| | GetStreamingState() | |
|
||||
| |-------------------------->| |
|
||||
| | StreamState | |
|
||||
| |<--------------------------| |
|
||||
| | | |
|
||||
| | File.Exists(playlistPath)?| |
|
||||
| |---------- NO ----------> | |
|
||||
| | | |
|
||||
| | LockAsync(playlistPath) | |
|
||||
| |--------------------------------------------->| |
|
||||
| | (async keyed lock held) | | |
|
||||
| | | | |
|
||||
| | StartFfMpeg(state, ...) | |
|
||||
| |------------------------------------------>| |
|
||||
| | | OnTranscodeBeginning()
|
||||
| | | _activeTranscodingJobs.Add(job)
|
||||
| | | Process.Start(ffmpeg)
|
||||
| | TranscodingJob | |
|
||||
| |<------------------------------------------| |
|
||||
| | | |
|
||||
| | WaitForMinimumSegmentCount() (if minSegments > 0) |
|
||||
| |------------------------------------------ ... ---|
|
||||
| | | |
|
||||
| 200 OK (m3u8 playlist text) | | |
|
||||
|<-------------------------------| | |
|
||||
| | | |
|
||||
| GET /Videos/{id}/hls/segment0.ts | |
|
||||
|------------------------------->| | |
|
||||
| | GetStreamingState() | |
|
||||
| |-------------------------->| |
|
||||
| | | |
|
||||
| | File.Exists(playlistPath)?| |
|
||||
| |---------- YES ----------> | |
|
||||
| | | |
|
||||
| | OnTranscodeBeginRequest(playlistPath, type) |
|
||||
| |------------------------------------------>| |
|
||||
| | job (from _activeTranscodingJobs by path) |
|
||||
| |<------------------------------------------| |
|
||||
| | | |
|
||||
| | PingTranscodingJob(playSessionId) |
|
||||
| | (resets kill timer, marks active) |
|
||||
| | | |
|
||||
| 200 OK (segment data) | | |
|
||||
|<-------------------------------| | |
|
||||
| | | |
|
||||
| (client stops requesting) | | |
|
||||
| | | |
|
||||
| [kill timer fires after inactivity timeout] | |
|
||||
| | | |
|
||||
| | OnTranscodeKillTimerStopped() |
|
||||
| |------------------------------------------>| |
|
||||
| | KillTranscodingJob(job, ...) |
|
||||
| | Process.Kill(ffmpeg) |
|
||||
| | DeletePartialStreamFiles(path) |
|
||||
| | _activeTranscodingJobs.Remove(job) |
|
||||
```
|
||||
|
||||
### `GetStreamingState()` — What It Does
|
||||
|
||||
`StreamingHelpers.GetStreamingState()` (in `Jellyfin.Api/Helpers/StreamingHelpers.cs`)
|
||||
constructs a `StreamState` object from the inbound `StreamingRequestDto`. It:
|
||||
|
||||
- Resolves the `MediaSourceInfo` for the request
|
||||
- Computes `OutputFilePath` from `IApplicationPaths.TranscodePath` + a hash-derived subdirectory
|
||||
- Applies encoding parameters from the request and the device profile
|
||||
- Does **not** consult any durable store — state is recomputed from scratch on every request
|
||||
|
||||
### `StartFfMpeg()` — What It Does
|
||||
|
||||
`TranscodeManager.StartFfMpeg()` (line ~371, `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs`):
|
||||
|
||||
1. Calls `OnTranscodeBeginning()` → creates a `TranscodingJob`, adds it to `_activeTranscodingJobs`
|
||||
2. Calls `AcquireResources()` (waits `MediaSource.BufferMs` if set)
|
||||
3. Starts FFmpeg process with the generated command line
|
||||
4. Calls `StartThrottler()` and `StartSegmentCleaner()` if applicable
|
||||
5. Returns the `TranscodingJob` to the caller
|
||||
|
||||
### `OnTranscodeBeginRequest()` — What It Does
|
||||
|
||||
Called when the playlist already exists on disk. Looks up a job in `_activeTranscodingJobs`
|
||||
by filesystem path and `TranscodingJobType`. Returns `null` if no matching in-memory job
|
||||
exists (which is exactly the pod-takeover failure scenario).
|
||||
|
||||
---
|
||||
|
||||
## Key In-Memory State Fields
|
||||
|
||||
### `TranscodeManager._activeTranscodingJobs`
|
||||
|
||||
**Location**: `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs`, line 48
|
||||
|
||||
```csharp
|
||||
private readonly List<TranscodingJob> _activeTranscodingJobs = new();
|
||||
```
|
||||
|
||||
- Protected by `lock(_activeTranscodingJobs)` (monitor lock)
|
||||
- **Process-local**: not shared between pods, not persisted to any durable store
|
||||
- Contains one `TranscodingJob` per active FFmpeg process
|
||||
- Looked up by `PlaySessionId` (string) or by path + type pair
|
||||
|
||||
Key `TranscodingJob` fields relevant to recovery:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `PlaySessionId` | `string?` | Caller-supplied; can be null |
|
||||
| `Path` | `string` | Absolute path to the m3u8 playlist file |
|
||||
| `Type` | `TranscodingJobType` | `HLS`, `Progressive`, etc. |
|
||||
| `DeviceId` | `string` | Client device identifier |
|
||||
| `Process` | `Process?` | The live FFmpeg process handle |
|
||||
| `IsLiveOutput` | `bool` | Set to `true` for live HLS streams |
|
||||
| `Id` | `string` | `Guid.NewGuid().ToString("N")` — per-job, not durable |
|
||||
|
||||
### `SessionManager._activeLiveStreamSessions`
|
||||
|
||||
**Location**: `Emby.Server.Implementations/Session/SessionManager.cs`, line ~67
|
||||
|
||||
```csharp
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _activeLiveStreamSessions
|
||||
```
|
||||
|
||||
- Maps `liveStreamId → (sessionId → playSessionId)`
|
||||
- Updated by `UpdateLiveStreamActiveSessionMappings()` (line ~849)
|
||||
- Queried in media-open paths to prevent double-opening a live stream
|
||||
- **Process-local**: cleared on pod shutdown (`_activeLiveStreamSessions.Clear()` on line ~2151)
|
||||
- A takeover pod **cannot** inherit these mappings without explicit rehydration from a durable store
|
||||
|
||||
---
|
||||
|
||||
## Why `playSessionId` Is Insufficient
|
||||
|
||||
`playSessionId` is an **optional, caller-supplied** query parameter:
|
||||
|
||||
```csharp
|
||||
// DynamicHlsController.cs, GetLiveHlsStream():
|
||||
[FromQuery] string? playSessionId,
|
||||
```
|
||||
|
||||
It is passed directly to `StreamingRequestDto.PlaySessionId` and from there into
|
||||
`TranscodingJob.PlaySessionId`. This creates three failure modes for HA:
|
||||
|
||||
### Failure Mode 1: Two clients collide on the same ID
|
||||
|
||||
If two clients supply the same `playSessionId` string, `GetTranscodingJob(playSessionId)`
|
||||
returns the first matching job regardless of which device owns it. The second client's
|
||||
segment requests will ping the first client's kill timer, potentially extending an
|
||||
unrelated session indefinitely.
|
||||
|
||||
### Failure Mode 2: `null` PlaySessionId is common
|
||||
|
||||
When the Jellyfin web client does not supply a `playSessionId`, the field is `null`.
|
||||
`GetTranscodingJob(string playSessionId)` does an `OrdinalIgnoreCase` compare:
|
||||
|
||||
```csharp
|
||||
return _activeTranscodingJobs.FirstOrDefault(j =>
|
||||
string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
|
||||
```
|
||||
|
||||
If `playSessionId` is null, `string.Equals(null, null)` returns `true`, so the lookup
|
||||
returns the **first job in the list with a null PlaySessionId**, regardless of path,
|
||||
device, or item. On a shared filesystem with two pods, this creates an ambiguity
|
||||
between jobs running on different pods.
|
||||
|
||||
### Failure Mode 3: Insufficient as a durable recovery key
|
||||
|
||||
`playSessionId` is not generated by the server — it is client-supplied. There is no
|
||||
guarantee it is present, globally unique, or stable across client reconnects. A durable
|
||||
recovery store (Issue 5.2.1) must use a server-generated, correlation-stable key that
|
||||
includes at minimum: server-assigned UUID, item ID, media source ID, and owner pod name.
|
||||
|
||||
---
|
||||
|
||||
## Why `DeleteTranscodeFileTask` Is Unsafe for Shared Storage
|
||||
|
||||
**Location**: `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs`
|
||||
|
||||
```csharp
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
||||
// ...
|
||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, ...);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, ...)
|
||||
{
|
||||
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
||||
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) // ← age only
|
||||
.ToList();
|
||||
// deletes without any lease check
|
||||
}
|
||||
```
|
||||
|
||||
**Triggers**: startup + every 24h.
|
||||
|
||||
**Problem for shared NFS storage**: The task deletes *any* file not written to in the
|
||||
last 24 hours. When a pod dies and a takeover pod attempts recovery, it needs to:
|
||||
|
||||
1. Read the existing `.m3u8` manifest to find segment path prefix
|
||||
2. Determine the last fully-written `.ts` segment
|
||||
3. Restart FFmpeg from one segment before that point
|
||||
|
||||
If those files have an `mtime` older than 24 hours (e.g., the original pod started an
|
||||
overnight transcode), the cleanup task running on any pod that boots after 24h will
|
||||
delete them before the recovery pod can read them. There is **no lease or ownership check**.
|
||||
|
||||
**Required fix (Phase 5.2.2b)**: Before deleting a file, check whether a valid recovery
|
||||
lease exists in the durable store (`ITranscodeSessionStore`). Skip deletion for any path
|
||||
covered by an active or recently-expired lease.
|
||||
|
||||
---
|
||||
|
||||
## How `SessionManager._activeLiveStreamSessions` Works
|
||||
|
||||
When a Jellyfin client opens a live stream, `OpenMediaSource()` calls
|
||||
`UpdateLiveStreamActiveSessionMappings(liveStreamId, sessionId, playSessionId)`:
|
||||
|
||||
```csharp
|
||||
// SessionManager.cs, line ~849
|
||||
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
|
||||
{
|
||||
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(
|
||||
liveStreamId, _ => new ConcurrentDictionary<string, string>());
|
||||
activeSessionMappings[sessionId] = playSessionId;
|
||||
}
|
||||
```
|
||||
|
||||
This prevents two sessions from opening the same live stream without coordination. It is
|
||||
consulted when another `OpenMediaSource` call arrives for the same `liveStreamId`.
|
||||
|
||||
**Why this breaks in HA**:
|
||||
|
||||
- The mapping lives only in the pod that originally opened the stream
|
||||
- When the owning pod dies, active session mappings are gone
|
||||
- A takeover pod has no record that liveStreamId `X` is in use
|
||||
- `CloseLiveStream()` on pod B will never be called for a stream opened on pod A
|
||||
- The live stream source (e.g., a TV tuner) may stay locked open indefinitely
|
||||
|
||||
**Recovery approach (Phase 5.2.1/5.3.1)**: The durable `ITranscodeSessionStore` must
|
||||
persist `(liveStreamId → sessionId, playSessionId, ownerPod, openedAt)` and allow
|
||||
takeover pods to query and claim abandoned streams.
|
||||
|
||||
---
|
||||
|
||||
## NFSv3 Lock Recovery on Pod Death
|
||||
|
||||
**NFS version confirmed**: `nfsvers=3` — from `kubernetes/apps/media/nfs-pv.yaml` mount
|
||||
options used for all existing media NFS PersistentVolumes.
|
||||
|
||||
### NFSv3 Lock (`lockd`) Behavior on Pod Death
|
||||
|
||||
NFSv3 uses the Network Lock Manager (`lockd`) for advisory file locks. When a client
|
||||
(pod) terminates:
|
||||
|
||||
1. The NFS client kernel module sends an `NSM` (Network Status Monitor) notification
|
||||
to the NFS server
|
||||
2. The NFS server's `lockd` releases all locks held by that client after a grace period
|
||||
(typically the `sm-notify` retry window, default ~15s)
|
||||
3. **Not guaranteed**: If the pod is killed abruptly (OOM/SIGKILL) and cannot send NSM
|
||||
notification, the NFS server detects the client has disappeared via TCP keep-alive
|
||||
timeout (typically 20–120s depending on server configuration)
|
||||
|
||||
### Implications for Segment Files
|
||||
|
||||
FFmpeg writes `.ts` files sequentially. A typical write pattern:
|
||||
|
||||
1. Open `segment_N.ts` for write
|
||||
2. Write video/audio data (2–4 MB for a 2–4s segment)
|
||||
3. Close and rename/flush
|
||||
|
||||
If the pod dies **mid-write** of `segment_N.ts`:
|
||||
|
||||
- The file may be 0 bytes, partially filled, or have a corrupted end
|
||||
- NFSv3 does **not** guarantee close-to-open consistency for concurrent readers
|
||||
— another pod may see a stale cached version or a partial file
|
||||
- The NFS server releases the lock within seconds to minutes, but the file
|
||||
content is not rolled back
|
||||
|
||||
**Recovery rule (must implement in Phase 5.2)**:
|
||||
|
||||
> When resuming from a manifest on shared storage, identify the last `.ts` segment
|
||||
> that appears in the `.m3u8` `#EXTINF` entries AND is non-zero in size AND has a
|
||||
> stable mtime (not being written). Restart FFmpeg from **one segment before** that
|
||||
> point to ensure the last segment is re-written cleanly.
|
||||
|
||||
This is analogous to the WAL recovery principle: never trust the last write from a
|
||||
crashed writer.
|
||||
|
||||
### NFS Lock Hold-Up on Active Pod
|
||||
|
||||
When a Jellyfin pod has an open file handle on the NFS mount and the NAS becomes
|
||||
unreachable, NFSv3 with `hard` mount option (confirmed in existing PVs) will block
|
||||
I/O indefinitely — the pod will not crash, but it will stall. This is the correct
|
||||
behavior for transcode recovery: FFmpeg stalls rather than emitting corrupt segments.
|
||||
Test this in Issue 5.1.2 NAS outage test.
|
||||
|
||||
---
|
||||
|
||||
## Minimum Recovery State
|
||||
|
||||
For a takeover pod to resume an orphaned transcode session, the following minimum
|
||||
state must be durably stored (Phase 5.2.1):
|
||||
|
||||
| Field | Source | Why Needed |
|
||||
|---|---|---|
|
||||
| `sessionId` | server-generated UUID | Stable correlation key; not client-supplied |
|
||||
| `playSessionId` | client-supplied (may be null) | Needed to match kill-timer pings |
|
||||
| `ownerPod` | k8s `POD_NAME` env var | Identify which pod is current owner |
|
||||
| `manifestPath` | `OutputFilePath` with `.m3u8` extension | Entry point for takeover pod |
|
||||
| `segmentPathPrefix` | derived from `manifestPath` directory | Find `.ts` files |
|
||||
| `mediaSourceId` | `StreamState.MediaSource.Id` | Re-open the same stream |
|
||||
| `itemId` | `StreamState.Request.ItemId` | Re-construct `StreamingRequestDto` |
|
||||
| `encodingParams` | serialized subset of `StreamState` | Restart FFmpeg with identical params |
|
||||
| `lastHeartbeatUtc` | updated by owner pod on segment write | Orphan detection: > 120s = orphaned |
|
||||
| `lastCompletedSegmentIndex` | updated on each segment flush | Recovery knows where to seek |
|
||||
| `deviceId` | `StreamState.Request.DeviceId` | Kill-job scope on cleanup |
|
||||
|
||||
---
|
||||
|
||||
## HA Failure Scenario Walk-Through
|
||||
|
||||
### Scenario: Pod A dies mid-transcode, Pod B receives next segment request
|
||||
|
||||
```
|
||||
Pod A (owner) Redis (durable store) Pod B (takeover)
|
||||
| | |
|
||||
| write sessionKey → Redis | |
|
||||
|-------------------------------->| |
|
||||
| | |
|
||||
| heartbeat every 30s | |
|
||||
|-------------------------------->| |
|
||||
| | |
|
||||
DIES (OOMKill / node drain) | |
|
||||
| GET segment_N+1.ts
|
||||
|<------------------------|
|
||||
| session key exists |
|
||||
| lastHeartbeat > 120s ago
|
||||
| ownerPod != me |
|
||||
| |
|
||||
[today, WITHOUT Phase 5.2]: |
|
||||
| |
|
||||
| _activeTranscodingJobs is empty on Pod B
|
||||
| OnTranscodeBeginRequest() → null
|
||||
| No ffmpeg started
|
||||
| Client receives stale m3u8, then 404s on segment
|
||||
| Playback stalls indefinitely
|
||||
| |
|
||||
[with Phase 5.2]: |
|
||||
| |
|
||||
| CAS: set ownerPod = pod-B |
|
||||
|<------------------------|
|
||||
| |
|
||||
| recover from segment_N-1 |
|
||||
| StartFfMpeg(resumeFrom=N-1)
|
||||
|<------------------------|
|
||||
| |
|
||||
| client resumes from segment N-1 (~4s rewind)
|
||||
```
|
||||
|
||||
### Current State (Without Phase 5.2)
|
||||
|
||||
1. Client sends `GET .../segment_100.ts` to pod B (Traefik sticky session cookie
|
||||
`jellyfin-server-id` routes to pod B because pod A is gone)
|
||||
2. Pod B calls `GetStreamingState()` → computes same `OutputFilePath` (deterministic hash)
|
||||
3. Pod B calls `File.Exists(playlistPath)` → **true** (file exists on NFS from pod A)
|
||||
4. Pod B calls `OnTranscodeBeginRequest(playlistPath, HLS)` → **null** (no job in pod B's `_activeTranscodingJobs`)
|
||||
5. `job is null` → `OnTranscodeEndRequest` not called, no ping, no FFmpeg restart
|
||||
6. Pod B reads and returns the existing `.m3u8` from disk
|
||||
7. Client requests segment 100 → pod B tries to serve `segment_100.ts`
|
||||
- If the file exists and is complete: **success** (but no new segments will be produced)
|
||||
- If the file does not exist yet (pod A was mid-write): **404**, client stalls
|
||||
|
||||
Without Phase 5.2, the transcode stream terminates on pod death. No recovery happens
|
||||
automatically. The client must re-initiate playback from the beginning or from a
|
||||
seek point.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Before Phase 5.2
|
||||
|
||||
| # | Question | Who Answers | When |
|
||||
|---|---|---|---|
|
||||
| Q1 | What is the actual `leasetime` configured on the Ugreen DXP4800 NFS server? (default 90s, but UGOS Pro may differ) | Issue 5.1.2 benchmark pod | 5.1.2 |
|
||||
| Q2 | Does the NFS mount use `nfsvers=3` exclusively, or does UGOS Pro negotiate v4 when requested? | `nfsstat -m` in test pod | 5.1.2 |
|
||||
| Q3 | What is the minimum HLS segment duration in practice? (affects recovery seek distance) | FFmpeg log inspection | 5.1.1 follow-on |
|
||||
| Q4 | Does the Jellyfin web client re-supply a stable `playSessionId` on reconnect, or generate a new one? | Client code inspection | 5.2.2a |
|
||||
| Q5 | Does `StackExchange.Redis` in the fork use connection multiplexing that survives pod address changes? | 5.2.1a implementation | 5.2.1a |
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- [jellyfin-ha-plan.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-plan.md) — overall HA plan and phase structure
|
||||
- [jellyfin-ha-phase5-transcoding.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-phase5-transcoding.md) — Phase 5 issue list, rollback matrix, Go/No-Go preconditions
|
||||
- [jellyfin-ha-failover-test.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-failover-test.md) — SLO baselines, failover test procedures
|
||||
- [ci-cd.md](../home_k3s_cluster/docs/ci-cd.md) — Phase 5 CI/CD paths
|
||||
- `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs` — `_activeTranscodingJobs`, `StartFfMpeg()`, `KillTranscodingJob()`
|
||||
- `Jellyfin.Api/Controllers/DynamicHlsController.cs` — `GetLiveHlsStream()`, segment lookup
|
||||
- `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` — age-only cleanup
|
||||
- `Emby.Server.Implementations/Session/SessionManager.cs` — `_activeLiveStreamSessions`
|
||||
- `kubernetes/apps/media/nfs-pv.yaml` — `nfsvers=3` confirmed
|
||||
|
||||
---
|
||||
|
||||
## Bitrate/Segment Tradeoffs
|
||||
|
||||
### Why shorter segments trade throughput for faster failover
|
||||
|
||||
HLS streaming works by dividing a media stream into a series of short, independently decodable
|
||||
segments. The segment length is a fundamental trade-off: longer segments reduce per-segment HTTP
|
||||
overhead and allow FFmpeg to apply more aggressive compression across each chunk, improving overall
|
||||
bitrate efficiency. Shorter segments, however, mean that when a pod fails mid-transcode, a takeover
|
||||
pod only needs to rewind to the previous segment boundary — not the start of a much longer one.
|
||||
With the default 6-second segment length, a client could stall for up to 6 seconds before the
|
||||
takeover pod produces a new segment for it to consume. With the HA recovery default of 2 seconds
|
||||
(`RecoverySegmentLengthSeconds = 2`), that stall window is reduced to at most 2 seconds of rewind,
|
||||
dramatically improving the perceived continuity of playback during a pod failover.
|
||||
|
||||
### The rolling segment buffer and disk usage
|
||||
|
||||
In HA mode, `RecoverySegmentBufferCount` (default `5`) controls how many segments are retained in
|
||||
the HLS playlist at any one time. This creates a rolling on-disk buffer of `5 × 2 s = 10 seconds`
|
||||
of media that a takeover pod can serve immediately while it restarts FFmpeg from the last known
|
||||
position. Keeping fewer segments wastes less NFS storage but shrinks the window in which a newly
|
||||
promoted pod can respond to in-flight client requests without waiting for new segments to be
|
||||
produced. Keeping more segments lengthens the recovery window but increases NFS write pressure and
|
||||
disk usage proportionally. The valid range (2–10) was chosen so that the minimum buffer is always
|
||||
at least 4 seconds (2 × 2 s) and the maximum stays under 20 seconds (10 × 2 s), balancing storage
|
||||
cost against recovery robustness.
|
||||
|
||||
### Tuning guidance and rollback
|
||||
|
||||
The two knobs, `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount`, can be adjusted in
|
||||
the Jellyfin server's encoding options without restarting the service; the new values take effect on
|
||||
the next transcode session that enters HA mode. To reduce disk I/O at the cost of a slightly longer
|
||||
stall window, increase `RecoverySegmentLengthSeconds` toward its maximum of 6 (matching the
|
||||
throughput-optimized default). To shrink the NFS footprint at the cost of a narrower recovery
|
||||
window, lower `RecoverySegmentBufferCount` toward its minimum of 2. To roll back to the
|
||||
pre-HA-mode behavior entirely, set `RecoverySegmentLengthSeconds = 6` and ensure that no active
|
||||
session is registered in the `ITranscodeSessionStore` (which disables HA mode detection in
|
||||
`DynamicHlsController`). All changes are backwards-compatible: in single-pod deployments where the
|
||||
store is a no-op, these settings have no effect on the FFmpeg command generated.
|
||||
Reference in New Issue
Block a user