unkinben a06b11980e
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
ci: add Woodpecker build+test pipeline, drop GitHub Actions
Why:
- The fork inherited GitHub Actions workflows that target the upstream's
  self-hosted GitHub runners and do not run on this Gitea/Woodpecker
  infrastructure, so source changes (such as the scan-leader lease work)
  currently land without any CI validation.

How:
- Add .woodpecker/ci.yaml running restore, build and test of Jellyfin.sln
  on pull_request and push, using the .NET 9 SDK that global.json pins.
- Filter out RequiresDocker and Integration tests, mirroring the upstream
  test selection so the suite runs without extra services.
- Set memory-heavy resource requests/limits and a dedicated
  serviceAccountName for the build+test step.
- Remove the inherited .github/workflows/ pipelines that only run on the
  upstream's GitHub Actions runners.
2026-08-11 07:23:43 +10:00
2025-03-24 10:07:52 +00:00
2026-03-31 19:33:11 -04:00
2024-11-16 10:11:01 -07:00
2026-03-31 19:33:11 -04:00
2025-02-20 09:55:02 +00:00
2022-02-21 14:15:09 +01:00
2024-11-16 10:11:01 -07:00
2026-03-31 19:33:11 -04:00
2022-11-27 14:13:31 +01:00

jellyfin-ha

A fork of Jellyfin adding high-availability transcoding support for multi-pod Kubernetes deployments.

License: GPL v2 .NET 10 Upstream


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)

Architecture

┌─────────────┐     ┌─────────────┐
│  Jellyfin   │     │  Jellyfin   │
│   Pod A     │     │   Pod B     │
│             │     │             │
│ ┌─────────┐ │     │ ┌─────────┐ │
│ │Transcode│ │     │ │Transcode│ │
│ │Manager  │ │     │ │Manager  │ │
│ └────┬────┘ │     │ └────┬────┘ │
└──────┼──────┘     └──────┼──────┘
       │                   │
       └─────────┬─────────┘
                 │
         ┌───────▼───────┐
         │  Redis        │   ← ITranscodeSessionStore
         │  (lease store)│     TTL-based ownership
         └───────────────┘

       ┌─────────────────────┐
       │  Shared NAS / NFS   │   ← HLS segments + manifests
       │  (shared storage)   │
       └─────────────────────┘

How takeover works:

  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.

Quick Start

Single instance (no Redis)

No configuration required. NullTranscodeSessionStore is used automatically. Behavior is identical to upstream Jellyfin.

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:

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:

{
  "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 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 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.

# 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):

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)

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

kubectl create secret generic jellyfin-redis \
  --namespace jellyfin \
  --from-literal=connection-string="redis.jellyfin.svc.cluster.local:6379,abortConnect=false"

Shared transcode PVC (RWX)

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

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

# 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:

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:

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:

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

Build

dotnet build Jellyfin.Server/Jellyfin.Server.csproj

Run all tests

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
dotnet test tests/Jellyfin.Server.Implementations.Tests \
  --configuration Release \
  --filter "FullyQualifiedName~TranscodeSession"

Run with code coverage

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. 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 master. 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.


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 (at least 2022) and Visual Studio Code.

ffmpeg will also need to be installed.

Cloning the Repository

After dependencies have been installed you will need to clone a local copy of this repository. If you just want to run the server from source you can clone this repository directly, but if you are intending to contribute code changes to the project, you should set up your own fork of the repository. The following example shows how you can clone the repository directly over HTTPS.

git clone https://github.com/jellyfin/jellyfin.git

Installing the Web Client

The server is configured to host the static files required for the web client in addition to serving the backend by default. Before you can run the server, you will need to get a copy of the web client since they are not included in this repository directly.

Note that it is recommended for development to host the web client separately from the web server with some additional configuration, in which case you can skip this step.

There are two options to get the files for the web client.

  1. Build them from source following the instructions on the jellyfin-web repository
  2. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at C:\Program Files\Jellyfin\Server\jellyfin-web

Running The Server

