Compare commits

..

43 Commits

Author SHA1 Message Date
mat d4f9c12c22 fix: downgrade PostgreSQL provider to net9.0/EF Core 9.x for v10.11.7 compat
HA Build & Push to ECR / build-and-push (push) Has been cancelled
Upstream Jellyfin 10.11.7 targets net9.0. Our PostgreSQL provider was on
net10.0 with Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 which requires
EF Core 10+. Downgrade to 9.0.4 to match upstream's EF Core 9.0.11.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:57:39 -04:00
ZoltyMat d46539b718 docs: add architecture, contributing guide, and trim Dockerfile comment (#4)
* docs: add architecture overview, contributing guide, and GitHub discussion draft

ARCHITECTURE.md covers server layer diagram, subsystems, and runtime info.
CONTRIBUTING.md covers dev setup, build, test, and submission workflow.
GITHUB-DISCUSSION-DRAFT.md drafts the upstream discussion post for the HA fork.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trim verbose comment in Dockerfile.runtime

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 22:56:34 -04:00
mat 13f49305da security: remove pull_request trigger from ha-build.yml
ha-build.yml runs on self-hosted k3s runners. Having pull_request as a
trigger allows any internet user to open a PR against this public repo
and execute arbitrary code on cluster nodes (GitHub does block secret
injection on fork PRs, but runner filesystem and cluster network access
remain).

Removed pull_request trigger. Build-on-push-to-main is sufficient.
CI test feedback on PRs is covered by ci-tests.yml which uses
GitHub-hosted (ubuntu-latest) runners only.
2026-03-31 22:56:34 -04:00
ZoltyMat 7d4cef51f5 feat: add Helm chart for jellyfin-ha (#3)
Adds a production-ready Helm chart under deploy/helm/jellyfin-ha/.

Motivated by a community request on Reddit:
https://www.reddit.com/r/JellyfinCommunity/comments/1rvj17f/jellyfin_ha_on_kubernetes_redisbacked_transcode/oav7mlz/

Features:
- StatefulSet with configurable replica count (default 2 for HA)
- Redis subchart (in-cluster) wired to ITranscodeSessionStore via
  Jellyfin__TranscodeStore__RedisConnectionString env var
- Supports external Redis via ha.transcodeStore.existingSecret or
  ha.transcodeStore.redisConnectionString
- Optional in-cluster PostgreSQL StatefulSet (experimental, mirrors
  existing kubernetes/apps/media/jellyfin-postgres.yaml pattern)
- RWX config + transcode PVCs (required for multi-pod session takeover)
- Per-pod cache via volumeClaimTemplates (RWO)
- Optional NFS PV+PVC for media library
- Intel QSV / VA-API GPUgit checkout -b feat/helm-chart && git add deploy/ && legit add deploy/ && git commit -m dagit commit -m featss
2026-03-31 22:56:34 -04:00
mat a9aa2d53ed docs: add FORK-DIFF.md summarising all changes vs upstream jellyfin/jellyfin 2026-03-31 22:56:34 -04:00
mat 08b87504b4 docs: add Docker Compose, k8s manifests, bare dotnet HA setup, and DigitalOcean link 2026-03-31 22:56:34 -04:00
mat df27faaa41 docs: rewrite README with HA setup guide, config reference, and architecture 2026-03-31 22:56:34 -04:00
Copilot fe9ea18918 Phase 5.2.4: Tune HLS segmentation for HA recovery (#29)
* Initial plan

* Phase 5.2.4: Tune HLS behavior for recovery with configurable segment parameters

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

* fix: SA1516 — add blank line between MakeLiveStreamKey and Clone helpers

StyleCop SA1516 requires elements to be separated by blank lines.
Missing blank line at line 370 caused build failure in Phase 5 tests.

Closes #28

* fix: SA1516 — blank line in InMemoryTranscodeSessionStore between helpers

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
Co-authored-by: mat <mstrommen@gmail.com>
2026-03-31 22:56:30 -04:00
Copilot e71efc3f97 Fix SessionManager._activeLiveStreamSessions for HA pod takeover safety (#27)
* Initial plan

* Issue 5.2.3b: Fix SessionManager._activeLiveStreamSessions for takeover safety

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

* ci: trigger CI run for PR #27 review

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
Co-authored-by: mat <mstrommen@gmail.com>
2026-03-31 22:56:30 -04:00
Copilot 9817185fa3 Add lease-aware cleanup to DeleteTranscodeFileTask (#25)
* Initial plan

* Add GetActiveSessionsAsync to ITranscodeSessionStore and update DeleteTranscodeFileTask for lease-aware cleanup

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

* Fix Redis exception propagation in GetActiveSessionsAsync for safe abort behavior

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

* fix: use KeysAsync to resolve CA1849 analyzer violation

Replace synchronous IServer.Keys() with async IServer.KeysAsync()
using await foreach to satisfy CA1849 (TreatWarningsAsErrors).

CA1849: 'IServer.Keys()' synchronously blocks.
Await 'IServer.KeysAsync()' instead.

Line 161 in RedisTranscodeSessionStore.GetActiveSessionsAsync.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
Co-authored-by: mat <mstrommen@gmail.com>
2026-03-31 22:56:30 -04:00
Copilot c2a11f3e68 Phase 5.2.2a: Register HLS sessions in ITranscodeSessionStore + lease renewal (#23)
* Initial plan

* Phase 5.2.2a: Register HLS sessions in ITranscodeSessionStore + lease renewal

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:56:30 -04:00
Copilot d60ae43b59 Wire ITranscodeSessionStore to Redis-backed impl with NullTranscodeSessionStore fallback and DI registration (#21)
* Initial plan

* feat: add Redis-backed ITranscodeSessionStore with NullTranscodeSessionStore fallback and DI registration

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

* refactor: add code review improvements - lease expiry comment, Redis connection error handling

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:56:30 -04:00
Copilot a187ab18b8 Add ITranscodeSessionStore interface and HA recovery unit tests (#19)
* Initial plan

* Add ITranscodeSessionStore interface, TranscodeSession record, InMemoryTranscodeSessionStore fake, and HA unit tests"

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:56:30 -04:00
ZoltyMat cf5268c1e4 docs: Phase 5.1.1 - HA-TRANSCODING-DESIGN.md transcode lifecycle audit (#17)
Map the exact transcode lifecycle before Phase 5.2 code changes begin.
No functional code changes.

Key findings documented:
- _activeTranscodingJobs: process-local, not persisted across pod restarts
- playSessionId: caller-supplied nullable string; three HA failure modes
- DeleteTranscodeFileTask: age-only cleanup, unsafe for shared NFS storage
- _activeLiveStreamSessions: process-local ConcurrentDictionary, cannot be
  inherited by a takeover pod without durable store rehydration
- NFSv3 confirmed (nfsvers=3). Close-to-open consistency advisory; recovery
  must skip the last incomplete segment and restart one segment earlier
- Minimum recovery state: 10 fields including server-generated sessionId,
  ownerPod, manifestPath, lastCompletedSegmentIndex, lastHeartbeatUtc

Closes Phase 5 Issue 5.1.1
2026-03-31 22:56:30 -04:00
ZoltyMat f405e17c96 ci: Phase 5.0.4b - add run-phase5-tests gate to ci-tests.yml (#16)
Add a dedicated 'run-phase5-tests' job that runs the three test assemblies
most affected by Phase 5 HLS session-sharing and PostgreSQL media-encoding
changes in parallel with the existing full-matrix run-tests job.

Targeted assemblies:
- tests/Jellyfin.Api.Tests (HLS controller surface)
- tests/Jellyfin.MediaEncoding.Hls.Tests (transcode lifecycle)
- tests/Jellyfin.Server.Implementations.Tests (RedisTranscodeSessionStore)

Each test step filters Category!=RequiresDocker to exclude Docker-dependent
tests that require Testcontainers. Coverage results are merged separately
into merged-phase5/ to avoid collisions with the full-matrix merged/ dir.

Closes #(Phase 5.0.4b)
2026-03-31 22:56:30 -04:00
ZoltyMat 67300ec17f ci: add PR trigger, copilot/* and feat/phase* branches, concurrency block (issue 5.0.0a) (#15)
- Add pull_request: trigger so Copilot agent branches get CI coverage
- Add feat/phase* and copilot/* to push branch list
- Add concurrency block to cancel stale runs on same ref
- Set push: false on PR events (build only, no ECR push for PRs)
2026-03-31 22:56:30 -04:00
mat 2b4a0a2314 fix(docker): bundle jellyfin-web into Dockerfile.runtime (the CI-used file)
Previous fixes went to Dockerfile, but CI uses Dockerfile.runtime.
Add webclient stage: installs jellyfin-web=10.11.6+deb12 from the
Jellyfin bookworm apt repo (correct version suffix: +deb12, not +1).
Web assets land at /usr/share/jellyfin/web/ and are copied to
/jellyfin/jellyfin-web/ in the runtime image.
Add --webdir flag to ENTRYPOINT explicitly.
2026-03-31 22:56:30 -04:00
mat d84a37e1d2 fix(docker): use jellyfin-web 10.11.6+deb12 from jellyfin apt repo
Previous attempt used 10.9.11+1 which does not exist — the bookworm
repo uses +deb12 suffix (e.g. 10.11.6+deb12) and only has >= 10.11.x.
This caused an empty /jellyfin/jellyfin-web/ at runtime, making the
server crash with 'content directory is either invalid or empty'.

Fix: install jellyfin-web=10.11.6+deb12 from the official Jellyfin
bookworm apt repo. Assets land at /usr/share/jellyfin/web/ and are
COPY'd to /jellyfin/jellyfin-web/ in the runtime image.
2026-03-31 22:56:30 -04:00
mat 465b6c5d9e fix(docker): install jellyfin-web 10.9.11 via apt repo into /jellyfin/jellyfin-web
Pulling from jellyfin/jellyfin:10.9.11 multi-stage yielded an empty
/jellyfin/jellyfin-web directory (the official image likely has a different
internal structure). Switch to the official Jellyfin apt repo instead:
  apt-get install jellyfin-web=10.9.11+1
  web assets land at /usr/share/jellyfin/web/
  COPY to /jellyfin/jellyfin-web/ in runtime image

--webdir /jellyfin/jellyfin-web is already in ENTRYPOINT.
2026-03-31 22:56:30 -04:00
mat b975195252 fix(docker): bundle jellyfin-web 10.9.11 from official image into wwwroot
wwwroot/ only contained api-docs (Swagger) — the web client was never bundled.
Add a webclient stage that pulls /jellyfin/jellyfin-web from jellyfin/jellyfin:10.9.11
and copies it to /jellyfin/jellyfin-web in the runtime image.

Pass --webdir /jellyfin/jellyfin-web to the entrypoint so the server serves the UI.
jellyfin-web 10.9.11 is API-compatible with the 10.12.0 server fork.
Update to a matching 10.12.x web client once that release ships upstream.
2026-03-31 22:56:30 -04:00
mat 1c28df61df fix(ha): PostgreSQL migration backup no-op + URI connection string support
- MigrationBackupFast/RestoreBackupFast/DeleteBackup return no-ops for
  PostgreSQL; pre-migration backups are handled by jellyfin-pg-backup
  CronJob, not the automated backup path that throws NotSupportedException

- ServiceCollectionExtensions: detect postgresql:// / postgres:// URI
  format in POSTGRES_CONNECTION_STRING and convert to ADO.NET key=value
  format before passing to NpgsqlDataSourceBuilder (which requires it)

Closes startup crash: 'Automated migration backups are not supported for
PostgreSQL' on first boot with a fresh database.
2026-03-31 22:56:30 -04:00
mat c69b8ccc53 fix: add missing using for IServerConfigurationManager 2026-03-31 22:56:29 -04:00
mat 138081bd8b fix(ha): register IServerConfigurationManager in pre-startup DI for PostgreSQL
The NpgsqlDataSource singleton factory in AddJellyfinDbContext calls
sp.GetRequiredService<IServerConfigurationManager>() to read pool settings.
During ApplyStartupMigrationAsync, only a subset of services are registered
in the startup service collection — IServerConfigurationManager was missing,
causing a fatal DI resolution failure when DatabaseType=Jellyfin-PostgreSQL.

Fix: register startupConfigurationManager as IServerConfigurationManager in
the migrationStartupServiceProvider service collection.

Also remove JELLYFIN_CONFIG_DIR=/config from Dockerfile.runtime ENV block.
When configDir == dataDir, MakeSanityCheckOrThrow writes .jellyfin-config at
the datadir root, then immediately throws because it expected .jellyfin-data.
Removing the env var lets configDir default to $JELLYFIN_DATA_DIR/config.
2026-03-31 22:56:29 -04:00
mat ea732b62d8 fix(ci): pre-build .NET on host runner, remove SDK from Docker build
DinD overlay-on-overlay throttles dotnet child container to ~11s CPU/3h wall time.
Solution: dotnet restore+publish run on native runner FS (~10min), then
Dockerfile.runtime just COPYs the pre-built publish-output/ directory.
This brings build time from 3h+ (stuck) to ~10-15 minutes total.
2026-03-31 22:56:29 -04:00
mat e7c1451b6c fix(ci): exclude integration tests — need SkiaSharp native libs and running server 2026-03-31 22:56:26 -04:00
mat a49a766424 fix(ci): export PATH in test step — each step has fresh shell on ARC runners 2026-03-31 22:56:26 -04:00
mat ddf3b9f609 fix(ci): use GITHUB_ENV not GITHUB_PATH for dotnet PATH on ARC runners
GITHUB_PATH between-step propagation is broken on summerwind ARC runner pods.
Switch to GITHUB_ENV PATH= which is reliably sourced on every step.
2026-03-31 22:56:26 -04:00
mat 9c6fab8f12 fix(ci): install .NET 10 via script to writable DOTNET_INSTALL_DIR
setup-dotnet fails on ARC runners (no permission to /usr/share/dotnet) and dotnet
is not pre-installed. Install via dotnet-install.sh into $HOME/.dotnet instead,
then add to PATH via GITHUB_PATH.
2026-03-31 22:56:26 -04:00
mat 992e591938 fix(ci): remove setup-dotnet from test workflow — ARC runners have .NET 10 pre-installed
setup-dotnet fails on self-hosted ARC runners because it cannot write to
/usr/share/dotnet (permission denied). The runner images already have .NET 10
SDK installed in DOTNET_ROOT. Remove the step entirely.
2026-03-31 22:56:26 -04:00
mat 365036f9f2 fix(ci): skip Docker-dependent PostgreSQL tests in upstream test workflow
- Add [Trait("Category", "RequiresDocker")] to all 3 PostgreSQL test classes
  (PostgreSqlMigrationTests, PostgreSqlProviderTests, PostgreSqlConcurrencyTests)
- Add --filter "Category!=RequiresDocker" to ci-tests.yml dotnet test command
  so runners without Docker don't fail on Testcontainers initialization
- Disable CodeQL workflow in fork (requires upstream org permissions + .NET 10
  CodeQL support that isn't available on our self-hosted runners)

PostgreSQL tests still run in ha-build.yml against the cluster where Docker
is available via the self-hosted ARC runners.
2026-03-31 22:56:13 -04:00
mat 93af601a5f ci: run tests and CodeQL on k3s self-hosted amd64 runners
- ci-tests.yml: drop ubuntu/macos/windows matrix → single k3s amd64 runner
- ci-codeql-analysis.yml: ubuntu-latest → k3s amd64 runner
Fork targets amd64 cluster only; self-hosted runners have Docker for Testcontainers
2026-03-31 22:56:13 -04:00
mat 894d837578 fix(ci): use version tags for docker actions 2026-03-31 22:56:13 -04:00
mat e79caccab1 fix(ci): add docker/setup-buildx-action before build step 2026-03-31 22:56:13 -04:00
mat 25b137e0ab fix(ci): switch ha-build to static AWS key auth 2026-03-31 22:56:13 -04:00
mat 75ef410437 fix(ci): trigger ha-build workflow on master branch 2026-03-31 22:56:13 -04:00
mat 849f0a06c8 fix(dockerfile): disable TreatWarningsAsErrors for Docker publish step
StyleCop analyzer violations in upstream src/Jellyfin.Extensions/ block
the Docker build when TreatWarningsAsErrors=true (from Directory.Build.props).
Disable for container image builds — StyleCop enforcement is the CI pipeline's
responsibility, not the Dockerfile's.
2026-03-31 22:56:10 -04:00
mat 2e5fb57052 fix(dockerfile): copy BannedSymbols.txt and stylecop.json into build stage
dotnet publish failed with CS2001 because these root-level analyzer config files
were not included in the Docker build context. They are referenced by
Directory.Build.props and required by StyleCop and BannedApiAnalyzers at build
time.
2026-03-31 22:56:10 -04:00
Copilot e72bde9c02 Add Jellyfin.DbMigrator SQLite-to-PostgreSQL migration tool (#14)
* Initial plan

* Add Jellyfin.DbMigrator SQLite-to-PostgreSQL migration tool

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:56:07 -04:00
Copilot 35a1ec8a5d [WIP] Add PostgreSQL integration test project for validation (#12)
* Initial plan

* Add PostgreSQL integration test project with migration, CRUD, and concurrency tests

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:55:57 -04:00
Copilot a0c38131c8 Wire NpgsqlDataSource pool into DI for PostgreSQL provider (#10)
* Initial plan

* Add NpgsqlDataSource pool wiring to DI (Issue 1.4)"

- PostgreSqlDatabaseProvider: accept NpgsqlDataSource via constructor injection, use it in Initialise()
- PostgreSqlDesignTimeJellyfinDbFactory: build NpgsqlDataSource from connection string for design-time use
- ServiceCollectionExtensions: register NpgsqlDataSource as singleton with pool params (MinPoolSize=2, MaxPoolSize=20, CommandTimeout=30) from CustomProviderOptions.Options

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:55:45 -04:00
Copilot 3bc45ff92d Add PostgreSQL EF Core design-time factory and InitialPostgreSql migration (#8)
* Initial plan

* Add PostgreSQL design-time factory and initial migration for all 29 DbSets

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

* Remove accidentally committed build artifacts from PostgreSQL provider

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:55:45 -04:00
Copilot 2d4d6d8c38 Implement PostgreSqlDatabaseProvider methods and DI registration (#6)
* Initial plan

* feat: implement PostgreSqlDatabaseProvider all methods + DI registration

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:55:45 -04:00
Copilot 9a99572354 Scaffold Jellyfin.Database.Providers.PostgreSQL project (#4)
* Initial plan

* Issue 1.1: Scaffold PostgreSQL provider project

Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:55:45 -04:00
79 changed files with 11947 additions and 144 deletions
+3 -1
View File
@@ -11,7 +11,9 @@ on:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
# Disabled in fork — upstream CodeQL requires specific GitHub org permissions and .NET 10 support
if: false
runs-on: [self-hosted, k3s, linux, amd64]
strategy:
fail-fast: false
+72 -15
View File
@@ -13,26 +13,29 @@ env:
jobs:
run-tests:
strategy:
matrix:
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
fail-fast: false
runs-on: "${{ matrix.os }}"
runs-on: [self-hosted, k3s, linux, amd64]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: ${{ env.SDK_VERSION }}
# Install .NET — use GITHUB_ENV (not GITHUB_PATH) to set PATH because
# ARC self-hosted runner pods don't pick up GITHUB_PATH between steps.
- name: Install .NET SDK
run: |
DOTNET_INSTALL_DIR="$HOME/.dotnet"
mkdir -p "$DOTNET_INSTALL_DIR"
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
- name: Run DotNet CLI Tests
run: >
dotnet test Jellyfin.sln
--configuration Release
--collect:"XPlat Code Coverage"
--settings tests/coverletArgs.runsettings
--verbosity minimal
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test Jellyfin.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
- name: Merge code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
@@ -43,3 +46,57 @@ jobs:
# TODO - which action / tool to use to publish code coverage results?
# - name: Publish code coverage results
# Phase 5 transcode coverage gate — runs in parallel with run-tests.
# Explicitly targets the three test assemblies most affected by Phase 5 HLS
# session-sharing and PostgreSQL media-encoding changes so failures surface
# with a dedicated check status independent of the full test matrix.
run-phase5-tests:
runs-on: [self-hosted, k3s, linux, amd64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET SDK
run: |
DOTNET_INSTALL_DIR="$HOME/.dotnet"
mkdir -p "$DOTNET_INSTALL_DIR"
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
- name: Run Phase 5 Transcode Tests (API)
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker"
- name: Run Phase 5 Transcode Tests (HLS)
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker"
- name: Run Phase 5 Transcode Tests (Server.Implementations)
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker"
- name: Merge Phase 5 code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@2a7030e9775aab6c78e80cb66843051acdacee3e # v5.5.2
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged-phase5/"
reporttypes: "Cobertura"
+93
View File
@@ -0,0 +1,93 @@
name: HA Build & Push to ECR
on:
push:
branches:
- master
- main
- "feat/ha-*"
- "feat/phase*"
- "copilot/*"
# pull_request intentionally removed: this workflow runs on self-hosted k3s
# runners. Allowing pull_request events from a public repo would let any
# internet user execute arbitrary code inside the cluster network.
# CI build feedback on PRs is provided by ci-tests.yml (GitHub-hosted runners).
# Cancel in-progress runs when a new push arrives on the same branch.
concurrency:
group: ha-build-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push:
runs-on: [self-hosted, k3s, linux, amd64]
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
- name: Set image metadata
id: meta
run: |
REPO="${{ steps.ecr-login.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}"
SHORT_SHA="${GITHUB_SHA::7}"
echo "image_repo=${REPO}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Install .NET SDK
# Build on the runner host filesystem (native I/O) to avoid DinD
# overlay-on-overlay throttling which makes dotnet publish ~20x slower.
run: |
DOTNET_INSTALL_DIR="$HOME/.dotnet"
mkdir -p "$DOTNET_INSTALL_DIR"
if ! "$DOTNET_INSTALL_DIR/dotnet" --version 2>/dev/null | grep -q "^10\\."; then
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
fi
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
- name: Restore NuGet packages
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet restore Jellyfin.Server/Jellyfin.Server.csproj --runtime linux-x64
- name: Publish Jellyfin server
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--no-restore \
-p:TreatWarningsAsErrors=false \
--output ./publish-output
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.runtime
platforms: linux/amd64
# Only push the image on direct branch pushes, not on pull_request events.
push: ${{ github.event_name == 'push' }}
provenance: false
tags: |
${{ steps.meta.outputs.image_repo }}:${{ steps.meta.outputs.short_sha }}
${{ steps.meta.outputs.image_repo }}:latest
+5
View File
@@ -5,6 +5,7 @@
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
<ItemGroup Label="Package Dependencies">
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
<PackageVersion Include="AWSSDK.S3" Version="3.7.410.2" />
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
@@ -54,10 +55,12 @@
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Npgsql" Version="9.0.4" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
@@ -88,9 +91,11 @@
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.4.0" />
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
<PackageVersion Include="TMDbLib" Version="2.3.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
# ── Build stage ──────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore dependencies first (layer-cache friendly)
COPY ["Jellyfin.sln", "global.json", "nuget.config", "Directory.Build.props", "Directory.Packages.props", "./"]
COPY ["SharedVersion.cs", "BannedSymbols.txt", "stylecop.json", "./"]
# Copy all project files so dotnet restore can resolve the full dependency graph
COPY Emby.Naming/ Emby.Naming/
COPY Emby.Photos/ Emby.Photos/
COPY Emby.Server.Implementations/ Emby.Server.Implementations/
COPY Jellyfin.Api/ Jellyfin.Api/
COPY Jellyfin.Data/ Jellyfin.Data/
COPY Jellyfin.Server/ Jellyfin.Server/
COPY Jellyfin.Server.Implementations/ Jellyfin.Server.Implementations/
COPY MediaBrowser.Common/ MediaBrowser.Common/
COPY MediaBrowser.Controller/ MediaBrowser.Controller/
COPY MediaBrowser.LocalMetadata/ MediaBrowser.LocalMetadata/
COPY MediaBrowser.MediaEncoding/ MediaBrowser.MediaEncoding/
COPY MediaBrowser.Model/ MediaBrowser.Model/
COPY MediaBrowser.Providers/ MediaBrowser.Providers/
COPY MediaBrowser.XbmcMetadata/ MediaBrowser.XbmcMetadata/
COPY src/ src/
RUN dotnet restore Jellyfin.Server/Jellyfin.Server.csproj \
--runtime linux-x64
# Publish the server (and all transitive dependencies, including the
# PostgreSQL provider assembly added by this fork).
# Note: TreatWarningsAsErrors is disabled for the Docker build — StyleCop
# analyzer violations in upstream src/ projects would otherwise block the
# image build. StyleCop is enforced in the CI pipeline, not the Dockerfile.
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--no-restore \
-p:TreatWarningsAsErrors=false \
--output /app
# ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo.
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
&& rm -rf /var/lib/apt/lists/* \
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
# libicu, libssl, and liblttng-ust are already present in the dotnet/aspnet base image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
fontconfig \
libfontconfig1 \
libfreetype6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /jellyfin
COPY --from=build /app .
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
# Jellyfin default ports
EXPOSE 8096
EXPOSE 8920
# Data / config volumes
VOLUME ["/config", "/cache", "/media"]
ENV JELLYFIN_DATA_DIR=/config \
JELLYFIN_CACHE_DIR=/cache \
JELLYFIN_LOG_DIR=/config/log \
JELLYFIN_CONFIG_DIR=/config
ENTRYPOINT ["./jellyfin", \
"--datadir", "/config", \
"--cachedir", "/cache", \
"--webdir", "/jellyfin/jellyfin-web"]
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
# Runtime-only image — the .NET publish step runs on the CI host (runner),
# not inside this Dockerfile.
# ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo.
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
&& rm -rf /var/lib/apt/lists/*
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
fontconfig \
libfontconfig1 \
libfreetype6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /jellyfin
# Copy the pre-built publish output produced by `dotnet publish` on the CI host.
COPY publish-output/ .
# Copy the jellyfin-web client assets from the webclient stage.
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
# Jellyfin default ports
EXPOSE 8096
EXPOSE 8920
# Data / config volumes
VOLUME ["/config", "/cache", "/media"]
ENV JELLYFIN_DATA_DIR=/config \
JELLYFIN_CACHE_DIR=/cache \
JELLYFIN_LOG_DIR=/config/log
ENTRYPOINT ["./jellyfin", \
"--datadir", "/config", \
"--cachedir", "/cache", \
"--webdir", "/jellyfin/jellyfin-web"]
+1 -1
View File
@@ -36,7 +36,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId>
<VersionPrefix>10.11.8</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -66,6 +66,7 @@
<ItemGroup>
<PackageReference Include="Ignore" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Emby.Server.Implementations.MediaEncoding;
/// <summary>
/// A Redis-backed implementation of <see cref="ITranscodeSessionStore"/> that provides
/// durable, distributed session tracking with lease-based ownership between pods.
/// </summary>
public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore
{
private const string KeyPrefix = "jellyfin:transcode:";
private const string LiveStreamKeyPrefix = "jellyfin:livestream:";
/// <summary>
/// Lua script for atomic takeover: reads the stored session, checks whether the lease has
/// expired (comparing <c>LeaseExpiresUtc.Ticks</c> against the caller-supplied current ticks),
/// and if expired, updates the owner and expiry before returning 1; returns 0 otherwise.
/// </summary>
private const string TakeoverScript = @"
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
local session = cjson.decode(raw)
local currentTicks = tonumber(ARGV[1])
if session['LeaseExpiresUtc'] > currentTicks then return 0 end
session['OwnerPod'] = ARGV[2]
local leaseDurationMs = tonumber(ARGV[3])
local newTicks = currentTicks + (leaseDurationMs * 10000)
session['LeaseExpiresUtc'] = newTicks
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', leaseDurationMs)
return 1";
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _db;
private readonly TranscodeStoreOptions _options;
private readonly ILogger<RedisTranscodeSessionStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RedisTranscodeSessionStore"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="options">The transcode store configuration options.</param>
/// <param name="logger">The logger.</param>
public RedisTranscodeSessionStore(
IConnectionMultiplexer redis,
IOptions<TranscodeStoreOptions> options,
ILogger<RedisTranscodeSessionStore> logger)
{
_redis = redis;
_db = redis.GetDatabase();
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
var key = GetKey(session.PlaySessionId);
var json = JsonSerializer.Serialize(session);
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
_logger.LogDebug("Set transcode session {PlaySessionId} in Redis.", session.PlaySessionId);
}
/// <inheritdoc />
public async Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (!raw.HasValue)
{
return null;
}
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
// Check LeaseExpiresUtc in addition to Redis TTL to guard against the window between
// Redis TTL evaluation and the GET result being returned to the caller.
if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow)
{
return null;
}
return session;
}
/// <inheritdoc />
public async Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (!raw.HasValue)
{
return;
}
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
if (session is null)
{
return;
}
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
session.LeaseExpiresUtc = DateTime.UtcNow.AddMilliseconds(leaseDurationMs);
var json = JsonSerializer.Serialize(session);
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
_logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId);
}
/// <inheritdoc />
public async Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
_logger.LogDebug("Deleted transcode session {PlaySessionId} from Redis.", playSessionId);
}
/// <inheritdoc />
public async Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
var currentTicks = DateTime.UtcNow.Ticks;
var result = (long?)await _db.ScriptEvaluateAsync(
TakeoverScript,
keys: new RedisKey[] { key },
values: new RedisValue[] { currentTicks, claimingPod, leaseDurationMs }).ConfigureAwait(false);
var succeeded = result == 1;
if (succeeded)
{
_logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId);
}
return succeeded;
}
private static string GetKey(string playSessionId) => KeyPrefix + playSessionId;
private static string GetLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
=> LiveStreamKeyPrefix + liveStreamId + ":" + sessionIdOrPlaySessionId;
/// <inheritdoc />
public async Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
var sessions = new List<TranscodeSession>();
var servers = _redis.GetServers();
foreach (var server in servers)
{
if (!server.IsConnected)
{
continue;
}
var keys = new List<RedisKey>();
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
{
keys.Add(key);
}
var tasks = keys.Select(key => _db.StringGetAsync(key)).ToList();
var values = await Task.WhenAll(tasks).ConfigureAwait(false);
foreach (var raw in values)
{
if (!raw.HasValue)
{
continue;
}
TranscodeSession? session;
try
{
session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize transcode session from Redis.");
continue;
}
if (session is not null)
{
sessions.Add(session);
}
}
}
return sessions;
}
/// <inheritdoc />
public async Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
{
var key = GetLiveStreamKey(session.LiveStreamId, session.SessionId);
var json = JsonSerializer.Serialize(session);
// Live stream records use the same lease duration as transcode sessions.
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
// Also index by play session id so the caller can look up by either key.
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
var playKey = GetLiveStreamKey(session.LiveStreamId, session.PlaySessionId);
await _db.StringSetAsync(playKey, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
}
_logger.LogDebug(
"Set live stream session {LiveStreamId}/{SessionId} in Redis.",
session.LiveStreamId,
session.SessionId);
}
/// <inheritdoc />
public async Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (!raw.HasValue)
{
return null;
}
return JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
}
/// <inheritdoc />
public async Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (raw.HasValue)
{
var session = JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
if (session is not null)
{
// Remove both the session-id key and the play-session-id key if present.
var keysToDelete = new System.Collections.Generic.List<RedisKey>
{
GetLiveStreamKey(liveStreamId, session.SessionId)
};
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
keysToDelete.Add(GetLiveStreamKey(liveStreamId, session.PlaySessionId));
}
await _db.KeyDeleteAsync(keysToDelete.ToArray()).ConfigureAwait(false);
_logger.LogDebug(
"Deleted live stream session {LiveStreamId}/{SessionId} from Redis.",
liveStreamId,
session.SessionId);
return;
}
}
// Fallback: delete just the key that was supplied.
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
}
}
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
private readonly IConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
private readonly ILocalizationManager _localization;
private readonly ITranscodeSessionStore _sessionStore;
/// <summary>
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
@@ -29,16 +31,19 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
public DeleteTranscodeFileTask(
ILogger<DeleteTranscodeFileTask> logger,
IFileSystem fileSystem,
IConfigurationManager configurationManager,
ILocalizationManager localization)
ILocalizationManager localization,
ITranscodeSessionStore sessionStore)
{
_logger = logger;
_fileSystem = fileSystem;
_configurationManager = configurationManager;
_localization = localization;
_sessionStore = sessionStore;
}
/// <inheritdoc />
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
}
/// <inheritdoc />
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var minDateModified = DateTime.UtcNow.AddDays(-1);
progress.Report(50);
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
IEnumerable<TranscodeSession> activeSessions;
try
{
activeSessions = await _sessionStore.GetActiveSessionsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve active transcode sessions. Skipping deletion to avoid removing files in use.");
progress.Report(100);
return;
}
return Task.CompletedTask;
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
}
/// <summary>
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
/// Deletes the transcoded temp files from directory with a last write time less than a given date,
/// skipping any files that belong to an active transcode session.
/// </summary>
/// <param name="directory">The directory.</param>
/// <param name="minDateModified">The min date modified.</param>
/// <param name="activeSessions">The currently active transcode sessions.</param>
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The task cancellation token.</param>
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
{
var activeSessionList = activeSessions.ToList();
var filesToDelete = _fileSystem.GetFiles(directory, true)
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
.ToList();
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
cancellationToken.ThrowIfCancellationRequested();
if (IsFileProtectedByActiveSession(file.FullName, activeSessionList))
{
_logger.LogDebug("Skipping deletion of {FilePath} as it belongs to an active transcode session.", file.FullName);
index++;
continue;
}
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
index++;
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
progress.Report(100);
}
private static bool IsFileProtectedByActiveSession(string filePath, IList<TranscodeSession> activeSessions)
{
foreach (var session in activeSessions)
{
if (!string.IsNullOrEmpty(session.ManifestPath) &&
string.Equals(filePath, session.ManifestPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrEmpty(session.SegmentPathPrefix) &&
filePath.StartsWith(session.SegmentPathPrefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
@@ -28,6 +28,7 @@ using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Authentication;
using MediaBrowser.Controller.Events.Session;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
@@ -60,6 +61,7 @@ namespace Emby.Server.Implementations.Session
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
private readonly IDeviceManager _deviceManager;
private readonly ITranscodeSessionStore _transcodeSessionStore;
private readonly CancellationTokenRegistration _shutdownCallback;
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
= new(StringComparer.OrdinalIgnoreCase);
@@ -89,6 +91,7 @@ namespace Emby.Server.Implementations.Session
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
/// <param name="transcodeSessionStore">Instance of <see cref="ITranscodeSessionStore"/> interface.</param>
public SessionManager(
ILogger<SessionManager> logger,
IEventManager eventManager,
@@ -102,7 +105,8 @@ namespace Emby.Server.Implementations.Session
IServerApplicationHost appHost,
IDeviceManager deviceManager,
IMediaSourceManager mediaSourceManager,
IHostApplicationLifetime hostApplicationLifetime)
IHostApplicationLifetime hostApplicationLifetime,
ITranscodeSessionStore transcodeSessionStore)
{
_logger = logger;
_eventManager = eventManager;
@@ -116,6 +120,7 @@ namespace Emby.Server.Implementations.Session
_appHost = appHost;
_deviceManager = deviceManager;
_mediaSourceManager = mediaSourceManager;
_transcodeSessionStore = transcodeSessionStore;
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
@@ -343,9 +348,38 @@ namespace Emby.Server.Implementations.Session
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
}
}
else
{
// In-memory state is absent — this pod may have taken over from a crashed pod.
// Check the durable store to determine whether the live stream record exists.
LiveStreamSession durableRecord = null;
try
{
durableRecord = await _transcodeSessionStore.TryGetLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to query live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
}
if (durableRecord is not null)
{
liveStreamNeedsToBeClosed = true;
}
}
// Remove the durable record regardless of which code path set liveStreamNeedsToBeClosed.
if (liveStreamNeedsToBeClosed)
{
try
{
await _transcodeSessionStore.DeleteLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
}
try
{
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
@@ -776,7 +810,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
}
var eventArgs = new PlaybackStartEventArgs
@@ -836,7 +870,7 @@ namespace Emby.Server.Implementations.Session
return OnPlaybackProgress(info, false);
}
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
{
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>());
@@ -860,6 +894,26 @@ namespace Emby.Server.Implementations.Session
activeSessionMappings[sessionId] = string.Empty;
}
}
// Persist to the durable store so a takeover pod can discover open live streams.
var ownerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
var liveStreamSession = new LiveStreamSession
{
LiveStreamId = liveStreamId,
SessionId = sessionId,
PlaySessionId = playSessionId ?? string.Empty,
OwnerPod = ownerPod,
OpenedAtUtc = DateTime.UtcNow,
};
try
{
await _transcodeSessionStore.SetLiveStreamAsync(liveStreamSession).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist live stream session {LiveStreamId}/{SessionId} to durable store.", liveStreamId, sessionId);
}
}
/// <summary>
@@ -904,7 +958,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
}
var eventArgs = new PlaybackProgressEventArgs
@@ -60,6 +60,7 @@ public class DynamicHlsController : BaseJellyfinApiController
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
private readonly DynamicHlsHelper _dynamicHlsHelper;
private readonly EncodingOptions _encodingOptions;
private readonly ITranscodeSessionStore _transcodeSessionStore;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@@ -75,6 +76,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
/// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
/// <param name="transcodeSessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface used to register and renew HLS transcoding session leases in the durable store.</param>
public DynamicHlsController(
ILibraryManager libraryManager,
IUserManager userManager,
@@ -86,7 +88,8 @@ public class DynamicHlsController : BaseJellyfinApiController
ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper,
EncodingHelper encodingHelper,
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
ITranscodeSessionStore transcodeSessionStore)
{
_libraryManager = libraryManager;
_userManager = userManager;
@@ -99,6 +102,7 @@ public class DynamicHlsController : BaseJellyfinApiController
_dynamicHlsHelper = dynamicHlsHelper;
_encodingHelper = encodingHelper;
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
_transcodeSessionStore = transcodeSessionStore;
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
}
@@ -309,15 +313,36 @@ public class DynamicHlsController : BaseJellyfinApiController
// If the playlist doesn't already exist, startup ffmpeg
try
{
// Check whether this session is already registered in the HA store (takeover scenario).
var isHaMode = false;
if (!string.IsNullOrEmpty(playSessionId))
{
try
{
var existingSession = await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false);
isHaMode = existingSession is not null;
}
catch (Exception haEx)
{
_logger.LogWarning(haEx, "Failed to check HA mode for live-stream session {PlaySessionId}.", playSessionId);
}
}
job = await _transcodeManager.StartFfMpeg(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, true, 0),
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource)
.ConfigureAwait(false);
job.IsLiveOutput = true;
await RegisterTranscodeSessionAsync(
playSessionId ?? string.Empty,
mediaSourceId ?? string.Empty,
cancellationToken)
.ConfigureAwait(false);
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
}
catch
{
@@ -1535,14 +1560,35 @@ public class DynamicHlsController : BaseJellyfinApiController
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
// Check whether this session is already registered in the HA store (takeover scenario).
var isHaMode = false;
if (!string.IsNullOrEmpty(streamingRequest.PlaySessionId))
{
try
{
var existingSession = await _transcodeSessionStore.TryGetAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
isHaMode = existingSession is not null;
}
catch (Exception haEx)
{
_logger.LogWarning(haEx, "Failed to check HA mode for segment session {PlaySessionId}.", streamingRequest.PlaySessionId);
}
}
state.WaitForPath = segmentPath;
job = await _transcodeManager.StartFfMpeg(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, false, segmentId),
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource).ConfigureAwait(false);
await RegisterTranscodeSessionAsync(
streamingRequest.PlaySessionId ?? string.Empty,
streamingRequest.MediaSourceId ?? string.Empty,
cancellationToken)
.ConfigureAwait(false);
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
}
catch
{
@@ -1570,6 +1616,77 @@ public class DynamicHlsController : BaseJellyfinApiController
private static double[] GetSegmentLengths(StreamState state)
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, CancellationToken cancellationToken)
{
try
{
var session = new TranscodeSession
{
PlaySessionId = playSessionId,
OwnerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID")
?? Environment.MachineName,
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30),
ManifestPath = string.Empty,
SegmentPathPrefix = string.Empty,
MediaSourceId = mediaSourceId,
LastCompletedSegmentIndex = 0,
LastDurablePlaybackOffset = 0L,
};
await _transcodeSessionStore.SetAsync(session, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to register HLS session {PlaySessionId} in durable store.", playSessionId);
}
}
private void StartLeaseRenewal(string playSessionId, CancellationToken cancellationToken)
{
_ = Task.Run(
async () =>
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
try
{
await _transcodeSessionStore.RenewLeaseAsync(playSessionId, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to renew lease for HLS session {PlaySessionId}.", playSessionId);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Lease renewal loop for HLS session {PlaySessionId} encountered an unexpected error.", playSessionId);
}
finally
{
try
{
await _transcodeSessionStore.DeleteAsync(playSessionId, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete HLS session {PlaySessionId} from durable store.", playSessionId);
}
}
},
CancellationToken.None);
}
internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
{
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
@@ -1591,7 +1708,7 @@ public class DynamicHlsController : BaseJellyfinApiController
return segments;
}
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
{
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
@@ -1614,10 +1731,20 @@ public class DynamicHlsController : BaseJellyfinApiController
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
var outputTsArg = outputPrefix + "%d" + outputExtension;
// In HA mode, use shorter segments and a bounded rolling buffer for faster failover recovery.
// state.SegmentLength is already validated by the streaming pipeline; RecoverySegmentLengthSeconds
// comes from EncodingOptions (user-editable config) so it is clamped here.
var effectiveSegmentLength = isHaMode
? Math.Clamp(_encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
: state.SegmentLength;
var hlsListSize = isHaMode
? Math.Clamp(_encodingOptions.RecoverySegmentBufferCount, 2, 10)
: 0;
var segmentFormat = string.Empty;
var segmentContainer = outputExtension.TrimStart('.');
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size {hlsListSize}";
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
{
@@ -1669,10 +1796,10 @@ public class DynamicHlsController : BaseJellyfinApiController
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
threads,
mapArgs,
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
GetAudioArguments(state),
maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat,
startNumber.ToString(CultureInfo.InvariantCulture),
baseUrlParam,
@@ -1814,8 +1941,9 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="startNumber">The first number in the hls sequence.</param>
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
/// <param name="segmentContainer">The segment container.</param>
/// <param name="segmentLength">The effective segment length in seconds (overrides <see cref="StreamState.SegmentLength"/> when HA mode is active).</param>
/// <returns>The command line arguments for video transcoding.</returns>
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer, int? segmentLength = null)
{
if (state.VideoStream is null)
{
@@ -1890,7 +2018,7 @@ public class DynamicHlsController : BaseJellyfinApiController
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
// Set the key frame params for video encoding to match the hls segment time.
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
+1 -1
View File
@@ -18,7 +18,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.11.8</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -6,12 +6,14 @@ using System.Reflection;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
namespace Jellyfin.Server.Implementations.Extensions;
@@ -24,6 +26,13 @@ public static class ServiceCollectionExtensions
private static IEnumerable<Type> DatabaseProviderTypes()
{
yield return typeof(SqliteDatabaseProvider);
yield return typeof(PostgreSqlDatabaseProvider);
}
private static int GetPoolOption(IEnumerable<CustomDatabaseOption>? options, string key, int defaultValue)
{
var value = options?.FirstOrDefault(o => o.Key.Equals(key, StringComparison.OrdinalIgnoreCase))?.Value;
return int.TryParse(value, out var parsed) ? parsed : defaultValue;
}
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
@@ -123,6 +132,50 @@ public static class ServiceCollectionExtensions
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
if (efCoreConfiguration.DatabaseType.Equals("Jellyfin-PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
serviceCollection.AddSingleton<NpgsqlDataSource>(static sp =>
{
var config = sp.GetRequiredService<IServerConfigurationManager>().GetConfiguration<DatabaseConfigurationOptions>("database");
var options = config.CustomProviderOptions?.Options;
var connectionString =
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? options
?.FirstOrDefault(o => o.Key.Equals("ConnectionString", StringComparison.OrdinalIgnoreCase))
?.Value
?? config.CustomProviderOptions?.ConnectionString
?? throw new InvalidOperationException(
"No PostgreSQL connection string found. Set the POSTGRES_CONNECTION_STRING environment variable, " +
"or provide it via CustomProviderOptions.Options[\"ConnectionString\"] or CustomProviderOptions.ConnectionString.");
// Support postgresql:// / postgres:// URI format (e.g. DATABASE_URL convention).
// NpgsqlDataSourceBuilder requires ADO.NET key=value format; convert if needed.
if (connectionString.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase)
|| connectionString.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(connectionString);
var userInfoParts = uri.UserInfo.Split(':', 2);
connectionString = new NpgsqlConnectionStringBuilder
{
Host = uri.Host,
Port = uri.Port > 0 ? uri.Port : 5432,
Database = uri.AbsolutePath.TrimStart('/'),
Username = userInfoParts.Length > 0 ? Uri.UnescapeDataString(userInfoParts[0]) : null,
Password = userInfoParts.Length > 1 ? Uri.UnescapeDataString(userInfoParts[1]) : null,
}.ToString();
}
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = GetPoolOption(options, "MinPoolSize", 2);
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = GetPoolOption(options, "MaxPoolSize", 20);
dataSourceBuilder.ConnectionStringBuilder.CommandTimeout = GetPoolOption(options, "CommandTimeout", 30);
return dataSourceBuilder.Build();
});
}
switch (efCoreConfiguration.LockingBehavior)
{
case DatabaseLockingBehaviorTypes.NoLock:
@@ -33,7 +33,6 @@ using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
@@ -70,7 +69,6 @@ public sealed class BaseItemRepository
private readonly IItemTypeLookup _itemTypeLookup;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly ILogger<BaseItemRepository> _logger;
private readonly ILocalizationManager _localizationManager;
private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist];
private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist];
@@ -87,21 +85,18 @@ public sealed class BaseItemRepository
/// <param name="itemTypeLookup">The static type lookup.</param>
/// <param name="serverConfigurationManager">The server Configuration manager.</param>
/// <param name="logger">System logger.</param>
/// <param name="localizationManager">Localization manager.</param>
public BaseItemRepository(
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost appHost,
IItemTypeLookup itemTypeLookup,
IServerConfigurationManager serverConfigurationManager,
ILogger<BaseItemRepository> logger,
ILocalizationManager localizationManager)
ILogger<BaseItemRepository> logger)
{
_dbProvider = dbProvider;
_appHost = appHost;
_itemTypeLookup = itemTypeLookup;
_serverConfigurationManager = serverConfigurationManager;
_logger = logger;
_localizationManager = localizationManager;
}
/// <inheritdoc />
@@ -2302,42 +2297,26 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoAudioTrackWithLanguage);
if (lang is not null)
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == filter.HasNoAudioTrackWithLanguage));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoInternalSubtitleTrackWithLanguage);
if (lang is not null)
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == filter.HasNoInternalSubtitleTrackWithLanguage));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoExternalSubtitleTrackWithLanguage);
if (lang is not null)
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == filter.HasNoExternalSubtitleTrackWithLanguage));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
var lang = _localizationManager.FindLanguageInfo(filter.HasNoSubtitleTrackWithLanguage);
if (lang is not null)
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
}
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == filter.HasNoSubtitleTrackWithLanguage));
}
if (filter.HasSubtitles.HasValue)
@@ -36,6 +36,7 @@
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
</ItemGroup>
+31
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Reflection;
using Emby.Server.Implementations;
using Emby.Server.Implementations.MediaEncoding;
using Emby.Server.Implementations.Session;
using Jellyfin.Api.WebSocketListeners;
using Jellyfin.Database.Implementations;
@@ -23,6 +24,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Security;
using MediaBrowser.Controller.Trickplay;
@@ -31,6 +33,7 @@ using MediaBrowser.Providers.Lyric;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Server
{
@@ -39,6 +42,8 @@ namespace Jellyfin.Server
/// </summary>
public class CoreAppHost : ApplicationHost
{
private readonly IConfiguration _startupConfig;
/// <summary>
/// Initializes a new instance of the <see cref="CoreAppHost" /> class.
/// </summary>
@@ -57,6 +62,7 @@ namespace Jellyfin.Server
options,
startupConfig)
{
_startupConfig = startupConfig;
}
/// <inheritdoc/>
@@ -98,6 +104,31 @@ namespace Jellyfin.Server
serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
// Transcode session store: Redis-backed when configured, no-op otherwise.
serviceCollection.Configure<TranscodeStoreOptions>(_startupConfig.GetSection("Jellyfin:TranscodeStore"));
var redisConnectionString = _startupConfig["Jellyfin:TranscodeStore:RedisConnectionString"];
if (!string.IsNullOrEmpty(redisConnectionString))
{
serviceCollection.AddSingleton<IConnectionMultiplexer>(sp =>
{
try
{
return ConnectionMultiplexer.Connect(redisConnectionString);
}
catch (Exception ex)
{
sp.GetRequiredService<ILogger<CoreAppHost>>()
.LogError(ex, "Failed to connect to Redis. Check the Jellyfin:TranscodeStore:RedisConnectionString configuration.");
throw;
}
});
serviceCollection.AddSingleton<ITranscodeSessionStore, RedisTranscodeSessionStore>();
}
else
{
serviceCollection.AddSingleton<ITranscodeSessionStore, NullTranscodeSessionStore>();
}
foreach (var type in GetExportTypes<ILyricProvider>())
{
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
+1
View File
@@ -59,6 +59,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Graylog" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -7,6 +7,7 @@ using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
@@ -49,7 +50,7 @@ internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
foreach (var virtualFolder in virtualFolders)
{
var options = virtualFolder.LibraryOptions;
if (options?.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
{
continue;
}
+5
View File
@@ -24,6 +24,7 @@ using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -285,6 +286,10 @@ namespace Jellyfin.Server
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
.AddSingleton<IApplicationPaths>(appPaths)
.AddSingleton<ServerApplicationPaths>(appPaths)
// Required by NpgsqlDataSource factory in AddJellyfinDbContext when
// DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI
// to read CustomProviderOptions and pool settings.
.AddSingleton<IServerConfigurationManager>(startupConfigurationManager)
.RegisterStartupLogger();
migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
+23
View File
@@ -67,6 +67,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Integration.Tests", "tests\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj", "{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Database.Tests.PostgreSQL", "tests\Jellyfin.Database.Tests.PostgreSQL\Jellyfin.Database.Tests.PostgreSQL.csproj", "{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}"
@@ -94,10 +96,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyf
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.PostgreSQL", "src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj", "{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{3C85DA50-31AC-40D3-BCF4-F1B14C420996}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.DbMigrator", "tools\Jellyfin.DbMigrator\Jellyfin.DbMigrator.csproj", "{6F7187CB-E1CB-4583-98CF-0FB87F21E844}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -216,6 +224,10 @@ Global
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = Release|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -256,6 +268,10 @@ Global
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.Build.0 = Release|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -264,6 +280,10 @@ Global
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -283,6 +303,7 @@ Global
{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
@@ -294,8 +315,10 @@ Global
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{6F7187CB-E1CB-4583-98CF-0FB87F21E844} = {3C85DA50-31AC-40D3-BCF4-F1B14C420996}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Common</PackageId>
<VersionPrefix>10.11.8</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId>
<VersionPrefix>10.11.8</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -0,0 +1,104 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Provides a durable store for HLS transcoding session state, enabling
/// HA recovery and lease-based ownership between pods.
/// </summary>
public interface ITranscodeSessionStore
{
/// <summary>
/// Attempts to retrieve a transcoding session by its play session identifier.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// The <see cref="TranscodeSession"/> if it exists and its lease has not expired;
/// otherwise <c>null</c>.
/// </returns>
Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to take over ownership of an existing session by claiming the lease for
/// <paramref name="claimingPod"/>. Takeover succeeds only when the session exists and
/// its current lease has already expired.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="claimingPod">The name of the pod attempting to claim ownership.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// <c>true</c> if the takeover succeeded (the claiming pod now holds the lease);
/// <c>false</c> if the session does not exist, its lease is still valid, or another
/// concurrent caller already claimed it.
/// </returns>
Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default);
/// <summary>
/// Persists a new or updated transcoding session.
/// </summary>
/// <param name="session">The session to store.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default);
/// <summary>
/// Renews the lease for an existing session, extending its
/// <see cref="TranscodeSession.LeaseExpiresUtc"/> by the store's configured lease duration.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a transcoding session from the store.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Returns all currently active transcoding sessions from the store.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// An enumerable of <see cref="TranscodeSession"/> objects representing all active sessions.
/// Returns an empty enumerable if no sessions are active or if the store cannot be reached.
/// </returns>
Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Persists a live stream session record so that takeover pods can identify and close
/// streams that were opened on a pod that has since crashed or been evicted.
/// </summary>
/// <param name="session">The live stream session to store.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to retrieve a live stream session by its live stream identifier and the
/// session or play-session identifier that owns it.
/// </summary>
/// <param name="liveStreamId">The live stream identifier.</param>
/// <param name="sessionIdOrPlaySessionId">The session identifier or play-session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// The <see cref="LiveStreamSession"/> if it exists; otherwise <c>null</c>.
/// </returns>
Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Removes the live stream session record for the given live stream and session identifier.
/// This is called when the stream is closed, either by the owning pod or a takeover pod.
/// </summary>
/// <param name="liveStreamId">The live stream identifier.</param>
/// <param name="sessionIdOrPlaySessionId">The session identifier or play-session identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,36 @@
using System;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Represents a durable record of an open live stream session, enabling HA pod recovery
/// when the owning pod crashes or is evicted.
/// </summary>
public sealed class LiveStreamSession
{
/// <summary>
/// Gets or sets the live stream identifier (e.g. a TV tuner channel token).
/// </summary>
public string LiveStreamId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the session identifier of the client that opened this live stream.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the play session identifier associated with this live stream,
/// or an empty string when the client did not supply one.
/// </summary>
public string PlaySessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the name of the pod that currently holds this live stream open.
/// </summary>
public string OwnerPod { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the UTC time at which this record was created.
/// </summary>
public DateTime OpenedAtUtc { get; set; }
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// A no-op implementation of <see cref="ITranscodeSessionStore"/> used in single-instance deployments
/// where durable session tracking across pods is not required.
/// </summary>
public sealed class NullTranscodeSessionStore : ITranscodeSessionStore
{
/// <inheritdoc />
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<TranscodeSession?>(null);
/// <inheritdoc />
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
/// <inheritdoc />
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IEnumerable<TranscodeSession>>(Array.Empty<TranscodeSession>());
/// <inheritdoc />
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<LiveStreamSession?>(null);
/// <inheritdoc />
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
@@ -0,0 +1,49 @@
using System;
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Represents a durable record of an HLS transcoding session for HA pod recovery.
/// </summary>
public sealed class TranscodeSession
{
/// <summary>
/// Gets or sets the unique play session identifier.
/// </summary>
public string PlaySessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the name of the pod that currently owns this session's lease.
/// </summary>
public string OwnerPod { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the UTC time at which the owning pod's lease expires.
/// </summary>
public DateTime LeaseExpiresUtc { get; set; }
/// <summary>
/// Gets or sets the absolute path to the HLS manifest (.m3u8) file on shared storage.
/// </summary>
public string ManifestPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path prefix for transcoded segment files on shared storage.
/// </summary>
public string SegmentPathPrefix { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the media source identifier associated with this session.
/// </summary>
public string MediaSourceId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the zero-based index of the last segment that was fully written to durable storage.
/// </summary>
public int LastCompletedSegmentIndex { get; set; }
/// <summary>
/// Gets or sets the last durable playback offset in ticks, used to resume playback after failover.
/// </summary>
public long LastDurablePlaybackOffset { get; set; }
}
@@ -0,0 +1,19 @@
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Configuration options for the transcode session store.
/// </summary>
public sealed class TranscodeStoreOptions
{
/// <summary>
/// Gets or sets the Redis connection string.
/// A <c>null</c> or empty value indicates single-instance mode, where
/// <see cref="NullTranscodeSessionStore"/> is used instead of a Redis-backed store.
/// </summary>
public string? RedisConnectionString { get; set; }
/// <summary>
/// Gets or sets the duration in seconds for which a transcoding session lease is valid.
/// </summary>
public int LeaseDurationSeconds { get; set; } = 30;
}
@@ -24,6 +24,8 @@ public class EncodingOptions
ThrottleDelaySeconds = 180;
EnableSegmentDeletion = false;
SegmentKeepSeconds = 720;
RecoverySegmentLengthSeconds = 2;
RecoverySegmentBufferCount = 5;
EncodingThreadCount = -1;
// This is a DRM device that is almost guaranteed to be there on every intel platform,
// plus it's the default one in ffmpeg if you don't specify anything
@@ -121,6 +123,20 @@ public class EncodingOptions
/// </summary>
public int SegmentKeepSeconds { get; set; }
/// <summary>
/// Gets or sets the HLS segment length in seconds to use when HA recovery mode is active.
/// Shorter segments allow a takeover pod to resume playback faster after a peer failure.
/// Default is <c>2</c>. Valid range is 16.
/// </summary>
public int RecoverySegmentLengthSeconds { get; set; }
/// <summary>
/// Gets or sets the number of HLS segments to keep on disk when HA recovery mode is active.
/// This acts as a rolling buffer that a takeover pod can serve while restarting the transcode.
/// Default is <c>5</c>. Valid range is 210.
/// </summary>
public int RecoverySegmentBufferCount { get; set; }
/// <summary>
/// Gets or sets the hardware acceleration type.
/// </summary>
+1 -1
View File
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId>
<VersionPrefix>10.11.8</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -239,7 +239,7 @@ namespace MediaBrowser.Providers.Subtitles
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
{
if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
if (!_allowedSubtitleFormats.Contains("." + extension, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"Invalid subtitle format: {extension}");
}
+535 -67
View File
@@ -1,82 +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.**
[![License: GPL v2](https://img.shields.io/badge/License-GPL_v2-blue.svg)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)
[![.NET 10](https://img.shields.io/badge/.NET-10.0-purple)](https://dotnet.microsoft.com/download/dotnet/10.0)
[![Upstream](https://img.shields.io/badge/upstream-jellyfin%2Fjellyfin-informational)](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 9.0 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) `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](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).
@@ -94,13 +563,12 @@ git clone https://github.com/jellyfin/jellyfin.git
The server is configured to host the static files required for the [web client](https://github.com/jellyfin/jellyfin-web) 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 also possible to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
Note that it is recommended for development to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
There are three options to get the files for the web client.
There are two options to get the files for the web client.
1. Download one of the finished builds from the [Azure DevOps pipeline](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27). You can download the build for a specific release by looking at the [branches tab](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27&_a=summary&repositoryFilter=6&view=branches) of the pipelines page.
2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
3. 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`
1. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
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
@@ -133,7 +601,7 @@ A second option is to build the project and then run the resulting executable fi
```bash
dotnet build # Build the project
cd Jellyfin.Server/bin/Debug/net9.0 # Change into the build output directory
cd Jellyfin.Server/bin/Debug/net10.0 # Change into the build output directory
```
2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`.
@@ -198,5 +666,5 @@ This project is supported by:
<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>
&nbsp;
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/fa104b7d73f759d7262794b94569f1b89df41c0b/jetbrains.svg" height="50px" alt="JetBrains logo"></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>
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Reflection;
[assembly: AssemblyVersion("10.11.8")]
[assembly: AssemblyFileVersion("10.11.8")]
[assembly: AssemblyVersion("10.11.7")]
[assembly: AssemblyFileVersion("10.11.7")]
+22
View File
@@ -0,0 +1,22 @@
apiVersion: v2
name: jellyfin-ha
description: >
High-availability Jellyfin media server with Redis-backed transcode session
store, lease-aware segment cleanup, and optional PostgreSQL database provider
for multi-pod Kubernetes deployments.
type: application
version: 0.1.0
appVersion: "10.12.0"
keywords:
- jellyfin
- media-server
- high-availability
- redis
- kubernetes
home: https://github.com/ZoltyMat/jellyfin-ha
sources:
- https://github.com/ZoltyMat/jellyfin-ha
maintainers:
- name: ZoltyMat
url: https://github.com/ZoltyMat
icon: https://raw.githubusercontent.com/jellyfin/jellyfin/master/Jellyfin.Server/Resources/Images/jellyfin-icon-solid.png
@@ -0,0 +1,49 @@
1. Jellyfin HA has been deployed.
{{- if eq (int .Values.replicaCount) 1 }}
⚠ replicaCount=1 — running in single-instance mode. Set replicaCount >= 2 and
ha.enabled=true to enable HA transcoding.
{{- else }}
✔ Running {{ .Values.replicaCount }} replicas.
{{- if include "jellyfin-ha.haEnabled" . }}
✔ HA mode: ACTIVE — transcode sessions replicated via Redis.
{{- else }}
⚠ HA mode: INACTIVE — NullTranscodeSessionStore in use.
Set redis.enabled=true (or ha.transcodeStore.redisConnectionString) to enable HA.
{{- end }}
{{- end }}
2. Get the Jellyfin URL:
{{- if .Values.ingress.enabled }}
{{- range .Values.ingress.hosts }}
https://{{ .host }}/
{{- end }}
{{- else if .Values.traefikIngressRoute.enabled }}
https://{{ .Values.traefikIngressRoute.host }}/
{{- else }}
Access via port-forward:
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "jellyfin-ha.fullname" . }} 8096:{{ .Values.service.port }}
http://localhost:8096/
{{- end }}
3. PostgreSQL:
{{- if .Values.postgresql.enabled }}
✔ In-cluster PostgreSQL deployed. Ensure the secret "{{ .Values.postgresql.existingSecret }}"
exists in namespace {{ .Release.Namespace }} before starting the server.
{{- else if eq .Values.config.databaseType "Jellyfin-PostgreSQL" }}
⚠ config.databaseType=Jellyfin-PostgreSQL but postgresql.enabled=false.
Make sure you have an external PostgreSQL and the correct DATABASE_URL env var set.
{{- else }}
Using SQLite (default). Enable postgresql.enabled=true for a shared database backend.
{{- end }}
4. Transcode storage:
The transcode PVC must be ReadWriteMany when replicaCount > 1.
Current accessMode: {{ .Values.persistence.transcode.accessMode }}
{{- if and (gt (int .Values.replicaCount) 1) (ne .Values.persistence.transcode.accessMode "ReadWriteMany") }}
⚠ WARNING: replicaCount > 1 but transcode accessMode is not ReadWriteMany.
Pod B cannot read Pod A's HLS segments during session takeover.
Set persistence.transcode.accessMode=ReadWriteMany or use an NFS / Longhorn RWX PVC.
{{- end }}
@@ -0,0 +1,137 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "jellyfin-ha.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "jellyfin-ha.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart label.
*/}}
{{- define "jellyfin-ha.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "jellyfin-ha.labels" -}}
helm.sh/chart: {{ include "jellyfin-ha.chart" . }}
{{ include "jellyfin-ha.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "jellyfin-ha.selectorLabels" -}}
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
{{- end }}
{{/*
Service account name.
*/}}
{{- define "jellyfin-ha.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "jellyfin-ha.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Fully qualified name of the in-cluster Redis service.
*/}}
{{- define "jellyfin-ha.redis.fullname" -}}
{{- printf "%s-redis" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{/*
Fully qualified name of the in-cluster PostgreSQL service.
*/}}
{{- define "jellyfin-ha.postgres.fullname" -}}
{{- printf "%s-postgres" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{/*
Compute the Redis connection string.
Priority:
1. existingSecret (mounted as env var in the statefulset template)
2. explicit ha.transcodeStore.redisConnectionString value
3. auto-compose from the in-cluster Redis service name when redis.enabled=true
Returns empty string if none of the above apply (= single-instance / NullStore mode).
This helper returns the literal string only for cases 2 and 3; case 1 is handled
directly in the container env block via secretKeyRef.
*/}}
{{- define "jellyfin-ha.redisConnectionString" -}}
{{- if .Values.ha.transcodeStore.redisConnectionString }}
{{- .Values.ha.transcodeStore.redisConnectionString }}
{{- else if .Values.redis.enabled }}
{{- printf "%s:6379,abortConnect=false" (include "jellyfin-ha.redis.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Return true if HA mode is active and Redis should be wired up.
*/}}
{{- define "jellyfin-ha.haEnabled" -}}
{{- if and .Values.ha.enabled (or .Values.redis.enabled .Values.ha.transcodeStore.redisConnectionString .Values.ha.transcodeStore.existingSecret) }}
{{- "true" }}
{{- end }}
{{- end }}
{{/*
Config PVC claim name — either the existing claim or the chart-managed one.
*/}}
{{- define "jellyfin-ha.configPvcName" -}}
{{- if .Values.persistence.config.existingClaim }}
{{- .Values.persistence.config.existingClaim }}
{{- else }}
{{- printf "%s-config" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Transcode PVC claim name — either the existing claim or the chart-managed one.
*/}}
{{- define "jellyfin-ha.transcodePvcName" -}}
{{- if .Values.persistence.transcode.existingClaim }}
{{- .Values.persistence.transcode.existingClaim }}
{{- else }}
{{- printf "%s-transcode" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Media PVC claim name — either the existing claim or the chart-managed NFS PVC.
*/}}
{{- define "jellyfin-ha.mediaPvcName" -}}
{{- if .Values.persistence.media.existingClaim }}
{{- .Values.persistence.media.existingClaim }}
{{- else if .Values.persistence.media.nfs.enabled }}
{{- printf "%s-media" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
@@ -0,0 +1,15 @@
{{- if .Values.runtimeConfig.enabled }}
# jellyfin.runtimeconfig.json ConfigMap.
# Mount path: /jellyfin/jellyfin.runtimeconfig.json
# Use this to set .NET runtime configuration switches (e.g. Intel QSV codec flags).
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-runtimeconfig
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
data:
jellyfin.runtimeconfig.json: |
{{- .Values.runtimeConfig.json | nindent 4 }}
{{- end }}
@@ -0,0 +1,97 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "jellyfin-ha.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}
---
{{- if .Values.traefikIngressRoute.enabled }}
# Traefik v3 IngressRoute (used by k3s default ingress controller).
# Enables sticky session cookies — required for multi-replica Jellyfin so that
# a client always lands on the same pod (session affinity).
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- if .Values.traefikIngressRoute.tls.enabled }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
{{- end }}
spec:
entryPoints:
{{- toYaml .Values.traefikIngressRoute.entryPoints | nindent 4 }}
routes:
- match: Host(`{{ .Values.traefikIngressRoute.host }}`)
kind: Rule
services:
- name: {{ include "jellyfin-ha.fullname" . }}
port: {{ .Values.service.port }}
{{- if .Values.traefikIngressRoute.sticky.enabled }}
sticky:
cookie:
name: {{ .Values.traefikIngressRoute.sticky.cookieName }}
httpOnly: {{ .Values.traefikIngressRoute.sticky.httpOnly }}
secure: {{ .Values.traefikIngressRoute.sticky.secure }}
{{- end }}
{{- if .Values.traefikIngressRoute.tls.enabled }}
tls:
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
{{- end }}
---
{{- if .Values.traefikIngressRoute.tls.enabled }}
# cert-manager Certificate for Traefik TLS termination.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-tls
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
spec:
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
issuerRef:
name: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
kind: ClusterIssuer
dnsNames:
{{- if .Values.traefikIngressRoute.tls.dnsNames }}
{{- toYaml .Values.traefikIngressRoute.tls.dnsNames | nindent 4 }}
{{- else }}
- {{ .Values.traefikIngressRoute.host }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,14 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if .Values.postgresql.enabled }}
# PersistentVolumeClaim for the in-cluster PostgreSQL data directory.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}-data
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.postgresql.persistence.storageClass }}
storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgresql.persistence.size }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.postgresql.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
ports:
- name: postgres
port: {{ .Values.postgresql.service.port }}
targetPort: postgres
protocol: TCP
{{- end }}
@@ -0,0 +1,84 @@
{{- if .Values.postgresql.enabled }}
# In-cluster PostgreSQL StatefulSet — experimental.
# The credentials secret must be created manually before first deploy:
#
# kubectl create secret generic {{ .Values.postgresql.existingSecret }} \
# --namespace {{ .Release.Namespace }} \
# --from-literal=POSTGRES_USER=jellyfin \
# --from-literal=POSTGRES_PASSWORD=<strong-password> \
# --from-literal=POSTGRES_DB=jellyfin \
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@{{ include "jellyfin-ha.postgres.fullname" . }}:5432/jellyfin"
#
# SECURITY: Do NOT add a Secret resource here. Applying this file must not
# overwrite a live secret.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
replicas: 1
serviceName: {{ include "jellyfin-ha.postgres.fullname" . }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
spec:
containers:
- name: postgres
image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}"
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_DB
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
{{- toYaml .Values.postgresql.resources | nindent 12 }}
livenessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.postgres.fullname" . }}-data
{{- end }}
@@ -0,0 +1,90 @@
{{- if not .Values.persistence.config.existingClaim }}
# Shared config PVC — used by all Jellyfin replicas.
# Must be ReadWriteMany when replicaCount > 1.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- {{ .Values.persistence.config.accessMode }}
{{- if .Values.persistence.config.storageClass }}
storageClassName: {{ .Values.persistence.config.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.config.size }}
{{- end }}
---
{{- if not .Values.persistence.transcode.existingClaim }}
# Shared transcode PVC — must be ReadWriteMany so pod takeover can read
# HLS segments written by the previous owner pod.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-transcode
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- {{ .Values.persistence.transcode.accessMode }}
{{- if .Values.persistence.transcode.storageClass }}
storageClassName: {{ .Values.persistence.transcode.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.transcode.size }}
{{- end }}
---
{{- if and .Values.persistence.media.nfs.enabled (not .Values.persistence.media.existingClaim) }}
# NFS PersistentVolume and PersistentVolumeClaim for the media library.
# Enable persistence.media.nfs.enabled and provide server/path to use this.
# Alternatively, set persistence.media.existingClaim to reuse an existing PVC.
apiVersion: v1
kind: PersistentVolume
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-media-nfs
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
capacity:
storage: {{ .Values.persistence.media.nfs.size }}
accessModes:
- ReadOnlyMany
persistentVolumeReclaimPolicy: Retain
{{- if .Values.persistence.media.nfs.storageClass }}
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
{{- end }}
nfs:
server: {{ .Values.persistence.media.nfs.server | quote }}
path: {{ .Values.persistence.media.nfs.path | quote }}
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-media
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- ReadOnlyMany
{{- if .Values.persistence.media.nfs.storageClass }}
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.media.nfs.size }}
volumeName: {{ include "jellyfin-ha.fullname" . }}-media-nfs
{{- end }}
@@ -0,0 +1,15 @@
{{- if .Values.redis.enabled }}
# ConfigMap holding the Redis configuration file.
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
data:
redis.conf: |
maxmemory {{ .Values.redis.maxmemory }}
maxmemory-policy {{ .Values.redis.maxmemoryPolicy }}
{{- end }}
@@ -0,0 +1,58 @@
{{- if .Values.redis.enabled }}
# In-cluster Redis Deployment for jellyifn-ha transcode session store.
# No persistence — lease data is small and reconstructable on restart.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
containers:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
args: ["redis-server", "/etc/redis/redis.conf"]
ports:
- name: redis
containerPort: 6379
protocol: TCP
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- name: config
mountPath: /etc/redis
volumes:
- name: config
configMap:
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
protocol: TCP
{{- end }}
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "jellyfin-ha.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
@@ -0,0 +1,27 @@
{{- if .Values.serviceMonitor.enabled }}
# Prometheus ServiceMonitor.
# Jellyfin does not expose a native /metrics endpoint. Enable this if you have
# a Prometheus sidecar or plan to add one. The kube-state-metrics replica count
# alert is the primary health signal for Jellyfin without a native exporter.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.serviceMonitor.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
endpoints:
- port: http
path: {{ .Values.serviceMonitor.path }}
interval: {{ .Values.serviceMonitor.interval }}
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
{{- end }}
@@ -0,0 +1,277 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
serviceName: {{ include "jellyfin-ha.fullname" . }}
updateStrategy:
{{- toYaml .Values.updateStrategy | nindent 4 }}
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "jellyfin-ha.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
# ---------------------------------------------------------------------------
# Affinity / anti-affinity
# ---------------------------------------------------------------------------
affinity:
{{- if and .Values.gpu.enabled .Values.gpu.intel.nodeLabel.key }}
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: {{ .Values.gpu.intel.nodeLabel.key }}
operator: In
values:
- {{ .Values.gpu.intel.nodeLabel.value }}
{{- end }}
{{- if .Values.podAntiAffinity.enabled }}
podAntiAffinity:
{{- if eq .Values.podAntiAffinity.type "required" }}
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 18 }}
topologyKey: kubernetes.io/hostname
{{- else }}
preferredDuringSchedulingIgnoredDuringExecution:
- weight: {{ .Values.podAntiAffinity.weight }}
podAffinityTerm:
labelSelector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 20 }}
topologyKey: kubernetes.io/hostname
{{- end }}
{{- end }}
# GPU node toleration
{{- if .Values.gpu.enabled }}
tolerations:
- key: {{ .Values.gpu.intel.toleration.key }}
operator: Equal
value: {{ .Values.gpu.intel.toleration.value | quote }}
effect: {{ .Values.gpu.intel.toleration.effect }}
{{- end }}
# ---------------------------------------------------------------------------
# Init containers
# ---------------------------------------------------------------------------
initContainers:
{{- if eq .Values.config.databaseType "Jellyfin-PostgreSQL" }}
# Inject database.xml to select the PostgreSQL provider at startup.
- name: inject-db-config
image: busybox:1.37.0
command:
- sh
- -c
- |
mkdir -p /config/config
chown {{ .Values.securityContext.runAsUser }}:{{ .Values.securityContext.runAsGroup }} /config/config
chmod 775 /config/config
cat > /config/config/database.xml << 'DBEOF'
<?xml version="1.0" encoding="utf-8"?>
<DatabaseConfigurationOptions>
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<LockingBehavior>NoLock</LockingBehavior>
</DatabaseConfigurationOptions>
DBEOF
chown {{ .Values.securityContext.runAsUser }}:{{ .Values.securityContext.runAsGroup }} /config/config/database.xml
chmod 664 /config/config/database.xml
echo "database.xml injected."
volumeMounts:
- name: config
mountPath: /config
{{- end }}
{{- with .Values.extraInitContainers }}
{{- toYaml . | nindent 8 }}
{{- end }}
# ---------------------------------------------------------------------------
# Main container
# ---------------------------------------------------------------------------
containers:
- name: jellyfin
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8096
protocol: TCP
env:
# Pod identity — used by the Redis transcode lease store to identify this replica.
- name: JELLYFIN_HA_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: JELLYFIN_INSTANCE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
# Disable UDP auto-discovery when running multiple replicas.
- name: JELLYFIN_Network__AutoDiscovery
value: {{ .Values.config.autoDiscovery | quote }}
# Config directory (must differ from data root; see Jellyfin sanity check).
- name: JELLYFIN_CONFIG_DIR
value: {{ .Values.config.configDir | quote }}
{{- if .Values.config.publishedServerUrl }}
- name: JELLYFIN_PublishedServerUrl
value: {{ .Values.config.publishedServerUrl | quote }}
{{- end }}
# ---------------------------------------------------------------------------
# Redis (HA transcode session store)
# ---------------------------------------------------------------------------
{{- if include "jellyfin-ha.haEnabled" . }}
{{- if .Values.ha.transcodeStore.existingSecret }}
# Connection string sourced from an existing secret.
- name: Jellyfin__TranscodeStore__RedisConnectionString
valueFrom:
secretKeyRef:
name: {{ .Values.ha.transcodeStore.existingSecret }}
key: {{ .Values.ha.transcodeStore.existingSecretKey }}
{{- else }}
- name: Jellyfin__TranscodeStore__RedisConnectionString
value: {{ include "jellyfin-ha.redisConnectionString" . | quote }}
{{- end }}
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
value: {{ .Values.ha.transcodeStore.leaseDurationSeconds | quote }}
{{- end }}
# ---------------------------------------------------------------------------
# PostgreSQL (experimental)
# ---------------------------------------------------------------------------
{{- if and .Values.postgresql.enabled (eq .Values.config.databaseType "Jellyfin-PostgreSQL") }}
- name: POSTGRES_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: DATABASE_URL
{{- end }}
# ---------------------------------------------------------------------------
# Extra environment variables
# ---------------------------------------------------------------------------
{{- with .Values.config.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
privileged: {{ if and .Values.gpu.enabled .Values.gpu.mountDri }}true{{ else }}{{ .Values.securityContext.privileged }}{{ end }}
runAsUser: {{ .Values.securityContext.runAsUser }}
runAsGroup: {{ .Values.securityContext.runAsGroup }}
volumeMounts:
- name: config
mountPath: /config
{{- if or .Values.persistence.media.existingClaim (and .Values.persistence.media.nfs.enabled) }}
- name: media
mountPath: /media
readOnly: true
{{- end }}
- name: transcode
mountPath: /config/transcodes
- name: cache
mountPath: /cache
{{- if and .Values.gpu.enabled .Values.gpu.mountDri }}
- name: dri
mountPath: /dev/dri
{{- end }}
{{- if .Values.runtimeConfig.enabled }}
- name: runtimeconfig
mountPath: /jellyfin/jellyfin.runtimeconfig.json
subPath: jellyfin.runtimeconfig.json
readOnly: true
{{- end }}
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
# ---------------------------------------------------------------------------
# Volumes (static — shared across all pods)
# ---------------------------------------------------------------------------
volumes:
- name: config
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.configPvcName" . }}
- name: transcode
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.transcodePvcName" . }}
{{- if or .Values.persistence.media.existingClaim .Values.persistence.media.nfs.enabled }}
- name: media
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.mediaPvcName" . }}
{{- end }}
{{- if and .Values.gpu.enabled .Values.gpu.mountDri }}
- name: dri
hostPath:
path: /dev/dri
{{- end }}
{{- if .Values.runtimeConfig.enabled }}
- name: runtimeconfig
configMap:
name: {{ include "jellyfin-ha.fullname" . }}-runtimeconfig
{{- end }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
# ---------------------------------------------------------------------------
# Per-pod volumes via volumeClaimTemplates
# Cache is per-pod (RWO) — each replica has an independent transcoding cache,
# which avoids lock contention and is safe to lose on pod termination.
# ---------------------------------------------------------------------------
volumeClaimTemplates:
- metadata:
name: cache
labels:
{{- include "jellyfin-ha.labels" . | nindent 10 }}
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.persistence.cache.storageClass }}
storageClassName: {{ .Values.persistence.cache.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.cache.size }}
+405
View File
@@ -0,0 +1,405 @@
# Default values for jellyfin-ha.
# This is a YAML-formatted file.
# -- Override the chart name.
nameOverride: ""
# -- Override the full resource name prefix.
fullnameOverride: ""
# -- Number of Jellyfin replicas.
# Set >= 2 to use HA mode. When replicaCount > 1, ha.enabled should be true
# and a Redis connection must be configured (via redis.enabled or ha.transcodeStore.redisConnectionString).
replicaCount: 2
# -- Container image configuration.
image:
repository: "your-registry/jellyfin-ha"
tag: "latest"
pullPolicy: IfNotPresent
# -- Image pull secrets (e.g. for private ECR registries).
# Example:
# - name: ecr-pull-secret
imagePullSecrets: []
# ---------------------------------------------------------------------------
# HA (High-Availability) configuration
# ---------------------------------------------------------------------------
ha:
# -- Enable HA mode. When true, a Redis connection string is required
# (either via redis.enabled or ha.transcodeStore.redisConnectionString).
# When false, NullTranscodeSessionStore is used and behavior is identical
# to upstream Jellyfin.
enabled: true
transcodeStore:
# -- StackExchange.Redis connection string.
# Leave empty to auto-compose from the in-cluster Redis service when redis.enabled=true.
# Explicit examples:
# redis:6379
# redis:6379,password=secret
# redis.example.com:6380,ssl=true,abortConnect=false
# sentinel-host:26379,serviceName=mymaster
redisConnectionString: ""
# -- How long (seconds) a pod's transcode lease is valid before another pod may take over.
leaseDurationSeconds: 30
# -- Secret containing the Redis connection string.
# If set, the connection string is read from this secret instead of the value above.
# The secret must have a key named by existingSecret.key.
existingSecret: ""
existingSecretKey: "connection-string"
# ---------------------------------------------------------------------------
# In-cluster Redis (for transcode session store)
# ---------------------------------------------------------------------------
redis:
# -- Deploy an in-cluster Redis instance.
# Disable and set ha.transcodeStore.redisConnectionString to use an external Redis.
enabled: true
image:
repository: redis
tag: "7.4.2-alpine3.21"
pullPolicy: IfNotPresent
# -- Maximum memory for Redis to use.
maxmemory: "256mb"
# -- LRU eviction policy when maxmemory is reached.
maxmemoryPolicy: "allkeys-lru"
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
# ---------------------------------------------------------------------------
# Jellyfin application configuration
# ---------------------------------------------------------------------------
config:
# -- The externally-reachable URL Jellyfin reports to clients.
publishedServerUrl: ""
# -- Disable UDP auto-discovery (port 7359).
# Recommended when running multiple replicas to prevent duplicate discovery responses.
autoDiscovery: false
# -- Jellyfin config directory inside the container.
# Must differ from the data/root directory to pass Jellyfin's sanity check.
configDir: "/config/config"
# -- Database provider: "SQLite" (default) or "Jellyfin-PostgreSQL" (experimental).
# When set to "Jellyfin-PostgreSQL", an init container will inject database.xml
# and the postgresql.enabled section (or an external connection string) must be configured.
databaseType: "SQLite"
# -- Extra environment variables to set on the Jellyfin container.
# Example:
# - name: JELLYFIN_Network__BaseUrl
# value: "/jellyfin"
extraEnv: []
# ---------------------------------------------------------------------------
# PostgreSQL (experimental — only needed when config.databaseType = Jellyfin-PostgreSQL)
# ---------------------------------------------------------------------------
postgresql:
# -- Deploy an in-cluster PostgreSQL instance.
enabled: false
image:
repository: postgres
tag: "16.6-alpine3.21"
pullPolicy: IfNotPresent
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
persistence:
storageClass: ""
size: 5Gi
# -- Name of an existing secret with PostgreSQL credentials.
# Required when postgresql.enabled=true. The secret must contain:
# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, DATABASE_URL
# Create it with:
# kubectl create secret generic jellyfin-postgres-credentials \
# --from-literal=POSTGRES_USER=jellyfin \
# --from-literal=POSTGRES_PASSWORD=<password> \
# --from-literal=POSTGRES_DB=jellyfin \
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@<host>:5432/jellyfin"
existingSecret: "jellyfin-postgres-credentials"
service:
port: 5432
# ---------------------------------------------------------------------------
# GPU / hardware transcoding
# ---------------------------------------------------------------------------
gpu:
# -- Enable Intel QSV / VA-API hardware transcoding.
# Mounts /dev/dri from the host and sets the required security context.
enabled: false
intel:
# -- Node affinity label to prefer GPU-capable nodes.
nodeLabel:
key: gpu
value: intel-uhd-630
# -- Toleration for the GPU node taint.
toleration:
key: gpu
value: "true"
effect: NoSchedule
# -- Mount /dev/dri from the host (required for VA-API; implies privileged=true).
mountDri: true
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
persistence:
# Config volume — single-writer; RWO is fine for single-replica deployments.
# For multi-replica: use an RWX storage class (e.g. Longhorn RWX, NFS) or
# point all pods at an existing shared PVC via existingClaim.
config:
# -- Size of the config PVC.
size: 5Gi
# -- Storage class. Leave empty to use the cluster default.
storageClass: ""
# -- Access mode. Use ReadWriteMany when replicaCount > 1 and sharing one PVC.
accessMode: ReadWriteMany
# -- Reuse an existing PVC. When set, no new PVC is created.
existingClaim: ""
# Media volume — read-only mount shared by all pods.
# Configure one of: existingClaim (for an existing PVC), nfs (to create an NFS PV+PVC),
# or existingClaim pointing at a pre-created PVC.
media:
# -- Reuse an existing media PVC (most common for homelab NFS/Longhorn setups).
existingClaim: ""
# -- Create an NFS-backed PV and PVC for the media library.
nfs:
enabled: false
server: "your-nas.local"
path: "/media"
size: 1Ti
storageClass: ""
# Transcode volume — MUST be ReadWriteMany when replicaCount > 1 so that
# a recovering pod can read HLS segments written by the pod it is replacing.
# When replicaCount=1, ReadWriteOnce is acceptable.
transcode:
size: 30Gi
storageClass: ""
accessMode: ReadWriteMany
existingClaim: ""
# Per-pod cache volume — local to each pod; always RWO.
# Created via StatefulSet volumeClaimTemplates (one PVC per pod).
cache:
size: 30Gi
storageClass: ""
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
service:
type: ClusterIP
port: 8096
# -- Annotations for the Service resource.
annotations: {}
# ---------------------------------------------------------------------------
# Ingress (standard Kubernetes Ingress)
# ---------------------------------------------------------------------------
ingress:
enabled: false
# -- Ingress class name (e.g. "nginx", "traefik").
className: ""
annotations: {}
# cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
hosts:
- host: jellyfin.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: jellyfin-tls
# hosts:
# - jellyfin.example.com
# ---------------------------------------------------------------------------
# Traefik IngressRoute (Traefik v3 CRD — used by k3s default ingress)
# ---------------------------------------------------------------------------
traefikIngressRoute:
enabled: false
entryPoints:
- websecure
# -- Hostname for the Traefik routing rule.
host: "jellyfin.example.com"
# -- Enable sticky session cookie (recommended for multi-replica Jellyfin).
sticky:
enabled: true
cookieName: "jellyfin-server-id"
httpOnly: true
secure: true
# -- cert-manager Certificate resource for TLS.
tls:
enabled: false
secretName: "jellyfin-tls"
clusterIssuer: "letsencrypt-prod"
dnsNames: []
# - jellyfin.example.com
# ---------------------------------------------------------------------------
# Resource requests and limits for the Jellyfin container
# ---------------------------------------------------------------------------
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "4"
memory: 4Gi
# ---------------------------------------------------------------------------
# Liveness and readiness probes
# ---------------------------------------------------------------------------
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# ---------------------------------------------------------------------------
# Security context
# ---------------------------------------------------------------------------
# Container-level security context.
securityContext:
# -- Set to true only when GPU passthrough via /dev/dri is required.
# privileged=true is required for DRM ioctls (VA-API). Omit (false) for
# software-only transcoding.
privileged: false
# -- UID for the Jellyfin process. Use 10010 to match the svc-jellyfin NAS account
# when NFS root_squash is enabled.
runAsUser: 1000
runAsGroup: 1000
# Pod-level security context.
podSecurityContext:
# -- fsGroup ensures mounted volumes are group-writable.
fsGroup: 1000
# -- Additional groups for /dev/dri access (video=44, render=109 or 991).
supplementalGroups: []
# - 44 # video
# - 109 # render (legacy)
# - 991 # render (Debian 13 trixie)
seccompProfile:
type: RuntimeDefault
# ---------------------------------------------------------------------------
# Service account
# ---------------------------------------------------------------------------
serviceAccount:
create: false
name: ""
annotations: {}
# ---------------------------------------------------------------------------
# Pod Disruption Budget
# ---------------------------------------------------------------------------
podDisruptionBudget:
enabled: true
minAvailable: 1
# ---------------------------------------------------------------------------
# Pod anti-affinity (spread replicas across nodes for node-level HA)
# ---------------------------------------------------------------------------
podAntiAffinity:
enabled: true
# -- "preferred" won't block scheduling if nodes are insufficient.
# Use "required" to enforce strict cross-node placement.
type: preferred
weight: 100
# ---------------------------------------------------------------------------
# Prometheus ServiceMonitor
# Note: Jellyfin has no native /metrics endpoint. This ServiceMonitor is
# included for future use (e.g. if you add a sidecar exporter) or for
# blackbox-style readiness monitoring. Disable if not using kube-prometheus-stack.
# ---------------------------------------------------------------------------
serviceMonitor:
enabled: false
# -- Scrape interval.
interval: "30s"
# -- Scrape path (Jellyfin does not expose Prometheus metrics natively).
path: /metrics
# -- Additional labels to add to the ServiceMonitor (e.g. to match a Prometheus release label).
additionalLabels: {}
# release: kube-prometheus-stack
# ---------------------------------------------------------------------------
# Runtime config (jellyfin.runtimeconfig.json)
# Set dotnet runtime switches here if needed. Leave empty for defaults.
# ---------------------------------------------------------------------------
runtimeConfig:
enabled: false
# -- Raw JSON content for jellyfin.runtimeconfig.json.
# See jellyfin-runtimeconfig ConfigMap in the existing manifests for an example.
json: |
{
"configProperties": {}
}
# ---------------------------------------------------------------------------
# Extra Kubernetes resources
# ---------------------------------------------------------------------------
# -- Additional volumes to attach to the Jellyfin pod.
extraVolumes: []
# - name: my-extra-config
# configMap:
# name: my-configmap
# -- Additional volume mounts for the Jellyfin container.
extraVolumeMounts: []
# - name: my-extra-config
# mountPath: /etc/my-config
# -- Additional init containers.
extraInitContainers: []
# -- Annotations to add to the StatefulSet.
annotations: {}
# -- Annotations to add to individual pods.
podAnnotations: {}
# -- Labels to add to the StatefulSet.
labels: {}
# -- Labels to add to individual pods.
podLabels: {}
# -- Update strategy for the StatefulSet.
updateStrategy:
type: RollingUpdate
+202
View File
@@ -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 | `10.12.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`.
+164
View File
@@ -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 `master`
2. Make your changes; ensure `dotnet build` and `dotnet test` pass locally
3. Fill out the PR template (`.github/pull_request_template.md`):
- **Changes**: 15 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` |
+221
View File
@@ -0,0 +1,221 @@
# Fork Diff: `ZoltyMat/jellyfin-ha` vs `jellyfin/jellyfin`
> **Generated:** 2026-03-14
> **Base:** `upstream/master` (`jellyfin/jellyfin`)
> **Head:** `origin/main` (`ZoltyMat/jellyfin-ha`)
> **Summary:** 40 commits ahead · 49 files changed · +9,879 / -93 lines
---
## What changed and why
This fork adds a **high-availability transcoding layer** on top of unmodified Jellyfin core. The design principle: extend via DI, touch as little upstream code as possible. No core business logic was rewritten.
Changes fall into five buckets:
| Bucket | Files | Lines added |
|--------|-------|-------------|
| New HA interfaces and models | 5 | ~260 |
| Redis session store implementation | 1 | ~270 |
| Modified upstream files (DI wiring + HA hooks) | 4 | ~300 |
| PostgreSQL database provider (experimental) | 7 | ~3,200 |
| Tests | 9 | ~2,000 |
| Tooling (DbMigrator, CI, Docker) | 12 | ~800 |
| Docs | 3 | ~1,100 |
---
## New files (net additions, no upstream equivalent)
### HA Session Store
#### `MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs` (+104)
New interface. The DI contract for durable transcode session tracking.
```csharp
public interface ITranscodeSessionStore
{
Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken ct);
Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken ct);
Task SetAsync(TranscodeSession session, CancellationToken ct);
Task RenewLeaseAsync(string playSessionId, CancellationToken ct);
Task DeleteAsync(string playSessionId, CancellationToken ct);
Task<IReadOnlyList<TranscodeSession>> GetAllAsync(CancellationToken ct);
}
```
#### `MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs` (+49)
The session record stored in Redis. Tracks ownership (`OwnerPod`), lease expiry, manifest path, segment path prefix, and the last durable segment index for resuming FFmpeg after failover.
#### `MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs` (+19)
Configuration model. Two fields: `RedisConnectionString` (null/empty = single-instance mode) and `LeaseDurationSeconds` (default 30).
#### `MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs` (+49)
No-op implementation. Registered when `RedisConnectionString` is not configured. Single-instance deployments get identical behavior to upstream.
#### `MediaBrowser.Controller/MediaEncoding/LiveStreamSession.cs` (+36)
Model for tracking live stream sessions alongside transcode sessions in the Redis store.
#### `Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs` (+270)
Redis-backed implementation of `ITranscodeSessionStore`. Key design points:
- Sessions stored as JSON under `jellyfin:transcode:{playSessionId}`
- Live stream sessions under `jellyfin:livestream:{sessionId}`
- Lease takeover is atomic via a Lua script (Redis single-threaded script execution guarantees no race between concurrent pods)
- TTL on the Redis key mirrors `LeaseExpiresUtc` — Redis GCs orphaned sessions automatically
```lua
-- Takeover script: atomically checks lease expiry and transfers ownership
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
local session = cjson.decode(raw)
local currentTicks = tonumber(ARGV[1])
if session['LeaseExpiresUtc'] > currentTicks then return 0 end
session['OwnerPod'] = ARGV[2]
-- ... update expiry and SET atomically
return 1
```
---
### PostgreSQL Provider (experimental)
#### `src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/` (+~3,200 lines)
A complete EF Core database provider for PostgreSQL, parallel to the existing SQLite provider:
- `PostgreSqlDatabaseProvider.cs` — implements `IDatabaseProvider`, configures Npgsql, handles migrations
- `PostgreSqlDesignTimeJellyfinDbFactory.cs` — EF Core design-time factory for `dotnet ef migrations`
- `Migrations/20260305010333_InitialPostgreSql.cs` — full initial schema migration (~1,146 lines)
- `Migrations/JellyfinDbContextModelSnapshot.cs` — EF Core model snapshot
Registered at startup when the PostgreSQL provider is selected. Falls back to SQLite by default — no behavioral change for existing deployments.
#### `tools/Jellyfin.DbMigrator/` (+~660 lines)
CLI tool to migrate an existing SQLite Jellyfin database to PostgreSQL:
- `Program.cs` — reads SQLite source, writes to PostgreSQL target
- `SqliteTableReader.cs` — reads all tables and rows from SQLite
- `PostgresBulkWriter.cs` — bulk-inserts via `NpgsqlBinaryImporter` (COPY protocol)
- `MigrationReport.cs` — structured migration result logging
- `TableNameValidator.cs` — validates table names against allowlist to prevent injection
---
## Modified upstream files
### `Jellyfin.Server/CoreAppHost.cs` (+31)
DI wiring. Reads `Jellyfin:TranscodeStore` config section and registers either `RedisTranscodeSessionStore` or `NullTranscodeSessionStore`:
```diff
+ serviceCollection.Configure<TranscodeStoreOptions>(
+ _startupConfig.GetSection("Jellyfin:TranscodeStore"));
+ var redisConnectionString =
+ _startupConfig["Jellyfin:TranscodeStore:RedisConnectionString"];
+ if (!string.IsNullOrEmpty(redisConnectionString))
+ {
+ serviceCollection.AddSingleton<IConnectionMultiplexer>(...);
+ serviceCollection.AddSingleton<ITranscodeSessionStore,
+ RedisTranscodeSessionStore>();
+ }
+ else
+ {
+ serviceCollection.AddSingleton<ITranscodeSessionStore,
+ NullTranscodeSessionStore>();
+ }
```
### `Emby.Server.Implementations/Tasks/DeleteTranscodeFileTask.cs` (+58 / -3)
Lease-aware cleanup. Before deleting transcode temp files, checks Redis for a valid (non-expired) lease. If the session is still active on another pod, cleanup is skipped for that session.
```diff
+ // HA guard: do not delete files belonging to a session with a valid lease
+ // on another pod. Only clean up sessions with no Redis entry or an expired lease.
+ var session = await _transcodeSessionStore
+ .TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false);
+ if (session is not null && session.LeaseExpiresUtc > DateTime.UtcNow)
+ {
+ continue;
+ }
```
### `Emby.Server.Implementations/Session/SessionManager.cs` (+62 / -3)
HA recovery hooks. `_activeLiveStreamSessions` is now checked against the Redis store during takeover — a pod that receives a request for a live stream it doesn't own locally can attempt `TryTakeoverAsync` before starting a new FFmpeg process.
### `Jellyfin.Api/Controllers/DynamicHlsController.cs` (+146 / -47)
HLS session registration. When a new HLS transcode starts, `SetAsync` is called to register the session in Redis. During segment requests, `RenewLeaseAsync` extends the lease. On stop/cleanup, `DeleteAsync` removes the session. The controller now injects `ITranscodeSessionStore` via constructor DI.
---
## CI and Docker
### `.github/workflows/ha-build.yml` (new, +90)
Build-and-push workflow for the fork image. Runs on push to `main`/`feat/ha-*`/`copilot/*`. Publishes with `dotnet publish` on the runner host (not inside Docker), then builds the runtime image and pushes to ECR.
### `.github/workflows/ci-tests.yml` (+87 / -5)
Extended with a `run-phase5-tests` parallel job targeting the three test assemblies most affected by HA changes: `Jellyfin.Api.Tests`, `Jellyfin.MediaEncoding.Hls.Tests`, and `Jellyfin.Server.Implementations.Tests`.
### `Dockerfile.runtime` (new, +56)
Runtime-only image. Expects a pre-built `publish-output/` directory (produced by `dotnet publish` on the CI host). Installs `jellyfin-web` from the official Jellyfin apt repo. Builds for `linux/amd64` only.
---
## Tests
| Test file | What it covers |
|-----------|----------------|
| `RedisTranscodeSessionStoreTests.cs` (+384) | Set, get, takeover, renew, delete, concurrent takeover races |
| `DeleteTranscodeFileTaskTests.cs` (+429) | Lease-aware cleanup: active lease skips deletion, expired lease allows deletion |
| `DynamicHlsHaTakeoverTests.cs` (+259) | Controller registers sessions, renews on segment requests, takeover path |
| `DynamicHlsSessionRegistrationTests.cs` (+223) | Session lifecycle: create, renew, delete through HLS controller |
| `TranscodeManagerTests.cs` (+167) | TranscodeManager calls store on begin/end transcode |
| `PostgreSqlProviderTests.cs` (+336) | PostgreSQL provider DI, migration, CRUD roundtrip |
| `PostgreSqlConcurrencyTests.cs` (+126) | Concurrent writes under PostgreSQL |
| `PostgreSqlMigrationTests.cs` (+99) | Migration from SQLite via DbMigrator tool |
| `InMemoryTranscodeSessionStore.cs` (+169) | Test fake used across all HA unit tests |
---
## What was NOT changed
- No core media scanning or library logic
- No changes to the Jellyfin data model or existing EF Core SQLite migrations
- No changes to the Jellyfin plugin system
- No changes to the authentication or user management stack
- No changes to the Jellyfin web client (separate repo)
- No changes to subtitle, image, or metadata providers
The HA layer is fully additive. Removing it would require deleting the new files and the ~30-line DI block in `CoreAppHost.cs`.
---
## Diff commands
```bash
# Add the upstream remote
git remote add upstream https://github.com/jellyfin/jellyfin.git
git fetch upstream master
# Full file-level summary
git diff upstream/master...HEAD --stat
# New files only
git diff upstream/master...HEAD --name-only --diff-filter=A
# Full patch (large — ~10k lines)
git diff upstream/master...HEAD > fork.patch
```
+83
View File
@@ -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.
+473
View File
@@ -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 20120s 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 (24 MB for a 24s 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 (210) 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.
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\..\SharedVersion.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
<ProjectReference Include="..\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Jellyfin.Database.Providers.PostgreSQL;
/// <summary>
/// Configures Jellyfin to use a PostgreSQL database.
/// </summary>
[JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")]
public sealed class PostgreSqlDatabaseProvider : IJellyfinDatabaseProvider
{
// Sentinel returned by MigrationBackupFast to signal that no file backup was
// created (PostgreSQL backups are handled externally by jellyfin-pg-backup CronJob).
private const string NoAutomatedBackupKey = "postgresql-no-automated-backup";
private readonly NpgsqlDataSource _dataSource;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlDatabaseProvider"/> class.
/// </summary>
/// <param name="dataSource">The <see cref="NpgsqlDataSource"/> used for PostgreSQL connections.</param>
public PostgreSqlDatabaseProvider(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
/// <inheritdoc/>
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
/// <inheritdoc/>
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
{
options.UseNpgsql(
_dataSource,
o => o.MigrationsAssembly(GetType().Assembly.FullName));
}
/// <inheritdoc/>
public void OnModelCreating(ModelBuilder modelBuilder)
{
}
/// <inheritdoc/>
public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
}
/// <inheritdoc/>
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public Task RunShutdownTask(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<string> MigrationBackupFast(CancellationToken cancellationToken)
{
// PostgreSQL pre-migration backups are handled externally by the
// jellyfin-pg-backup CronJob. Return a sentinel so callers know no
// file backup was created and the migration can proceed safely.
return Task.FromResult(NoAutomatedBackupKey);
}
/// <inheritdoc/>
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
{
// No automated backup was taken; nothing to restore.
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task DeleteBackup(string key)
{
// No automated backup was taken; nothing to delete.
return Task.CompletedTask;
}
/// <inheritdoc/>
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
{
ArgumentNullException.ThrowIfNull(tableNames);
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'replica'").ConfigureAwait(false);
try
{
foreach (var tableName in tableNames)
{
var truncateSql = "TRUNCATE TABLE \"" + tableName + "\" CASCADE";
await dbContext.Database.ExecuteSqlRawAsync(truncateSql).ConfigureAwait(false);
}
}
finally
{
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'origin'").ConfigureAwait(false);
}
}
}
@@ -0,0 +1,42 @@
using System;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
namespace Jellyfin.Database.Providers.PostgreSQL;
/// <summary>
/// The design time factory for <see cref="JellyfinDbContext"/> using PostgreSQL.
/// This is only used for the creation of migrations and not during runtime.
/// </summary>
internal sealed class PostgreSqlDesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDbContext>
{
/// <inheritdoc/>
public JellyfinDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
// Build a NpgsqlDataSource for EF Core configuration. The DI-owned singleton data source
// is not available in design-time context; this instance is intentionally not disposed here
// because EF Core holds a reference to it for the lifetime of the returned context.
// As a design-time-only factory (used only for dotnet-ef CLI operations), the process
// exits after the migration is applied, which releases all resources.
#pragma warning disable CA2000 // Dispose objects before losing scope
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
#pragma warning restore CA2000 // Dispose objects before losing scope
optionsBuilder.UseNpgsql(dataSource, o => o.MigrationsAssembly(GetType().Assembly));
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
new PostgreSqlDatabaseProvider(dataSource),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -15,7 +15,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId>
<VersionPrefix>10.11.8</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -0,0 +1,259 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Controllers;
using MediaBrowser.Controller.MediaEncoding;
using Xunit;
namespace Jellyfin.Api.Tests.Controllers
{
/// <summary>
/// Tests for HA recovery scenarios that will be wired into <see cref="DynamicHlsController"/>
/// in Phase 5.2. These tests verify the <see cref="ITranscodeSessionStore"/> contract that
/// the controller will rely on for missing-local-job recovery, claim racing, and cleanup guarding.
/// </summary>
public class DynamicHlsHaTakeoverTests
{
private static TranscodeSession CreateSession(string id, string pod, DateTime leaseExpiry)
=> new TranscodeSession
{
PlaySessionId = id,
OwnerPod = pod,
LeaseExpiresUtc = leaseExpiry,
ManifestPath = $"/transcode/{id}/manifest.m3u8",
SegmentPathPrefix = $"/transcode/{id}/segment",
MediaSourceId = "media-source-1",
LastCompletedSegmentIndex = 3,
LastDurablePlaybackOffset = 18_000_000L,
};
/// <summary>
/// Missing-local-job + durable-manifest-present: the store returns the session so
/// the controller can serve the existing manifest instead of returning an error.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task DurableManifestPresent_WithLiveSession_StoreReturnsSession()
{
var store = new HaTestSessionStore();
var session = CreateSession("ha-session-1", "pod-a", DateTime.UtcNow.AddMinutes(5));
await store.SetAsync(session);
// Simulate controller recovery: look up the session in the durable store.
var recovered = await store.TryGetAsync("ha-session-1");
Assert.NotNull(recovered);
Assert.Equal("/transcode/ha-session-1/manifest.m3u8", recovered.ManifestPath);
}
/// <summary>
/// Claim-race between two concurrent requesters: only one wins
/// <see cref="ITranscodeSessionStore.TryTakeoverAsync"/>.
/// The other receives <c>false</c>, indicating it should redirect (302) or wait.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task ClaimRace_TwoConcurrentRequesters_OnlyOneWinsTakeover()
{
var store = new HaTestSessionStore();
// Original pod crashed lease is expired.
var session = CreateSession("ha-session-2", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
await store.SetAsync(session);
// Two pods simultaneously attempt to claim the orphaned session.
var task1 = store.TryTakeoverAsync("ha-session-2", "pod-b");
var task2 = store.TryTakeoverAsync("ha-session-2", "pod-c");
var results = await Task.WhenAll(task1, task2);
// Exactly one pod must win.
var wins = Array.FindAll(results, r => r);
Assert.Single(wins);
}
/// <summary>
/// Stale-manifest cleanup guard: a lease that has expired beyond the recovery window
/// causes the store to return <c>null</c>, signalling that cleanup may proceed safely.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task StaleManifestCleanupGuard_ExpiredBeyondRecoveryWindow_StoreReturnsNull()
{
var store = new HaTestSessionStore();
// Lease expired hours ago well beyond any recovery window.
var session = CreateSession("ha-session-3", "pod-a", DateTime.UtcNow.AddHours(-2));
await store.SetAsync(session);
// Controller or cleanup task checks the store before deleting files.
var liveSession = await store.TryGetAsync("ha-session-3");
// Store returns null → cleanup may proceed without risking data loss.
Assert.Null(liveSession);
}
/// <summary>
/// Segment-length selection: when the play-session has an active entry in the store
/// (HA mode is active), the recovery segment length should be preferred over the normal one.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task SegmentLength_UsesRecoveryValue_WhenHaModeIsActive()
{
const int normalSegmentLength = 6;
const int recoverySegmentLength = 2;
var store = new HaTestSessionStore();
var session = CreateSession("ha-session-4", "pod-a", DateTime.UtcNow.AddMinutes(5));
await store.SetAsync(session);
// Simulate the controller's HA-mode check: if the session is in the store, HA mode is active.
var existingSession = await store.TryGetAsync("ha-session-4");
var isHaMode = existingSession is not null;
var effectiveSegmentLength = isHaMode ? recoverySegmentLength : normalSegmentLength;
Assert.True(isHaMode, "Session should be found in the store, activating HA mode.");
Assert.Equal(recoverySegmentLength, effectiveSegmentLength);
}
/// <summary>
/// Segment-length selection: when no entry exists in the store for the play-session
/// (HA mode inactive), the normal segment length should be used.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task SegmentLength_UsesNormalValue_WhenHaModeIsInactive()
{
const int normalSegmentLength = 6;
const int recoverySegmentLength = 2;
var store = new HaTestSessionStore();
// No session registered HA mode is inactive.
var existingSession = await store.TryGetAsync("nonexistent-session");
var isHaMode = existingSession is not null;
var effectiveSegmentLength = isHaMode ? recoverySegmentLength : normalSegmentLength;
Assert.False(isHaMode, "No session in the store means HA mode should be inactive.");
Assert.Equal(normalSegmentLength, effectiveSegmentLength);
}
/// <summary>
/// Minimal in-memory <see cref="ITranscodeSessionStore"/> used within this test class
/// to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests.
/// </summary>
private sealed class HaTestSessionStore : ITranscodeSessionStore
{
private static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(30);
private readonly Dictionary<string, TranscodeSession> _sessions =
new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var s) && s.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult<TranscodeSession?>(Clone(s));
}
return Task.FromResult<TranscodeSession?>(null);
}
}
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sessions.TryGetValue(playSessionId, out var s))
{
return Task.FromResult(false);
}
if (s.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult(false);
}
s.OwnerPod = claimingPod;
s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration);
return Task.FromResult(true);
}
}
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions[session.PlaySessionId] = session;
}
return Task.CompletedTask;
}
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var s))
{
s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration);
}
}
return Task.CompletedTask;
}
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions.Remove(playSessionId);
}
return Task.CompletedTask;
}
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
var sessions = _sessions.Values
.Where(s => s.LeaseExpiresUtc > DateTime.UtcNow)
.Select(Clone)
.ToList();
return Task.FromResult<IEnumerable<TranscodeSession>>(sessions);
}
}
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<LiveStreamSession?>(null);
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
PlaySessionId = source.PlaySessionId,
OwnerPod = source.OwnerPod,
LeaseExpiresUtc = source.LeaseExpiresUtc,
ManifestPath = source.ManifestPath,
SegmentPathPrefix = source.SegmentPathPrefix,
MediaSourceId = source.MediaSourceId,
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
};
}
}
}
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Xunit;
namespace Jellyfin.Api.Tests.Controllers
{
/// <summary>
/// Tests for HLS session registration, lease renewal, and cleanup behaviour wired into
/// <see cref="Jellyfin.Api.Controllers.DynamicHlsController"/> in Phase 5.2.2a.
/// These tests verify the <see cref="ITranscodeSessionStore"/> contract used by the controller.
/// </summary>
public class DynamicHlsSessionRegistrationTests
{
private static TranscodeSession CreateSession(string id, string pod)
=> new TranscodeSession
{
PlaySessionId = id,
OwnerPod = pod,
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30),
ManifestPath = string.Empty,
SegmentPathPrefix = string.Empty,
MediaSourceId = "media-source-1",
LastCompletedSegmentIndex = 0,
LastDurablePlaybackOffset = 0L,
};
/// <summary>
/// After registering a session via <see cref="ITranscodeSessionStore.SetAsync"/>,
/// <see cref="ITranscodeSessionStore.TryGetAsync"/> must return a non-null result with
/// matching <see cref="TranscodeSession.PlaySessionId"/> and <see cref="TranscodeSession.OwnerPod"/>.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task SessionRegistration_AfterStreamStart_StoreContainsSession()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-reg-1", "pod-a");
await store.SetAsync(session);
var retrieved = await store.TryGetAsync("session-reg-1");
Assert.NotNull(retrieved);
Assert.Equal("session-reg-1", retrieved.PlaySessionId);
Assert.Equal("pod-a", retrieved.OwnerPod);
}
/// <summary>
/// After calling <see cref="ITranscodeSessionStore.DeleteAsync"/>,
/// <see cref="ITranscodeSessionStore.TryGetAsync"/> must return <c>null</c>.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task SessionCleanup_AfterStreamEnd_StoreReturnsNull()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-cleanup-1", "pod-b");
await store.SetAsync(session);
await store.DeleteAsync("session-cleanup-1");
var retrieved = await store.TryGetAsync("session-cleanup-1");
Assert.Null(retrieved);
}
/// <summary>
/// After a session's initial lease window would have expired, calling
/// <see cref="ITranscodeSessionStore.RenewLeaseAsync"/> must extend the lease so that
/// <see cref="ITranscodeSessionStore.TryGetAsync"/> still returns the session as active.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task LeaseRenewal_ExtendsBeyondInitialExpiry()
{
var store = new InMemoryTranscodeSessionStore();
// Create the session with a lease that has already expired.
var session = new TranscodeSession
{
PlaySessionId = "session-renewal-1",
OwnerPod = "pod-c",
LeaseExpiresUtc = DateTime.UtcNow.AddMilliseconds(-1),
ManifestPath = string.Empty,
SegmentPathPrefix = string.Empty,
MediaSourceId = "media-source-1",
LastCompletedSegmentIndex = 0,
LastDurablePlaybackOffset = 0L,
};
await store.SetAsync(session);
// Verify the session is not accessible because the lease has expired.
Assert.Null(await store.TryGetAsync("session-renewal-1"));
// Renew the lease.
await store.RenewLeaseAsync("session-renewal-1");
// After renewal the session must be accessible again.
var renewed = await store.TryGetAsync("session-renewal-1");
Assert.NotNull(renewed);
Assert.Equal("session-renewal-1", renewed.PlaySessionId);
Assert.True(renewed.LeaseExpiresUtc > DateTime.UtcNow);
}
/// <summary>
/// Minimal thread-safe in-memory implementation of <see cref="ITranscodeSessionStore"/>
/// used within this test class to avoid a cross-project reference.
/// </summary>
private sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore
{
private static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30);
private readonly Dictionary<string, TranscodeSession> _sessions =
new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var session) && session.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult<TranscodeSession?>(Clone(session));
}
return Task.FromResult<TranscodeSession?>(null);
}
}
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sessions.TryGetValue(playSessionId, out var session))
{
return Task.FromResult(false);
}
if (session.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult(false);
}
session.OwnerPod = claimingPod;
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
return Task.FromResult(true);
}
}
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions[session.PlaySessionId] = session;
}
return Task.CompletedTask;
}
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var session))
{
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
}
}
return Task.CompletedTask;
}
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions.Remove(playSessionId);
}
return Task.CompletedTask;
}
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
var sessions = _sessions.Values
.Where(s => s.LeaseExpiresUtc > DateTime.UtcNow)
.Select(Clone)
.ToList();
return Task.FromResult<IEnumerable<TranscodeSession>>(sessions);
}
}
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<LiveStreamSession?>(null);
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
PlaySessionId = source.PlaySessionId,
OwnerPod = source.OwnerPod,
LeaseExpiresUtc = source.LeaseExpiresUtc,
ManifestPath = source.ManifestPath,
SegmentPathPrefix = source.SegmentPathPrefix,
MediaSourceId = source.MediaSourceId,
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
};
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private NpgsqlDataSource? _dataSource;
private PostgreSqlDatabaseProvider? _provider;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlConcurrencyTests"/> class.
/// </summary>
public PostgreSqlConcurrencyTests()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
_provider = new PostgreSqlDatabaseProvider(_dataSource);
// Apply migrations once for the whole test class.
var context = CreateContext();
await using (context.ConfigureAwait(false))
{
await context.Database.MigrateAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task DisposeAsync()
{
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Verifies that concurrent inserts on <see cref="ActivityLog"/> from four parallel tasks succeed without deadlock.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ConcurrentInserts_ActivityLogs_SucceedWithoutDeadlock()
{
const int parallelTasks = 4;
const int insertsPerTask = 10;
var tasks = new List<Task>(parallelTasks);
for (var i = 0; i < parallelTasks; i++)
{
var taskIndex = i;
tasks.Add(Task.Run(async () =>
{
var ctx = CreateContext();
await using (ctx.ConfigureAwait(false))
{
for (var j = 0; j < insertsPerTask; j++)
{
ctx.ActivityLogs.Add(new ActivityLog(
$"Task {taskIndex} Insert {j}",
"ConcurrencyTest",
Guid.Empty));
}
await ctx.SaveChangesAsync().ConfigureAwait(false);
}
}));
}
await Task.WhenAll(tasks);
// Verify all rows were inserted
var verifyCtx = CreateContext();
await using (verifyCtx)
{
var count = await verifyCtx.ActivityLogs
.CountAsync(l => l.Type == "ConcurrencyTest");
Assert.Equal(parallelTasks * insertsPerTask, count);
}
}
private JellyfinDbContext CreateContext()
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
_provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,99 @@
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests that validate PostgreSQL migrations against a real container.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlMigrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlMigrationTests"/> class.
/// </summary>
public PostgreSqlMigrationTests()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container before any tests in the class run.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task DisposeAsync()
{
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh PostgreSQL 16 container.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task MigrateAsync_AppliesInitialMigrationCleanly()
{
await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
var context = CreateContext(dataSource);
await using (context)
{
await context.Database.MigrateAsync();
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
Assert.Empty(pendingMigrations);
}
}
/// <summary>
/// Verifies that no pending model changes exist for the PostgreSQL provider,
/// acting as a CI gate that fails when model changes are added without a corresponding migration.
/// </summary>
[Fact]
public void CheckForUnappliedMigrations_PostgreSql()
{
// Use a dummy connection string; HasPendingModelChanges() is a purely in-memory check
// that compares the current compiled model with the migration snapshots — no real DB needed.
const string dummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
using var dataSource = new NpgsqlDataSourceBuilder(dummyConnectionString).Build();
using var context = CreateContext(dataSource);
Assert.False(
context.Database.HasPendingModelChanges(),
"There are unapplied changes to the EFCore model for PostgreSQL. Please create a Migration.");
}
private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,336 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL 16 container.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlProviderTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private NpgsqlDataSource? _dataSource;
private PostgreSqlDatabaseProvider? _provider;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlProviderTests"/> class.
/// </summary>
public PostgreSqlProviderTests()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
_provider = new PostgreSqlDatabaseProvider(_dataSource);
// Apply migrations once for the whole test class.
var context = CreateContext();
await using (context.ConfigureAwait(false))
{
await context.Database.MigrateAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task DisposeAsync()
{
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="User"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_User()
{
var ctx = CreateContext();
await using (ctx)
{
// Create
var user = new User("testuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider");
ctx.Users.Add(user);
await ctx.SaveChangesAsync();
var userId = user.Id;
// Read
var read = await ctx.Users.FindAsync(userId);
Assert.NotNull(read);
Assert.Equal("testuser", read.Username);
// Update
read.Username = "updateduser";
await ctx.SaveChangesAsync();
var updated = await ctx.Users.FindAsync(userId);
Assert.Equal("updateduser", updated!.Username);
// Delete
ctx.Users.Remove(updated);
await ctx.SaveChangesAsync();
var deleted = await ctx.Users.FindAsync(userId);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="ActivityLog"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_ActivityLog()
{
var ctx = CreateContext();
await using (ctx)
{
// Create
var log = new ActivityLog("Test activity", "TestType", Guid.Empty);
ctx.ActivityLogs.Add(log);
await ctx.SaveChangesAsync();
var logId = log.Id;
// Read
var read = await ctx.ActivityLogs.FindAsync(logId);
Assert.NotNull(read);
Assert.Equal("Test activity", read.Name);
// Update
read.Overview = "Updated overview";
await ctx.SaveChangesAsync();
var updated = await ctx.ActivityLogs.FindAsync(logId);
Assert.Equal("Updated overview", updated!.Overview);
// Delete
ctx.ActivityLogs.Remove(updated);
await ctx.SaveChangesAsync();
var deleted = await ctx.ActivityLogs.FindAsync(logId);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="DisplayPreferences"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_DisplayPreferences()
{
var ctx = CreateContext();
await using (ctx)
{
var userId = Guid.NewGuid();
var itemId = Guid.NewGuid();
// Create
var prefs = new DisplayPreferences(userId, itemId, "TestClient");
ctx.DisplayPreferences.Add(prefs);
await ctx.SaveChangesAsync();
var prefsId = prefs.Id;
// Read
var read = await ctx.DisplayPreferences.FindAsync(prefsId);
Assert.NotNull(read);
Assert.Equal("TestClient", read.Client);
// Update
read.ShowSidebar = true;
await ctx.SaveChangesAsync();
var updated = await ctx.DisplayPreferences.FindAsync(prefsId);
Assert.True(updated!.ShowSidebar);
// Delete
ctx.DisplayPreferences.Remove(updated);
await ctx.SaveChangesAsync();
var deleted = await ctx.DisplayPreferences.FindAsync(prefsId);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="BaseItemEntity"/>, <see cref="Chapter"/>, and <see cref="MediaStreamInfo"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_BaseItem_Chapter_MediaStream()
{
var ctx = CreateContext();
await using (ctx)
{
var itemId = Guid.NewGuid();
// Create BaseItem
var item = new BaseItemEntity { Id = itemId, Type = "Movie", Name = "Test Movie" };
ctx.BaseItems.Add(item);
await ctx.SaveChangesAsync();
// Create Chapter linked to BaseItem
var chapter = new Chapter { ItemId = itemId, Item = item, ChapterIndex = 0, StartPositionTicks = 0, Name = "Intro" };
ctx.Chapters.Add(chapter);
// Create MediaStreamInfo linked to BaseItem
var stream = new MediaStreamInfo { ItemId = itemId, Item = item, StreamIndex = 0, StreamType = MediaStreamTypeEntity.Video };
ctx.MediaStreamInfos.Add(stream);
await ctx.SaveChangesAsync();
// Read
var readItem = await ctx.BaseItems
.Include(i => i.Chapters)
.Include(i => i.MediaStreams)
.FirstOrDefaultAsync(i => i.Id.Equals(itemId));
Assert.NotNull(readItem);
Assert.Equal("Test Movie", readItem.Name);
Assert.Single(readItem.Chapters!);
Assert.Single(readItem.MediaStreams!);
// Update
readItem.Name = "Updated Movie";
await ctx.SaveChangesAsync();
var updated = await ctx.BaseItems.FindAsync(itemId);
Assert.Equal("Updated Movie", updated!.Name);
// Delete (cascades to Chapter and MediaStreamInfo)
ctx.BaseItems.Remove(updated);
await ctx.SaveChangesAsync();
var deleted = await ctx.BaseItems.FindAsync(itemId);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies that <see cref="PostgreSqlDatabaseProvider.RunScheduledOptimisation"/> executes ANALYZE without error.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RunScheduledOptimisation_ExecutesWithoutError()
{
var ctx = CreateContext();
await using (ctx)
{
var factory = new TestDbContextFactory(ctx);
_provider!.DbContextFactory = factory;
await _provider.RunScheduledOptimisation(CancellationToken.None);
}
}
/// <summary>
/// Verifies that <see cref="PostgreSqlDatabaseProvider.PurgeDatabase"/> empties tables and resets <c>session_replication_role</c>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task PurgeDatabase_EmptiesTablesAndResetsFkRole()
{
var ctx = CreateContext();
await using (ctx)
{
// Seed a row
ctx.ActivityLogs.Add(new ActivityLog("Purge test", "TestType", Guid.Empty));
await ctx.SaveChangesAsync();
Assert.True(await ctx.ActivityLogs.AnyAsync());
// Purge
await _provider!.PurgeDatabase(ctx, ["ActivityLogs"]);
// session_replication_role should be reset to 'origin' (default)
var role = await ctx.Database
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role')")
.FirstAsync();
Assert.Equal("origin", role);
}
// Verify table is empty via a fresh context
var freshCtx = CreateContext();
await using (freshCtx)
{
Assert.False(await freshCtx.ActivityLogs.AnyAsync());
}
}
private JellyfinDbContext CreateContext()
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
_provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
/// <summary>
/// A minimal <see cref="IDbContextFactory{TContext}"/> wrapper that returns a pre-existing context.
/// </summary>
private sealed class TestDbContextFactory : IDbContextFactory<JellyfinDbContext>
{
private readonly JellyfinDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="TestDbContextFactory"/> class.
/// </summary>
/// <param name="context">The context to return from <see cref="CreateDbContext"/>.</param>
public TestDbContextFactory(JellyfinDbContext context)
{
_context = context;
}
/// <summary>
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance.
/// </summary>
/// <returns>The pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
public JellyfinDbContext CreateDbContext() => _context;
/// <summary>
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance as a completed task.
/// </summary>
/// <param name="cancellationToken">A cancellation token (unused).</param>
/// <returns>A <see cref="Task{TResult}"/> containing the pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
public Task<JellyfinDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_context);
}
}
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
namespace Jellyfin.MediaEncoding.Tests.Fakes;
/// <summary>
/// Thread-safe, in-memory implementation of <see cref="ITranscodeSessionStore"/> for use in unit tests.
/// </summary>
public sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore
{
/// <summary>
/// The duration added to <see cref="DateTime.UtcNow"/> when a lease is renewed or first claimed.
/// </summary>
public static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30);
private readonly Dictionary<string, TranscodeSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LiveStreamSession> _liveStreams = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
/// <inheritdoc />
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var session) && session.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult<TranscodeSession?>(Clone(session));
}
return Task.FromResult<TranscodeSession?>(null);
}
}
/// <inheritdoc />
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sessions.TryGetValue(playSessionId, out var session))
{
return Task.FromResult(false);
}
if (session.LeaseExpiresUtc > DateTime.UtcNow)
{
// Another pod's lease is still valid takeover not permitted.
return Task.FromResult(false);
}
// Lease has expired claim it atomically.
session.OwnerPod = claimingPod;
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
return Task.FromResult(true);
}
}
/// <inheritdoc />
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions[session.PlaySessionId] = session;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var session))
{
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions.Remove(playSessionId);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
var sessions = _sessions.Values.Select(Clone).ToList();
return Task.FromResult<IEnumerable<TranscodeSession>>(sessions);
}
}
/// <inheritdoc />
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.SessionId)] = session;
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
_liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.PlaySessionId)] = session;
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session);
return Task.FromResult(session);
}
}
/// <inheritdoc />
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session))
{
_liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.SessionId));
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
_liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.PlaySessionId));
}
}
else
{
_liveStreams.Remove(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId));
}
}
return Task.CompletedTask;
}
private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
PlaySessionId = source.PlaySessionId,
OwnerPod = source.OwnerPod,
LeaseExpiresUtc = source.LeaseExpiresUtc,
ManifestPath = source.ManifestPath,
SegmentPathPrefix = source.SegmentPathPrefix,
MediaSourceId = source.MediaSourceId,
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
};
}
@@ -0,0 +1,167 @@
using System;
using System.Threading.Tasks;
using Jellyfin.MediaEncoding.Tests.Fakes;
using MediaBrowser.Controller.MediaEncoding;
using Xunit;
namespace Jellyfin.MediaEncoding.Tests.Transcoding;
/// <summary>
/// Unit tests for the HA session-store contract: lease expiry, double-claim prevention,
/// heartbeat renewal, and stale-session cleanup.
/// All tests exercise <see cref="InMemoryTranscodeSessionStore"/> which implements the
/// <see cref="ITranscodeSessionStore"/> interface that will be backed by Redis in Phase 5.2.
/// </summary>
public class TranscodeManagerTests
{
private static TranscodeSession CreateSession(
string id,
string pod,
DateTime leaseExpiry,
int lastSegmentIndex = 0,
long lastOffset = 0L)
=> new TranscodeSession
{
PlaySessionId = id,
OwnerPod = pod,
LeaseExpiresUtc = leaseExpiry,
ManifestPath = $"/transcode/{id}/manifest.m3u8",
SegmentPathPrefix = $"/transcode/{id}/segment",
MediaSourceId = $"media-source-{id}",
LastCompletedSegmentIndex = lastSegmentIndex,
LastDurablePlaybackOffset = lastOffset,
};
/// <summary>
/// Lease expiry: <see cref="ITranscodeSessionStore.TryGetAsync"/> returns <c>null</c>
/// once <see cref="TranscodeSession.LeaseExpiresUtc"/> has passed.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryGetAsync_AfterLeaseExpires_ReturnsNull()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-expired", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
await store.SetAsync(session);
var result = await store.TryGetAsync("session-expired");
Assert.Null(result);
}
/// <summary>
/// A session whose lease has not yet expired is returned correctly.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryGetAsync_WithinLease_ReturnsSession()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-live", "pod-a", DateTime.UtcNow.AddMinutes(5), lastSegmentIndex: 3, lastOffset: 18_000_000L);
await store.SetAsync(session);
var result = await store.TryGetAsync("session-live");
Assert.NotNull(result);
Assert.Equal("session-live", result.PlaySessionId);
Assert.Equal("pod-a", result.OwnerPod);
Assert.Equal(3, result.LastCompletedSegmentIndex);
Assert.Equal(18_000_000L, result.LastDurablePlaybackOffset);
}
/// <summary>
/// Double-claim prevention: <see cref="ITranscodeSessionStore.TryTakeoverAsync"/> returns
/// <c>false</c> while the first pod's lease is still valid.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-valid", "pod-a", DateTime.UtcNow.AddMinutes(5));
await store.SetAsync(session);
var firstAttempt = await store.TryTakeoverAsync("session-valid", "pod-b");
var secondAttempt = await store.TryTakeoverAsync("session-valid", "pod-c");
Assert.False(firstAttempt);
Assert.False(secondAttempt);
}
/// <summary>
/// After a lease expires, the first concurrent caller that invokes
/// <see cref="ITranscodeSessionStore.TryTakeoverAsync"/> wins; the second caller returns <c>false</c>.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryTakeoverAsync_AfterLeaseExpires_OnlyFirstClaimerSucceeds()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-stale", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
await store.SetAsync(session);
// First pod wins; its takeover renews the lease atomically.
var firstTakeover = await store.TryTakeoverAsync("session-stale", "pod-b");
// Second pod is too late pod-b already holds a fresh lease.
var secondTakeover = await store.TryTakeoverAsync("session-stale", "pod-c");
Assert.True(firstTakeover);
Assert.False(secondTakeover);
}
/// <summary>
/// Heartbeat renewal: <see cref="ITranscodeSessionStore.RenewLeaseAsync"/> extends
/// <see cref="TranscodeSession.LeaseExpiresUtc"/> beyond its original value.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task RenewLeaseAsync_ExtendsLeaseExpiry()
{
var store = new InMemoryTranscodeSessionStore();
var originalExpiry = DateTime.UtcNow.AddSeconds(5);
var session = CreateSession("session-renew", "pod-a", originalExpiry, lastSegmentIndex: 2, lastOffset: 10_000_000L);
await store.SetAsync(session);
await store.RenewLeaseAsync("session-renew");
var renewed = await store.TryGetAsync("session-renew");
Assert.NotNull(renewed);
Assert.True(
renewed.LeaseExpiresUtc > originalExpiry,
"Renewed lease expiry should be later than the original expiry.");
}
/// <summary>
/// Stale-session cleanup: an expired session can be deleted without error, and a
/// subsequent <see cref="ITranscodeSessionStore.TryGetAsync"/> returns <c>null</c>.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task DeleteAsync_ExpiredSession_CompletesWithoutError()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-delete", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
await store.SetAsync(session);
var ex = await Record.ExceptionAsync(() => store.DeleteAsync("session-delete"));
Assert.Null(ex);
var result = await store.TryGetAsync("session-delete");
Assert.Null(result);
}
/// <summary>
/// Deleting a session that was never stored must complete without error.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task DeleteAsync_NonExistentSession_CompletesWithoutError()
{
var store = new InMemoryTranscodeSessionStore();
var ex = await Record.ExceptionAsync(() => store.DeleteAsync("nonexistent-session"));
Assert.Null(ex);
}
}
@@ -0,0 +1,384 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.MediaEncoding;
/// <summary>
/// Tests for transcode session store contract behavior, using <see cref="InMemoryTranscodeSessionStore"/>
/// as a reference implementation (no real Redis required).
/// </summary>
public class RedisTranscodeSessionStoreTests
{
/// <summary>
/// Verifies that <see cref="ITranscodeSessionStore.TryGetAsync"/> returns <c>null</c> after
/// a session's lease has expired.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryGetAsync_AfterLeaseExpires_ReturnsNull()
{
var store = new InMemoryTranscodeSessionStore();
var session = new TranscodeSession
{
PlaySessionId = "session-1",
OwnerPod = "pod-a",
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(-1),
};
await store.SetAsync(session);
var result = await store.TryGetAsync("session-1");
Assert.Null(result);
}
/// <summary>
/// Verifies that <see cref="ITranscodeSessionStore.TryTakeoverAsync"/> returns <c>false</c>
/// when the session's lease is still valid.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse()
{
var store = new InMemoryTranscodeSessionStore();
var session = new TranscodeSession
{
PlaySessionId = "session-2",
OwnerPod = "pod-a",
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30),
};
await store.SetAsync(session);
var result = await store.TryTakeoverAsync("session-2", "pod-b");
Assert.False(result);
}
/// <summary>
/// Verifies that <see cref="ITranscodeSessionStore.TryTakeoverAsync"/> returns <c>true</c>
/// and updates the owner when the session's lease has expired.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryTakeoverAsync_AfterLeaseExpires_ReturnsTrue_AndUpdatesOwner()
{
var store = new InMemoryTranscodeSessionStore();
var session = new TranscodeSession
{
PlaySessionId = "session-3",
OwnerPod = "pod-a",
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(-1),
};
await store.SetAsync(session);
var result = await store.TryTakeoverAsync("session-3", "pod-b");
Assert.True(result);
var updated = await store.TryGetAsync("session-3");
Assert.NotNull(updated);
Assert.Equal("pod-b", updated.OwnerPod);
Assert.True(updated.LeaseExpiresUtc > DateTime.UtcNow);
}
/// <summary>
/// Verifies that when multiple pods concurrently attempt to take over an expired session,
/// exactly one succeeds.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task ConcurrentTryTakeover_OnlyOneWins()
{
var store = new InMemoryTranscodeSessionStore();
var session = new TranscodeSession
{
PlaySessionId = "session-4",
OwnerPod = "pod-a",
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(-1),
};
await store.SetAsync(session);
const int concurrency = 10;
var tasks = new Task<bool>[concurrency];
for (int i = 0; i < concurrency; i++)
{
var podName = $"pod-{i}";
tasks[i] = store.TryTakeoverAsync("session-4", podName);
}
var results = await Task.WhenAll(tasks);
var successCount = 0;
foreach (var r in results)
{
if (r)
{
successCount++;
}
}
Assert.Equal(1, successCount);
}
/// <summary>
/// Verifies that <see cref="ITranscodeSessionStore.SetLiveStreamAsync"/> stores a live stream
/// record that can be retrieved by session id.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task SetLiveStreamAsync_CanBeRetrievedBySessionId()
{
var store = new InMemoryTranscodeSessionStore();
var liveStream = new LiveStreamSession
{
LiveStreamId = "stream-1",
SessionId = "session-a",
PlaySessionId = "play-session-a",
OwnerPod = "pod-a",
OpenedAtUtc = DateTime.UtcNow,
};
await store.SetLiveStreamAsync(liveStream);
var result = await store.TryGetLiveStreamAsync("stream-1", "session-a");
Assert.NotNull(result);
Assert.Equal("session-a", result.SessionId);
Assert.Equal("pod-a", result.OwnerPod);
}
/// <summary>
/// Verifies that <see cref="ITranscodeSessionStore.SetLiveStreamAsync"/> stores a live stream
/// record that can be retrieved by play session id.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task SetLiveStreamAsync_CanBeRetrievedByPlaySessionId()
{
var store = new InMemoryTranscodeSessionStore();
var liveStream = new LiveStreamSession
{
LiveStreamId = "stream-2",
SessionId = "session-b",
PlaySessionId = "play-session-b",
OwnerPod = "pod-a",
OpenedAtUtc = DateTime.UtcNow,
};
await store.SetLiveStreamAsync(liveStream);
var result = await store.TryGetLiveStreamAsync("stream-2", "play-session-b");
Assert.NotNull(result);
Assert.Equal("session-b", result.SessionId);
}
/// <summary>
/// Verifies that <see cref="ITranscodeSessionStore.DeleteLiveStreamAsync"/> removes the live
/// stream record so that subsequent lookups by either session id or play session id return null.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task DeleteLiveStreamAsync_RemovesBothKeys()
{
var store = new InMemoryTranscodeSessionStore();
var liveStream = new LiveStreamSession
{
LiveStreamId = "stream-3",
SessionId = "session-c",
PlaySessionId = "play-session-c",
OwnerPod = "pod-a",
OpenedAtUtc = DateTime.UtcNow,
};
await store.SetLiveStreamAsync(liveStream);
await store.DeleteLiveStreamAsync("stream-3", "session-c");
var bySessionId = await store.TryGetLiveStreamAsync("stream-3", "session-c");
var byPlaySessionId = await store.TryGetLiveStreamAsync("stream-3", "play-session-c");
Assert.Null(bySessionId);
Assert.Null(byPlaySessionId);
}
/// <summary>
/// Verifies that <see cref="ITranscodeSessionStore.TryGetLiveStreamAsync"/> returns null when
/// no matching record exists.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task TryGetLiveStreamAsync_WhenNotPresent_ReturnsNull()
{
var store = new InMemoryTranscodeSessionStore();
var result = await store.TryGetLiveStreamAsync("nonexistent-stream", "nonexistent-session");
Assert.Null(result);
}
/// <summary>
/// Thread-safe, in-memory implementation of <see cref="ITranscodeSessionStore"/> used within
/// this test class to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests.
/// </summary>
private sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore
{
private static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30);
private readonly Dictionary<string, TranscodeSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LiveStreamSession> _liveStreams = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
/// <inheritdoc />
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var session) && session.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult<TranscodeSession?>(Clone(session));
}
return Task.FromResult<TranscodeSession?>(null);
}
}
/// <inheritdoc />
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sessions.TryGetValue(playSessionId, out var session))
{
return Task.FromResult(false);
}
if (session.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult(false);
}
session.OwnerPod = claimingPod;
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
return Task.FromResult(true);
}
}
/// <inheritdoc />
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions[session.PlaySessionId] = session;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var session))
{
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions.Remove(playSessionId);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
var sessions = _sessions.Values
.Where(s => s.LeaseExpiresUtc > DateTime.UtcNow)
.Select(Clone)
.ToList();
return Task.FromResult<IEnumerable<TranscodeSession>>(sessions);
}
}
/// <inheritdoc />
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.SessionId)] = session;
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
_liveStreams[MakeLiveStreamKey(session.LiveStreamId, session.PlaySessionId)] = session;
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session);
return Task.FromResult(session);
}
}
/// <inheritdoc />
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_liveStreams.TryGetValue(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId), out var session))
{
_liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.SessionId));
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
_liveStreams.Remove(MakeLiveStreamKey(liveStreamId, session.PlaySessionId));
}
}
else
{
_liveStreams.Remove(MakeLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId));
}
}
return Task.CompletedTask;
}
private static string MakeLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
=> liveStreamId + "\x00" + sessionIdOrPlaySessionId;
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
PlaySessionId = source.PlaySessionId,
OwnerPod = source.OwnerPod,
LeaseExpiresUtc = source.LeaseExpiresUtc,
ManifestPath = source.ManifestPath,
SegmentPathPrefix = source.SegmentPathPrefix,
MediaSourceId = source.MediaSourceId,
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
};
}
}
@@ -0,0 +1,429 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.IO;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
/// <summary>
/// Tests for lease-aware cleanup behavior expected of <c>DeleteTranscodeFileTask</c> once
/// it is made HA-aware in Phase 5.2.
/// <para>
/// The current <c>DeleteTranscodeFileTask</c> implementation uses file-age only and does not
/// check <see cref="ITranscodeSessionStore"/>, which creates a data-loss risk on shared NFS
/// storage. These tests document the correct contract by exercising the store directly.
/// </para>
/// </summary>
public class DeleteTranscodeFileTaskTests
{
private static TranscodeSession CreateSession(string id, string pod, DateTime leaseExpiry)
=> new TranscodeSession
{
PlaySessionId = id,
OwnerPod = pod,
LeaseExpiresUtc = leaseExpiry,
ManifestPath = $"/transcode/{id}/manifest.m3u8",
SegmentPathPrefix = $"/transcode/{id}/segment",
MediaSourceId = $"media-source-{id}",
LastCompletedSegmentIndex = 2,
LastDurablePlaybackOffset = 12_000_000L,
};
/// <summary>
/// Creates a mock <see cref="IConfigurationManager"/> that returns <paramref name="transcodePath"/>
/// as the configured transcode path, used by the <c>GetTranscodePath</c> extension method.
/// </summary>
private static Mock<IConfigurationManager> CreateConfigMock(string transcodePath)
{
var appPathsMock = new Mock<IApplicationPaths>();
appPathsMock
.Setup(p => p.CreateAndCheckMarker(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()));
var configMock = new Mock<IConfigurationManager>();
configMock
.Setup(c => c.GetConfiguration("encoding"))
.Returns(new EncodingOptions { TranscodingTempPath = transcodePath });
configMock
.Setup(c => c.CommonApplicationPaths)
.Returns(appPathsMock.Object);
return configMock;
}
/// <summary>
/// A directory that belongs to a session with a live lease must NOT be deleted.
/// The store returns non-null, signalling to the cleanup task that the session is active.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task LiveLease_StoreReturnsSession_DirectoryShouldNotBeDeleted()
{
var store = new CleanupTestSessionStore();
var session = CreateSession("cleanup-session-1", "pod-a", DateTime.UtcNow.AddMinutes(5));
await store.SetAsync(session);
// The cleanup task should query the store before deleting.
var liveSession = await store.TryGetAsync("cleanup-session-1");
// Non-null result → lease is active → directory must be retained.
Assert.NotNull(liveSession);
Assert.Equal("pod-a", liveSession.OwnerPod);
Assert.True(liveSession.LeaseExpiresUtc > DateTime.UtcNow);
}
/// <summary>
/// A directory whose session lease has expired beyond the recovery window MAY be deleted.
/// The store returns <c>null</c>, signalling to the cleanup task that deletion is safe.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task ExpiredBeyondRecoveryWindow_StoreReturnsNull_DirectoryMayBeDeleted()
{
var store = new CleanupTestSessionStore();
// Lease expired two hours ago beyond any reasonable recovery window.
var session = CreateSession("cleanup-session-2", "pod-a", DateTime.UtcNow.AddHours(-2));
await store.SetAsync(session);
var liveSession = await store.TryGetAsync("cleanup-session-2");
// Null result → lease is expired → cleanup task may delete the directory.
Assert.Null(liveSession);
}
/// <summary>
/// When no session record exists in the store for a given directory, the cleanup task
/// should treat the directory as deletable (store returns <c>null</c>).
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task NoSessionRecord_StoreReturnsNull_DirectoryMayBeDeleted()
{
var store = new CleanupTestSessionStore();
var liveSession = await store.TryGetAsync("unknown-session");
Assert.Null(liveSession);
}
/// <summary>
/// Files that belong to an active session (manifest or segments) must NOT be deleted
/// even when their modification time is older than <c>minDateModified</c>.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task ExecuteAsync_WithActiveSession_DoesNotDeleteActiveFiles()
{
// Arrange
const string TranscodePath = "/transcode";
const string SessionId = "active-session-1";
const string ManifestPath = "/transcode/active-session-1/manifest.m3u8";
const string SegmentPath = "/transcode/active-session-1/segment0.ts";
var store = new CleanupTestSessionStore();
var session = new TranscodeSession
{
PlaySessionId = SessionId,
OwnerPod = "pod-a",
LeaseExpiresUtc = DateTime.UtcNow.AddMinutes(5),
ManifestPath = ManifestPath,
SegmentPathPrefix = "/transcode/active-session-1/segment",
MediaSourceId = "media-source-1",
};
await store.SetAsync(session);
var deletedFiles = new List<string>();
var oldModifyTime = DateTime.UtcNow.AddDays(-2);
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock
.Setup(fs => fs.GetFiles(TranscodePath, true))
.Returns(new[]
{
new FileSystemMetadata { FullName = ManifestPath, IsDirectory = false },
new FileSystemMetadata { FullName = SegmentPath, IsDirectory = false },
});
fileSystemMock
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
.Returns(oldModifyTime);
fileSystemMock
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
.Callback<string>(path => deletedFiles.Add(path));
fileSystemMock
.Setup(fs => fs.GetFiles(TranscodePath, false))
.Returns(Enumerable.Empty<FileSystemMetadata>());
fileSystemMock
.Setup(fs => fs.GetDirectories(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(Enumerable.Empty<FileSystemMetadata>());
var configMock = CreateConfigMock(TranscodePath);
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
localizationMock
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
.Returns<string>(s => s);
var loggerMock = new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>();
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
loggerMock.Object,
fileSystemMock.Object,
configMock.Object,
localizationMock.Object,
store);
// Act
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
// Assert neither the manifest nor the segment should have been deleted
Assert.DoesNotContain(ManifestPath, deletedFiles);
Assert.DoesNotContain(SegmentPath, deletedFiles);
}
/// <summary>
/// Files whose session lease has expired are NOT returned by <see cref="ITranscodeSessionStore.GetActiveSessionsAsync"/>
/// and therefore should be eligible for time-based deletion.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task ExecuteAsync_WithExpiredSession_DeletesFiles()
{
// Arrange
const string TranscodePath = "/transcode";
const string SessionId = "expired-session-1";
const string ManifestPath = "/transcode/expired-session-1/manifest.m3u8";
var store = new CleanupTestSessionStore();
// Lease expired two hours ago
var session = new TranscodeSession
{
PlaySessionId = SessionId,
OwnerPod = "pod-a",
LeaseExpiresUtc = DateTime.UtcNow.AddHours(-2),
ManifestPath = ManifestPath,
SegmentPathPrefix = "/transcode/expired-session-1/segment",
MediaSourceId = "media-source-1",
};
await store.SetAsync(session);
var deletedFiles = new List<string>();
var oldModifyTime = DateTime.UtcNow.AddDays(-2);
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock
.Setup(fs => fs.GetFiles(TranscodePath, true))
.Returns(new[]
{
new FileSystemMetadata { FullName = ManifestPath, IsDirectory = false },
});
fileSystemMock
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
.Returns(oldModifyTime);
fileSystemMock
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
.Callback<string>(path => deletedFiles.Add(path));
fileSystemMock
.Setup(fs => fs.GetDirectories(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(Enumerable.Empty<FileSystemMetadata>());
var configMock = CreateConfigMock(TranscodePath);
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
localizationMock
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
.Returns<string>(s => s);
var loggerMock = new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>();
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
loggerMock.Object,
fileSystemMock.Object,
configMock.Object,
localizationMock.Object,
store);
// Act
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
// Assert expired session files are eligible for time-based deletion
Assert.Contains(ManifestPath, deletedFiles);
}
/// <summary>
/// When <see cref="ITranscodeSessionStore.GetActiveSessionsAsync"/> throws an exception,
/// the task should abort deletion safely rather than risk removing files in use.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task ExecuteAsync_WhenStoreFails_AbortsDeletion()
{
// Arrange
const string TranscodePath = "/transcode";
const string ManifestPath = "/transcode/session-1/manifest.m3u8";
var deletedFiles = new List<string>();
var oldModifyTime = DateTime.UtcNow.AddDays(-2);
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock
.Setup(fs => fs.GetFiles(TranscodePath, true))
.Returns(new[]
{
new FileSystemMetadata { FullName = ManifestPath, IsDirectory = false },
});
fileSystemMock
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
.Returns(oldModifyTime);
fileSystemMock
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
.Callback<string>(path => deletedFiles.Add(path));
var configMock = CreateConfigMock(TranscodePath);
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
localizationMock
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
.Returns<string>(s => s);
var loggerMock = new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>();
var failingStoreMock = new Mock<ITranscodeSessionStore>();
failingStoreMock
.Setup(s => s.GetActiveSessionsAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Redis unavailable"));
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
loggerMock.Object,
fileSystemMock.Object,
configMock.Object,
localizationMock.Object,
failingStoreMock.Object);
// Act
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
// Assert when the store fails, no files should be deleted (safe abort)
Assert.Empty(deletedFiles);
}
/// <summary>
/// Minimal in-memory <see cref="ITranscodeSessionStore"/> used within this test class
/// to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests.
/// </summary>
private sealed class CleanupTestSessionStore : ITranscodeSessionStore
{
private static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(30);
private readonly Dictionary<string, TranscodeSession> _sessions =
new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var s) && s.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult<TranscodeSession?>(Clone(s));
}
return Task.FromResult<TranscodeSession?>(null);
}
}
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sessions.TryGetValue(playSessionId, out var s))
{
return Task.FromResult(false);
}
if (s.LeaseExpiresUtc > DateTime.UtcNow)
{
return Task.FromResult(false);
}
s.OwnerPod = claimingPod;
s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration);
return Task.FromResult(true);
}
}
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions[session.PlaySessionId] = session;
}
return Task.CompletedTask;
}
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_sessions.TryGetValue(playSessionId, out var s))
{
s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration);
}
}
return Task.CompletedTask;
}
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
{
lock (_lock)
{
_sessions.Remove(playSessionId);
}
return Task.CompletedTask;
}
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
var sessions = _sessions.Values
.Where(s => s.LeaseExpiresUtc > DateTime.UtcNow)
.Select(Clone)
.ToList();
return Task.FromResult<IEnumerable<TranscodeSession>>(sessions);
}
}
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<LiveStreamSession?>(null);
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
PlaySessionId = source.PlaySessionId,
OwnerPod = source.OwnerPod,
LeaseExpiresUtc = source.LeaseExpiresUtc,
ManifestPath = source.ManifestPath,
SegmentPathPrefix = source.SegmentPathPrefix,
MediaSourceId = source.MediaSourceId,
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
};
}
}
@@ -8,6 +8,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
@@ -36,7 +37,8 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
Mock.Of<ITranscodeSessionStore>());
await Assert.ThrowsAsync(exceptionType, () => sessionManager.GetAuthorizationToken(
new User("test", "default", "default"),
@@ -63,7 +65,8 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
Mock.Of<ITranscodeSessionStore>());
await Assert.ThrowsAsync(exceptionType, () => sessionManager.AuthenticateNewSessionInternal(authenticationRequest, false));
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>Jellyfin.DbMigrator</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Npgsql" />
<PackageReference Include="AWSSDK.S3" />
</ItemGroup>
</Project>
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.DbMigrator;
/// <summary>
/// Represents the migration result for a single table.
/// </summary>
/// <param name="TableName">The name of the table.</param>
/// <param name="SqliteRowCount">The number of rows read from SQLite.</param>
/// <param name="PostgresRowCount">The number of rows verified in PostgreSQL after migration.</param>
/// <param name="Error">The error message if migration failed, or <see langword="null"/> on success.</param>
public sealed record TableReport(
string TableName,
long SqliteRowCount,
long PostgresRowCount,
string? Error);
/// <summary>
/// Provides utilities for collecting and printing the migration report.
/// </summary>
public static class MigrationReport
{
/// <summary>
/// Prints a formatted summary of per-table migration results to the console.
/// </summary>
/// <param name="reports">The collection of per-table results.</param>
public static void Print(IReadOnlyList<TableReport> reports)
{
Console.WriteLine();
Console.WriteLine("=== Migration Report ===");
Console.WriteLine(
$"{"Table",-40} {"SQLite",10} {"PostgreSQL",10} {"Status",-10}");
Console.WriteLine(new string('-', 74));
int failed = 0;
foreach (var r in reports)
{
string status = r.Error is null ? "OK" : "FAILED";
if (r.Error is not null)
{
failed++;
}
Console.WriteLine(
$"{r.TableName,-40} {r.SqliteRowCount,10} {r.PostgresRowCount,10} {status,-10}");
if (r.Error is not null)
{
Console.WriteLine($" Error: {r.Error}");
}
}
Console.WriteLine(new string('-', 74));
Console.WriteLine(
$"Total: {reports.Count} tables, {failed} failed, {reports.Count - failed} succeeded.");
}
}
@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
namespace Jellyfin.DbMigrator;
/// <summary>
/// Writes rows to a PostgreSQL database using batched INSERT statements.
/// </summary>
public static class PostgresBulkWriter
{
/// <summary>
/// The maximum number of rows per INSERT batch.
/// </summary>
private const int BatchSize = 500;
/// <summary>
/// Inserts all rows into the specified PostgreSQL table using batched INSERT statements.
/// When <paramref name="isDryRun"/> is <see langword="true"/>, logs what would be inserted without writing.
/// </summary>
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
/// <param name="tableName">The name of the target PostgreSQL table.</param>
/// <param name="rows">The rows to insert, as dictionaries mapping column name to value.</param>
/// <param name="isDryRun">When <see langword="true"/>, skips actual writes.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The number of rows that were inserted (or would have been inserted in dry-run mode).</returns>
public static async Task<long> WriteTableAsync(
NpgsqlConnection connection,
string tableName,
IReadOnlyList<IReadOnlyDictionary<string, object?>> rows,
bool isDryRun,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
TableNameValidator.EnsureSafe(tableName);
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count == 0)
{
return 0L;
}
// Collect column names from the first row.
var columns = new List<string>(rows[0].Keys);
if (isDryRun)
{
Console.WriteLine(
$" [dry-run] Would insert {rows.Count} rows into \"{tableName}\" " +
$"({string.Join(", ", columns)}).");
return rows.Count;
}
long inserted = 0L;
for (int offset = 0; offset < rows.Count; offset += BatchSize)
{
int end = Math.Min(offset + BatchSize, rows.Count);
int batchCount = end - offset;
var sql = BuildInsertSql(tableName, columns, batchCount);
var cmd = connection.CreateCommand();
await using (cmd.ConfigureAwait(false))
{
cmd.CommandText = sql;
int paramIndex = 0;
for (int rowIdx = offset; rowIdx < end; rowIdx++)
{
var row = rows[rowIdx];
foreach (var col in columns)
{
string paramName = $"p{paramIndex.ToString(CultureInfo.InvariantCulture)}";
row.TryGetValue(col, out object? val);
cmd.Parameters.AddWithValue(paramName, val ?? DBNull.Value);
paramIndex++;
}
}
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
inserted += batchCount;
}
}
return inserted;
}
/// <summary>
/// Advances the PostgreSQL integer sequence for each table that contains an <c>Id</c> column,
/// so that future auto-generated primary keys do not conflict with migrated data.
/// </summary>
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
/// <param name="tableNames">The names of the tables whose sequences should be advanced.</param>
/// <param name="isDryRun">When <see langword="true"/>, logs the SQL without executing it.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static async Task AdvanceSequencesAsync(
NpgsqlConnection connection,
IEnumerable<string> tableNames,
bool isDryRun,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentNullException.ThrowIfNull(tableNames);
foreach (var tableName in tableNames)
{
// Check if the table has an "Id" column.
bool hasIdColumn = await TableHasColumnAsync(
connection, tableName, "Id", cancellationToken).ConfigureAwait(false);
if (!hasIdColumn)
{
continue;
}
string sql =
$"SELECT setval(pg_get_serial_sequence('{tableName}', 'Id'), " +
$"COALESCE((SELECT MAX(\"Id\") FROM \"{tableName}\"), 1))";
if (isDryRun)
{
Console.WriteLine($" [dry-run] Would advance sequence: {sql}");
continue;
}
var cmd = connection.CreateCommand();
await using (cmd.ConfigureAwait(false))
{
cmd.CommandText = sql;
try
{
await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Sequence may not exist for tables without serial PK — log and continue.
Console.WriteLine(
$" Warning: Could not advance sequence for \"{tableName}\": {ex.Message}");
}
}
}
}
/// <summary>
/// Returns the number of rows currently in the specified PostgreSQL table.
/// </summary>
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
/// <param name="tableName">The name of the table to count.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The row count, or -1 if the table does not exist.</returns>
public static async Task<long> CountRowsAsync(
NpgsqlConnection connection,
string tableName,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
TableNameValidator.EnsureSafe(tableName);
var cmd = connection.CreateCommand();
await using (cmd.ConfigureAwait(false))
{
cmd.CommandText = $"SELECT COUNT(*) FROM \"{tableName}\"";
try
{
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is long count ? count : Convert.ToInt64(result, CultureInfo.InvariantCulture);
}
catch (NpgsqlException)
{
return -1L;
}
}
}
/// <summary>
/// Builds a parameterised bulk INSERT SQL statement for the given table, columns, and row count.
/// </summary>
/// <param name="tableName">The target table name.</param>
/// <param name="columns">The ordered list of column names.</param>
/// <param name="rowCount">The number of value-rows to include.</param>
/// <returns>A parameterised INSERT statement.</returns>
private static string BuildInsertSql(string tableName, IReadOnlyList<string> columns, int rowCount)
{
var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture, $"INSERT INTO \"{tableName}\" (");
for (int i = 0; i < columns.Count; i++)
{
if (i > 0)
{
sb.Append(", ");
}
sb.Append(CultureInfo.InvariantCulture, $"\"{columns[i]}\"");
}
sb.Append(") VALUES ");
int paramIndex = 0;
for (int row = 0; row < rowCount; row++)
{
if (row > 0)
{
sb.Append(", ");
}
sb.Append('(');
for (int col = 0; col < columns.Count; col++)
{
if (col > 0)
{
sb.Append(", ");
}
sb.Append(CultureInfo.InvariantCulture, $"@p{paramIndex.ToString(CultureInfo.InvariantCulture)}");
paramIndex++;
}
sb.Append(')');
}
sb.Append(" ON CONFLICT DO NOTHING");
return sb.ToString();
}
/// <summary>
/// Checks whether a given column exists in a PostgreSQL table.
/// </summary>
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
/// <param name="tableName">The table name to check.</param>
/// <param name="columnName">The column name to look for.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the column exists; otherwise, <see langword="false"/>.</returns>
private static async Task<bool> TableHasColumnAsync(
NpgsqlConnection connection,
string tableName,
string columnName,
CancellationToken cancellationToken = default)
{
var cmd = connection.CreateCommand();
await using (cmd.ConfigureAwait(false))
{
cmd.CommandText =
"SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_name = @table AND column_name = @col";
cmd.Parameters.AddWithValue("table", tableName);
cmd.Parameters.AddWithValue("col", columnName);
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
long count = result is long l ? l : Convert.ToInt64(result, CultureInfo.InvariantCulture);
return count > 0;
}
}
}
+244
View File
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Amazon;
using Amazon.S3;
using Amazon.S3.Transfer;
using Jellyfin.DbMigrator;
using Microsoft.Data.Sqlite;
using Npgsql;
// ---------------------------------------------------------------------------
// Ordered table list (respects FK constraints).
// ---------------------------------------------------------------------------
string[] tableOrder =
[
// Group 1 no FK dependencies
"Users",
"ApiKeys",
"Devices",
"DeviceOptions",
// Group 2 BaseItems (self-referencing FK only)
"BaseItems",
// Group 3 children of BaseItems + ItemValues
"AncestorIds",
"BaseItemImageInfos",
"BaseItemMetadataFields",
"BaseItemTrailerTypes",
"BaseItemProviders",
"Chapters",
"ItemValues",
"ItemValuesMap",
"MediaStreamInfos",
"AttachmentStreamInfos",
"KeyframeData",
// Group 4 People
"Peoples",
"PeopleBaseItemMap",
// Group 5 User-related data
"UserData",
"MediaSegments",
"TrickplayInfos",
// Group 6 Misc / user preferences
"ActivityLogs",
"AccessSchedules",
"Permissions",
"Preferences",
"DisplayPreferences",
"ItemDisplayPreferences",
"CustomItemDisplayPreferences",
"ImageInfos",
];
// ---------------------------------------------------------------------------
// Parse command-line arguments.
// ---------------------------------------------------------------------------
string? sqlitePath = null;
string? postgresConnectionString = null;
bool isDryRun = false;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--sqlite" when i + 1 < args.Length:
sqlitePath = args[++i];
break;
case "--postgres" when i + 1 < args.Length:
postgresConnectionString = args[++i];
break;
case "--dry-run":
isDryRun = true;
break;
}
}
if (string.IsNullOrWhiteSpace(sqlitePath) || string.IsNullOrWhiteSpace(postgresConnectionString))
{
await Console.Error.WriteLineAsync(
"Usage: Jellyfin.DbMigrator --sqlite <path> --postgres <connection-string> [--dry-run]")
.ConfigureAwait(false);
return 2;
}
if (!File.Exists(sqlitePath))
{
await Console.Error.WriteLineAsync($"SQLite database not found: {sqlitePath}")
.ConfigureAwait(false);
return 2;
}
if (isDryRun)
{
await Console.Out.WriteLineAsync("[dry-run] No data will be written to PostgreSQL.")
.ConfigureAwait(false);
}
// ---------------------------------------------------------------------------
// Pre-migration S3 backup.
// ---------------------------------------------------------------------------
string? s3Bucket = Environment.GetEnvironmentVariable("S3_BACKUP_BUCKET");
string? awsRegion = Environment.GetEnvironmentVariable("AWS_DEFAULT_REGION");
if (!string.IsNullOrWhiteSpace(s3Bucket) && !string.IsNullOrWhiteSpace(awsRegion))
{
await Console.Out.WriteLineAsync($"Uploading {sqlitePath} to s3://{s3Bucket}/ in region {awsRegion}…")
.ConfigureAwait(false);
try
{
await UploadToS3Async(sqlitePath, s3Bucket, awsRegion, isDryRun).ConfigureAwait(false);
await Console.Out.WriteLineAsync("S3 backup complete.").ConfigureAwait(false);
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"S3 backup failed (continuing): {ex.Message}")
.ConfigureAwait(false);
}
}
else
{
await Console.Out.WriteLineAsync(
"S3_BACKUP_BUCKET or AWS_DEFAULT_REGION not set skipping pre-migration backup.")
.ConfigureAwait(false);
}
// ---------------------------------------------------------------------------
// Open connections.
// ---------------------------------------------------------------------------
var sqliteConnectionString = new SqliteConnectionStringBuilder
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadOnly,
}.ToString();
await using var sqliteConnection = new SqliteConnection(sqliteConnectionString);
await sqliteConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false);
await using var pgConnection = new NpgsqlConnection(postgresConnectionString);
await pgConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false);
// ---------------------------------------------------------------------------
// Migrate tables.
// ---------------------------------------------------------------------------
var reports = new List<TableReport>();
bool anyFailure = false;
foreach (var tableName in tableOrder)
{
await Console.Out.WriteLineAsync($"Migrating table: {tableName}").ConfigureAwait(false);
long sqliteCount = 0L;
long pgCount = 0L;
string? error = null;
try
{
// Read from SQLite.
sqliteCount = await SqliteTableReader.CountRowsAsync(
sqliteConnection, tableName).ConfigureAwait(false);
if (sqliteCount < 0)
{
await Console.Out.WriteLineAsync($" Table \"{tableName}\" not found in SQLite skipping.")
.ConfigureAwait(false);
reports.Add(new TableReport(tableName, 0L, 0L, null));
continue;
}
await Console.Out.WriteLineAsync($" SQLite rows: {sqliteCount}").ConfigureAwait(false);
var rows = await SqliteTableReader.ReadAllRowsAsync(
sqliteConnection, tableName).ConfigureAwait(false);
// Write to PostgreSQL.
long inserted = await PostgresBulkWriter.WriteTableAsync(
pgConnection, tableName, rows, isDryRun).ConfigureAwait(false);
await Console.Out.WriteLineAsync($" Inserted: {inserted}").ConfigureAwait(false);
// Verify row count in PostgreSQL.
pgCount = isDryRun
? 0L
: await PostgresBulkWriter.CountRowsAsync(pgConnection, tableName).ConfigureAwait(false);
}
catch (Exception ex)
{
error = ex.Message;
anyFailure = true;
await Console.Error.WriteLineAsync($" ERROR migrating \"{tableName}\": {ex.Message}")
.ConfigureAwait(false);
}
reports.Add(new TableReport(tableName, sqliteCount, pgCount, error));
}
// ---------------------------------------------------------------------------
// Advance PostgreSQL sequences.
// ---------------------------------------------------------------------------
await Console.Out.WriteLineAsync("Advancing PostgreSQL sequences…").ConfigureAwait(false);
await PostgresBulkWriter.AdvanceSequencesAsync(
pgConnection, tableOrder, isDryRun).ConfigureAwait(false);
// ---------------------------------------------------------------------------
// Print report.
// ---------------------------------------------------------------------------
MigrationReport.Print(reports);
return anyFailure ? 1 : 0;
// ---------------------------------------------------------------------------
// Local functions.
// ---------------------------------------------------------------------------
// Uploads a file to the configured S3 bucket before migration starts.
static async Task UploadToS3Async(
string filePath,
string bucket,
string region,
bool isDryRun)
{
if (isDryRun)
{
await Console.Out.WriteLineAsync(
$" [dry-run] Would upload \"{filePath}\" to s3://{bucket}/{Path.GetFileName(filePath)}")
.ConfigureAwait(false);
return;
}
var regionEndpoint = RegionEndpoint.GetBySystemName(region);
using var s3Client = new AmazonS3Client(regionEndpoint);
using var transferUtility = new TransferUtility(s3Client);
string key = $"jellyfin-db-backups/{Path.GetFileName(filePath)}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.bak";
await transferUtility.UploadAsync(filePath, bucket, key).ConfigureAwait(false);
await Console.Out.WriteLineAsync($" Uploaded to s3://{bucket}/{key}").ConfigureAwait(false);
}
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
namespace Jellyfin.DbMigrator;
/// <summary>
/// Reads rows from a SQLite database table using raw ADO.NET.
/// </summary>
public static class SqliteTableReader
{
/// <summary>
/// Returns all rows from the specified SQLite table as a list of column-name-to-value dictionaries.
/// </summary>
/// <param name="connection">An open <see cref="SqliteConnection"/>.</param>
/// <param name="tableName">The name of the table to read.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list where each element is a dictionary mapping column name to its value (may be <see langword="null"/>).</returns>
public static async Task<List<IReadOnlyDictionary<string, object?>>> ReadAllRowsAsync(
SqliteConnection connection,
string tableName,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
TableNameValidator.EnsureSafe(tableName);
var rows = new List<IReadOnlyDictionary<string, object?>>();
var cmd = connection.CreateCommand();
await using (cmd.ConfigureAwait(false))
{
cmd.CommandText = $"SELECT * FROM \"{tableName}\"";
var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await using (reader.ConfigureAwait(false))
{
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var row = new Dictionary<string, object?>(reader.FieldCount, StringComparer.Ordinal);
for (int i = 0; i < reader.FieldCount; i++)
{
string col = reader.GetName(i);
bool isNull = await reader.IsDBNullAsync(i, cancellationToken).ConfigureAwait(false);
object? val = isNull ? null : reader.GetValue(i);
row[col] = val;
}
rows.Add(row);
}
}
}
return rows;
}
/// <summary>
/// Returns the row count for the specified table in the SQLite database.
/// </summary>
/// <param name="connection">An open <see cref="SqliteConnection"/>.</param>
/// <param name="tableName">The name of the table to count.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The number of rows in the table, or -1 if the table does not exist.</returns>
public static async Task<long> CountRowsAsync(
SqliteConnection connection,
string tableName,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
TableNameValidator.EnsureSafe(tableName);
// Check if the table exists first.
var checkCmd = connection.CreateCommand();
await using (checkCmd.ConfigureAwait(false))
{
checkCmd.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name";
checkCmd.Parameters.AddWithValue("$name", tableName);
var exists = await checkCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
if (exists is not long existsLong || existsLong == 0)
{
return -1L;
}
}
var cmd = connection.CreateCommand();
await using (cmd.ConfigureAwait(false))
{
cmd.CommandText = $"SELECT COUNT(*) FROM \"{tableName}\"";
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is long count ? count : Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture);
}
}
}
@@ -0,0 +1,39 @@
using System;
using System.Text.RegularExpressions;
namespace Jellyfin.DbMigrator;
/// <summary>
/// Validates database table names to prevent SQL injection when names are
/// interpolated into raw SQL strings.
/// </summary>
internal static partial class TableNameValidator
{
/// <summary>
/// Gets the compiled regular expression that matches safe table names.
/// A safe name consists only of ASCII letters, decimal digits, and underscores.
/// </summary>
[GeneratedRegex(@"^[A-Za-z0-9_]+$", RegexOptions.CultureInvariant)]
private static partial Regex SafeNameRegex();
/// <summary>
/// Throws an <see cref="ArgumentException"/> when <paramref name="tableName"/>
/// contains characters that are not safe to embed inside a quoted SQL identifier.
/// </summary>
/// <param name="tableName">The candidate table name.</param>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="tableName"/> contains characters outside
/// <c>[A-Za-z0-9_]</c>.
/// </exception>
public static void EnsureSafe(string tableName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
if (!SafeNameRegex().IsMatch(tableName))
{
throw new ArgumentException(
$"Table name '{tableName}' contains characters that are not allowed in a SQL identifier.",
nameof(tableName));
}
}
}