The following instructions will help you get the project up and running via the command line, or your preferred IDE.

Running With Visual Studio

To run the project with Visual Studio you can open the Solution (.sln) file and then press F5 to run the server.

Running With Visual Studio Code

To run the project with Visual Studio Code you will first need to open the repository directory with Visual Studio Code using the Open Folder... option.

Second, you need to install the recommended extensions for the workspace. Note that extension recommendations are classified as either "Workspace Recommendations" or "Other Recommendations", but only the "Workspace Recommendations" are required.

After the required extensions are installed, you can run the server by pressing F5.

Running From the Command Line

To run the server from the command line you can use the dotnet run command. The example below shows how to do this if you have cloned the repository into a directory named jellyfin (the default directory name) and should work on all operating systems.

cd jellyfin                          # Move into the repository directory
dotnet run --project Jellyfin.Server --webdir /absolute/path/to/jellyfin-web/dist # Run the server startup project

A second option is to build the project and then run the resulting executable file directly. When running the executable directly you can easily add command line options. Add the --help flag to list details on all the supported command line options.

  1. Build the project
dotnet build                       # Build the project
cd Jellyfin.Server/bin/Debug/net10.0 # Change into the build output directory
  1. Execute the build output. On Linux, Mac, etc. use ./jellyfin and on Windows use jellyfin.exe.

Accessing the Hosted Web Client

If the Server is configured to host the Web Client, and the Server is running, the Web Client can be accessed at http://localhost:8096 by default.

API documentation can be viewed at http://localhost:8096/api-docs/swagger/index.html

Running from GitHub Codespaces

As Jellyfin will run on a container on a GitHub hosted server, JF needs to handle some things differently.

NOTE: Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 seconds to load all extensions and prepare the environment while VS Code is already open. Just give it some time and wait until you see Downloading .NET version(s) 7.0.15~x64 ...... Done! in the output tab.

NOTE: If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VS Code window to public.

NOTE: When first opening the server instance with any WebUI, you will be sent to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup.

There are two configurations for you to choose from.

Default - Development Jellyfin Server

This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run through the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the .NET Launch (nowebclient) launch config to start the server.

Keep in mind that as this has no web client you have to connect to it via an external client. This can be just another codespace container running the WebUI. vuejs does not work from the get-go as it does not support the setup steps.

Development Jellyfin Server ffmpeg

this extends the default server with a default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual If you want to install a specific ffmpeg version, follow the comments embedded in the .devcontainer/Dev - Server Ffmpeg/install.ffmpeg.sh file.

Use the ghcs .NET Launch (nowebclient, ffmpeg) launch config to run with the jellyfin-ffmpeg enabled.

Running The Tests

This repository also includes unit tests that are used to validate functionality as part of a CI pipeline on Azure. There are several ways to run these tests.

  1. Run tests from the command line using dotnet test
  2. Run tests in Visual Studio using the Test Explorer
  3. Run individual tests in Visual Studio Code using the associated CodeLens annotation

Advanced Configuration

The following sections describe some more advanced scenarios for running the server from source that build upon the standard instructions above.

Hosting The Web Client Separately

It is not necessary to host the frontend web client as part of the backend server. Hosting these two components separately may be useful for frontend developers who would prefer to host the client in a separate webpack development server for a tighter development loop. See the jellyfin-web repo for instructions on how to do this.

To instruct the server not to host the web content, there is a nowebclient configuration flag that must be set. This can be specified using the command line switch --nowebclient or the environment variable JELLYFIN_NOWEBCONTENT=true.

Since this is a common scenario, there is also a separate launch profile defined for Visual Studio called Jellyfin.Server (nowebcontent) that can be selected from the 'Start Debugging' dropdown in the main toolbar.

NOTE: The setup wizard cannot be run if the web client is hosted separately.


This project is supported by:

DigitalOcean   JetBrains logo

S
Description
Source fork of ZoltyMat/jellyfin-ha carrying HA patches
Readme GPL-2.0 75 MiB
Languages
C# 99.5%
HTML 0.3%