Compare commits

..

56 Commits

Author SHA1 Message Date
Jellyfin Release Bot 4b4b4cd94d Bump version to 10.11.10 2026-05-24 17:37:49 -04:00
Joshua M. Boniface f2b358a3c8 Merge commit from fork
Fix GHSA-wwwm-px48-fpvq
2026-05-24 17:34:48 -04:00
Joshua M. Boniface 95600752a3 Merge commit from fork
Fix GHSA-jg92-mrxq-vv75
2026-05-24 17:34:08 -04:00
Joshua M. Boniface 7249b5744c Merge commit from fork
Fix GHSA-f47c-m7gr-q92j
2026-05-24 17:33:51 -04:00
Joshua M. Boniface d8b0034a50 Merge pull request #15048 from theguymadmax/fix-cache-issue
Fix stale UserData cache
2026-05-24 12:30:47 -04:00
Bond-009 ed379a1882 Merge pull request #16906 from JPVenson/fix/UserManagerCollation
Fix/user manager collation
2026-05-24 15:52:43 +02:00
JPVenson 984e67c067 Move index creation to its own migration 2026-05-24 12:04:40 +00:00
JPVenson eece62a90b Add unqiue contraint for NormalizedUsername 2026-05-24 11:04:01 +00:00
JPVenson e5c34e7096 Fix non changing username creation 2026-05-23 17:46:39 +00:00
JPVenson 9526083523 Reorder setup user update 2026-05-23 15:28:30 +00:00
JPVenson c4fb0285fc Fixed startup user renaming 2026-05-23 15:15:09 +00:00
JPVenson 620b7a2495 Remove obsolete supressions 2026-05-22 22:41:23 +00:00
JPVenson ac9aa273ab Update gitignore 2026-05-22 21:34:51 +00:00
Shadowghost 72360ba292 Fix GHSA-f47c-m7gr-q92j 2026-05-22 22:32:48 +02:00
Shadowghost 706000cfce Add PathHelper 2026-05-22 22:26:20 +02:00
Shadowghost 734145ab98 Fix GHSA-jg92-mrxq-vv75 2026-05-22 22:24:44 +02:00
Shadowghost 89d32a9525 Add PathHelper 2026-05-22 22:24:30 +02:00
Shadowghost 19a35a6159 Fix GHSA-wwwm-px48-fpvq 2026-05-22 22:23:12 +02:00
JPVenson 8743c22551 Add tests 2026-05-22 18:55:01 +00:00
JPVenson d1ba366f97 Remove 3rd stage of migration 2026-05-22 17:56:11 +00:00
JPVenson 999de06d6b Add 3 part migration for normalized Username 2026-05-22 17:31:45 +00:00
JPVenson 6435600a9c Fix changing of username 2026-05-22 17:06:23 +00:00
JPVenson e52e448c30 Definitively fixed migration 2026-05-22 16:36:19 +00:00
JPVenson f1137a9587 Update default function for migration 2026-05-22 14:38:19 +00:00
JPVenson 6de99306ec Add normalized username column 2026-05-22 10:42:00 +00:00
JPVenson 03ff69a6e1 Update gitignore 2026-05-22 09:19:20 +00:00
theguymadmax 94d0f7b1ac Apply review feedback 2026-05-21 19:11:34 -04:00
Jellyfin Release Bot e83a7e62f2 Bump version to 10.11.9 2026-05-20 21:27:36 -04:00
Bond-009 445c6c9448 Merge pull request #16845 from JPVenson/backport/adaptLoggingForUserConcurrency
Update log for user session related concurrency update fails
2026-05-15 10:02:50 +02:00
JPVenson 5f3189af41 readded concurrency exception check 2026-05-14 13:09:00 +00:00
JPVenson b278dcf475 revert change 2026-05-14 13:08:24 +00:00
JPVenson f7d80ae9e6 backport changes from #16835 2026-05-14 12:54:35 +00:00
Bond-009 a023b9c88d Fix rate control in av1_amf encoder (#16819)
Only the h26x_amf in legacy driver needs this.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-11 18:25:50 +02:00
nyanmisaka 40f35f6094 Fix rate control in av1_amf encoder
Only the h26x_amf in legacy driver needs this.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-11 01:23:45 +08:00
Niels van Velzen 2b6fc19842 Merge pull request #15368 from JPVenson/bugfix/15153_FixUserConcurrency
Fix UserManager after EFcore refactor
2026-05-05 19:36:58 +02:00
Bond-009 8c29098c8a Use strict QSV CPB size for less powerful H.264 decoder (#16743)
* Fix int32 overflow in QSV rate-control parameter computation (#16376)

Fix int32 overflow in QSV rate-control parameter computation

* Use strict QSV CPB size for less powerful H.264 decoder

Signed-off-by: nyanmisaka <nst799610810@gmail.com>

---------

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
Co-authored-by: scheilch <christian.scheil@icloud.com>
2026-05-03 12:21:33 +02:00
nyanmisaka 758ee0af76 Use strict QSV CPB size for less powerful H.264 decoder
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-02 20:14:51 +08:00
scheilch 2e19c247ef Fix int32 overflow in QSV rate-control parameter computation (#16376)
Fix int32 overflow in QSV rate-control parameter computation
2026-05-02 18:45:41 +08:00
JPVenson 511f90d6d3 Update Reset method to not rely on externally available entity 2026-05-01 11:05:33 +00:00
JPVenson 1ae45519d0 Removed obsolete comment 2026-05-01 08:55:15 +00:00
JPVenson 586fa01e46 Apply review comments 2026-04-30 18:08:14 +00:00
JPVenson 2ac0edc052 Refactored all UserManager db access methods
Fixed stale cached entities in UserManager
Fixed wrong state persisting though method calls
2026-04-30 15:42:46 +00:00
JPVenson b37ebec5f6 Merge remote-tracking branch 'jellyfinorigin/release-10.11.z' into bugfix/15153_FixUserConcurrency 2026-04-30 12:46:39 +00:00
Bond-009 938c043596 Merge pull request #16718 from jellyfin/fix-vpp-hdr10
Allow HDR10 for VPP tonemapping
2026-04-29 21:36:24 +02:00
gnattu 46a53d0605 also allow hdr10plus 2026-04-28 00:09:31 +08:00
gnattu 97f88743b8 Allow HDR10 for VPP tonemapping
It mistakenly required OpenCL tone mapping but VPP can handle this
2026-04-27 20:10:12 +08:00
Jellyfin Release Bot 2c62d40f0d Bump version to 10.11.8 2026-04-05 15:08:15 -04:00
Niels van Velzen dca3cc74b7 Merge pull request #16538 from MBR-0001/fix-language-querying
Fix querying media with language filters
2026-04-05 17:52:57 +02:00
Niels van Velzen be095f85ab Merge pull request #16540 from Shadowghost/handle-bad-folders
Handle folders without associated library in FixLibrarySubtitleDownloadLanguages
2026-04-03 19:08:52 +02:00
Niels van Velzen f51c63e244 Merge pull request #16539 from MBR-0001/fix-language-saving
Fix subtitle saving
2026-04-03 19:08:21 +02:00
MBR-0001 cc678383c9 Simplify subtitle format validation condition 2026-04-02 00:23:18 +02:00
Shadowghost ba0720a555 Handle folders without associated library in FixLibrarySubtitleDownloadLanguages 2026-04-01 16:49:48 +02:00
MBR#0001 417df3df57 Fix subtitle saving 2026-04-01 16:33:04 +02:00
MBR#0001 169e48ac00 Fix querying media with language filters 2026-04-01 16:04:20 +02:00
JPVenson 01ae62aa49 #15153 fixed 2025-11-03 12:47:26 +00:00
theguymadmax d2df6adc16 Fix stale UserData cache 2025-10-20 12:46:07 -04:00
106 changed files with 1976 additions and 9632 deletions
+1 -3
View File
@@ -11,9 +11,7 @@ on:
jobs:
analyze:
name: Analyze
# Disabled in fork — upstream CodeQL requires specific GitHub org permissions and .NET 10 support
if: false
runs-on: [self-hosted, k3s, linux, amd64]
runs-on: ubuntu-latest
strategy:
fail-fast: false
+15 -72
View File
@@ -13,29 +13,26 @@ env:
jobs:
run-tests:
runs-on: [self-hosted, k3s, linux, amd64]
strategy:
matrix:
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
fail-fast: false
runs-on: "${{ matrix.os }}"
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# 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"
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: ${{ env.SDK_VERSION }}
- name: Run DotNet CLI Tests
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"
run: >
dotnet test Jellyfin.sln
--configuration Release
--collect:"XPlat Code Coverage"
--settings tests/coverletArgs.runsettings
--verbosity minimal
- name: Merge code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
@@ -46,57 +43,3 @@ 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
@@ -1,93 +0,0 @@
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
+4
View File
@@ -277,3 +277,7 @@ apiclient/generated
# Omnisharp crash logs
mono_crash.*.json
# Devcontainer temp files
.devcontainer/devcontainer-lock.json
dotnet/
-5
View File
@@ -5,7 +5,6 @@
<!-- 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" />
@@ -55,12 +54,10 @@
<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" />
@@ -91,11 +88,9 @@
<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
@@ -1,96 +0,0 @@
# 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
@@ -1,55 +0,0 @@
# 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.7</VersionPrefix>
<VersionPrefix>10.11.10</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -66,7 +66,6 @@
<ItemGroup>
<PackageReference Include="Ignore" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library;
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
/// </summary>
public class PathManager : IPathManager
{
private readonly ILogger<PathManager> _logger;
private readonly IServerConfigurationManager _config;
private readonly IApplicationPaths _appPaths;
/// <summary>
/// Initializes a new instance of the <see cref="PathManager"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="config">The server configuration manager.</param>
/// <param name="appPaths">The application paths.</param>
public PathManager(
ILogger<PathManager> logger,
IServerConfigurationManager config,
IApplicationPaths appPaths)
{
_logger = logger;
_config = config;
_appPaths = appPaths;
}
@@ -35,9 +41,16 @@ public class PathManager : IPathManager
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
/// <inheritdoc />
public string GetAttachmentPath(string mediaSourceId, string fileName)
public string? GetAttachmentPath(string mediaSourceId, string fileName)
{
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName);
var safeName = PathHelper.GetSafeLeafFileName(fileName);
if (safeName is null)
{
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
return null;
}
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), safeName);
}
/// <inheritdoc />
@@ -236,12 +236,16 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item)
public UserItemData GetUserData(User user, BaseItem item)
{
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
{
Key = item.GetUserDataKeys()[0],
};
var cacheKey = GetCacheKey(user.InternalId, item.Id);
return _cache.GetOrAdd(
cacheKey,
(k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
{
Key = i.GetUserDataKeys()[0],
},
item);
}
/// <inheritdoc />
@@ -1,270 +0,0 @@
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,7 +5,6 @@ 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;
@@ -22,7 +21,6 @@ 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.
@@ -31,19 +29,16 @@ 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,
ITranscodeSessionStore sessionStore)
ILocalizationManager localization)
{
_logger = logger;
_fileSystem = fileSystem;
_configurationManager = configurationManager;
_localization = localization;
_sessionStore = sessionStore;
}
/// <inheritdoc />
@@ -83,39 +78,25 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
}
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var minDateModified = DateTime.UtcNow.AddDays(-1);
progress.Report(50);
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;
}
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
return Task.CompletedTask;
}
/// <summary>
/// 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.
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
/// </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, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
{
var activeSessionList = activeSessions.ToList();
var filesToDelete = _fileSystem.GetFiles(directory, true)
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
.ToList();
@@ -131,13 +112,6 @@ 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++;
@@ -147,24 +121,4 @@ 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,7 +28,6 @@ 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;
@@ -61,7 +60,6 @@ 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);
@@ -91,7 +89,6 @@ 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,
@@ -105,8 +102,7 @@ namespace Emby.Server.Implementations.Session
IServerApplicationHost appHost,
IDeviceManager deviceManager,
IMediaSourceManager mediaSourceManager,
IHostApplicationLifetime hostApplicationLifetime,
ITranscodeSessionStore transcodeSessionStore)
IHostApplicationLifetime hostApplicationLifetime)
{
_logger = logger;
_eventManager = eventManager;
@@ -120,7 +116,6 @@ namespace Emby.Server.Implementations.Session
_appHost = appHost;
_deviceManager = deviceManager;
_mediaSourceManager = mediaSourceManager;
_transcodeSessionStore = transcodeSessionStore;
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
@@ -276,9 +271,9 @@ namespace Emby.Server.Implementations.Session
user.LastActivityDate = activityDate;
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
}
catch (DbUpdateConcurrencyException e)
catch (DbUpdateConcurrencyException)
{
_logger.LogDebug(e, "Error updating user's last activity date.");
_logger.LogDebug("Error updating user's last activity date due to concurrency conflict. This is an expected event.");
}
}
}
@@ -348,38 +343,9 @@ 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);
@@ -810,7 +776,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
}
var eventArgs = new PlaybackStartEventArgs
@@ -870,7 +836,7 @@ namespace Emby.Server.Implementations.Session
return OnPlaybackProgress(info, false);
}
private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
{
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>());
@@ -894,26 +860,6 @@ 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>
@@ -958,7 +904,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
}
var eventArgs = new PlaybackProgressEventArgs
@@ -2097,7 +2043,7 @@ namespace Emby.Server.Implementations.Session
{
CheckDisposed();
var adminUserIds = _userManager.Users
var adminUserIds = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
.Select(i => i.Id)
.ToList();
@@ -60,7 +60,6 @@ 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.
@@ -76,7 +75,6 @@ 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,
@@ -88,8 +86,7 @@ public class DynamicHlsController : BaseJellyfinApiController
ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper,
EncodingHelper encodingHelper,
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
ITranscodeSessionStore transcodeSessionStore)
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
{
_libraryManager = libraryManager;
_userManager = userManager;
@@ -102,7 +99,6 @@ public class DynamicHlsController : BaseJellyfinApiController
_dynamicHlsHelper = dynamicHlsHelper;
_encodingHelper = encodingHelper;
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
_transcodeSessionStore = transcodeSessionStore;
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
}
@@ -313,36 +309,15 @@ 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, isHaMode),
GetCommandLineArguments(playlistPath, state, true, 0),
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
{
@@ -1560,35 +1535,14 @@ 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, isHaMode),
GetCommandLineArguments(playlistPath, state, false, segmentId),
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
{
@@ -1616,77 +1570,6 @@ 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;
@@ -1708,7 +1591,7 @@ public class DynamicHlsController : BaseJellyfinApiController
return segments;
}
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
{
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
@@ -1731,20 +1614,10 @@ 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 {hlsListSize}";
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
{
@@ -1796,10 +1669,10 @@ public class DynamicHlsController : BaseJellyfinApiController
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
threads,
mapArgs,
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
GetAudioArguments(state),
maxMuxingQueueSize,
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat,
startNumber.ToString(CultureInfo.InvariantCulture),
baseUrlParam,
@@ -1941,9 +1814,8 @@ 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, int? segmentLength = null)
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
{
if (state.VideoStream is null)
{
@@ -2018,7 +1890,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, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, 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)
+16 -9
View File
@@ -1,5 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.StartupDtos;
@@ -111,7 +111,7 @@ public class StartupController : BaseJellyfinApiController
{
// TODO: Remove this method when startup wizard no longer requires an existing user.
await _userManager.InitializeAsync().ConfigureAwait(false);
var user = _userManager.Users.First();
var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization.");
return new StartupUserDto
{
Name = user.Username
@@ -131,22 +131,29 @@ public class StartupController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
{
var user = _userManager.Users.First();
var user = _userManager.GetFirstUser();
if (user is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
{
return BadRequest("Password must not be empty");
}
if (startupUserDto.Name is not null)
{
user.Username = startupUserDto.Name;
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
#pragma warning disable CA1309 // Use ordinal string comparison
if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase))
{
await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false);
}
#pragma warning restore CA1309 // Use ordinal string comparison
if (!string.IsNullOrEmpty(startupUserDto.Password))
{
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);
}
return NoContent();
+7 -7
View File
@@ -288,7 +288,7 @@ public class UserController : BaseJellyfinApiController
if (request.ResetPassword)
{
await _userManager.ResetPassword(user).ConfigureAwait(false);
await _userManager.ResetPassword(user.Id).ConfigureAwait(false);
}
else
{
@@ -306,7 +306,7 @@ public class UserController : BaseJellyfinApiController
}
}
await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false);
await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false);
var currentToken = User.GetToken();
@@ -392,7 +392,7 @@ public class UserController : BaseJellyfinApiController
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
{
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false);
}
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
@@ -448,7 +448,7 @@ public class UserController : BaseJellyfinApiController
// If removing admin access
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
{
if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
{
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
}
@@ -463,7 +463,7 @@ public class UserController : BaseJellyfinApiController
// If disabling
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
{
if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
{
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
}
@@ -545,7 +545,7 @@ public class UserController : BaseJellyfinApiController
// no need to authenticate password for new user
if (request.Password is not null)
{
await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false);
}
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
@@ -620,7 +620,7 @@ public class UserController : BaseJellyfinApiController
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
{
var users = _userManager.Users;
var users = _userManager.GetUsers();
if (isDisabled.HasValue)
{
+1 -1
View File
@@ -18,7 +18,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.11.7</VersionPrefix>
<VersionPrefix>10.11.10</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -6,14 +6,12 @@ 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;
@@ -26,13 +24,6 @@ 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()
@@ -132,50 +123,6 @@ 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,6 +33,7 @@ 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;
@@ -69,6 +70,7 @@ 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];
@@ -85,18 +87,21 @@ 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)
ILogger<BaseItemRepository> logger,
ILocalizationManager localizationManager)
{
_dbProvider = dbProvider;
_appHost = appHost;
_itemTypeLookup = itemTypeLookup;
_serverConfigurationManager = serverConfigurationManager;
_logger = logger;
_localizationManager = localizationManager;
}
/// <inheritdoc />
@@ -2297,26 +2302,42 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == 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)));
}
}
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == 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)));
}
}
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == 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)));
}
}
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
baseQuery = baseQuery
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == 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)));
}
}
if (filter.HasSubtitles.HasValue)
@@ -36,7 +36,6 @@
<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>
@@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users
var resetUser = userManager.GetUserByName(spr.UserName)
?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false);
await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false);
usersReset.Add(resetUser.Username);
File.Delete(resetFile);
}
@@ -1,12 +1,13 @@
#pragma warning disable CA1307
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
@@ -35,7 +36,7 @@ namespace Jellyfin.Server.Implementations.Users
/// <summary>
/// Manages the creation and retrieval of <see cref="User"/> instances.
/// </summary>
public partial class UserManager : IUserManager
public partial class UserManager : IUserManager, IDisposable
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IEventManager _eventManager;
@@ -50,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IDictionary<Guid, User> _users;
private readonly AsyncKeyedLocker<Guid> _userLock = new();
/// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -89,29 +90,28 @@ namespace Jellyfin.Server.Implementations.Users
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
_users = new ConcurrentDictionary<Guid, User>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var user in dbContext.Users
.AsSplitQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
.Include(user => user.ProfileImage)
.AsEnumerable())
{
_users.Add(user.Id, user);
}
}
/// <inheritdoc/>
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
/// <inheritdoc/>
public IEnumerable<User> Users => _users.Values;
public IEnumerable<User> GetUsers()
{
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext)
.ToArray();
}
/// <inheritdoc/>
public IEnumerable<Guid> UsersIds => _users.Keys;
public IEnumerable<Guid> GetUsersIds()
{
using var dbContext = _dbProvider.CreateDbContext();
return dbContext.Users
.AsNoTracking()
.Select(user => user.Id)
.ToArray();
}
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
@@ -127,8 +127,28 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Guid can't be empty", nameof(id));
}
_users.TryGetValue(id, out var user);
return user;
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext)
.FirstOrDefault(user => user.Id == id);
}
private static IQueryable<User> UserQuery(JellyfinDbContext dbContext)
{
return dbContext.Users
.AsSingleQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
.Include(user => user.ProfileImage)
.AsNoTracking();
}
/// <inheritdoc/>
public User? GetFirstUser()
{
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext)
.FirstOrDefault();
}
/// <inheritdoc/>
@@ -139,42 +159,51 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Invalid username", nameof(name));
}
return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
using var dbContext = _dbProvider.CreateDbContext();
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
return UserQuery(dbContext)
.FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant());
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
}
/// <inheritdoc/>
public async Task RenameUser(User user, string newName)
public async Task RenameUser(Guid userId, string oldName, string newName)
{
ArgumentNullException.ThrowIfNull(user);
ThrowIfInvalidUsername(newName);
if (user.Username.Equals(newName, StringComparison.Ordinal))
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("The new and old names must be different.");
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
User user = null!; // user is never actually null where its used afterwards so we can just ignore.
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id))
.ConfigureAwait(false))
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
newName));
}
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
if (await dbContext.Users
.AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
newName));
}
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user.Username = newName;
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
user = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == userId)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(userId));
user.Username = newName;
user.NormalizedUsername = newName.ToUpperInvariant();
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
var eventArgs = new UserUpdatedEventArgs(user);
@@ -185,10 +214,9 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task UpdateUserAsync(User user)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
}
@@ -218,23 +246,26 @@ namespace Jellyfin.Server.Implementations.Users
{
ThrowIfInvalidUsername(name);
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
name));
}
User newUser;
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
if (await dbContext.Users
.AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
name));
}
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_users.Add(newUser.Id, newUser);
}
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
@@ -245,62 +276,84 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task DeleteUserAsync(Guid userId)
{
if (!_users.TryGetValue(userId, out var user))
User? user;
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
throw new ResourceNotFoundException(nameof(userId));
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
user = await dbContext.Users
.Include(u => u.Permissions)
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
.ConfigureAwait(false);
if (_users.Count == 1)
{
throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
user.Username));
}
if (user is null)
{
throw new ResourceNotFoundException(nameof(userId));
}
if (user.HasPermission(PermissionKind.IsAdministrator)
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
user.Username),
nameof(userId));
}
var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false);
if (userCount == 1)
{
throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
user.Username));
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Users.Attach(user);
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
if (user.HasPermission(PermissionKind.IsAdministrator)
&& await dbContext.Users
.CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
.ConfigureAwait(false) == 1)
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
user.Username),
nameof(userId));
}
_users.Remove(userId);
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task ResetPassword(User user)
public Task ResetPassword(Guid userId)
{
return ChangePassword(user, string.Empty);
return ChangePassword(userId, string.Empty);
}
/// <inheritdoc/>
public async Task ChangePassword(User user, string newPassword)
public async Task ChangePassword(Guid userId, string newPassword)
{
ArgumentNullException.ThrowIfNull(user);
if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
User dbUser = null!;
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == userId)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(userId));
if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
{
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
}
await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
await UpdateUserAsync(user).ConfigureAwait(false);
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -403,102 +456,114 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentNullException(nameof(username));
}
var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
var authResult = await AuthenticateLocalUser(username, password, user)
.ConfigureAwait(false);
var authenticationProvider = authResult.AuthenticationProvider;
var success = authResult.Success;
if (user is null)
bool success;
var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{
string updatedUsername = authResult.Username;
if (success
&& authenticationProvider is not null
&& authenticationProvider is not DefaultAuthenticationProvider)
// Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null)
{
// Trust the username returned by the authentication provider
username = updatedUsername;
user = GetUserById(user.Id) ?? user;
}
// Search the database for the user again
// the authentication provider might have created it
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
var authResult = await AuthenticateLocalUser(username, password, user)
.ConfigureAwait(false);
var authenticationProvider = authResult.AuthenticationProvider;
success = authResult.Success;
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
if (user is null)
{
string updatedUsername = authResult.Username;
if (success
&& authenticationProvider is not null
&& authenticationProvider is not DefaultAuthenticationProvider)
{
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
// Trust the username returned by the authentication provider
username = updatedUsername;
// Search the database for the user again
// the authentication provider might have created it
user = GetUserByName(username);
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
}
}
}
}
if (success && user is not null && authenticationProvider is not null)
{
var providerId = authenticationProvider.GetType().FullName;
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
if (success && user is not null && authenticationProvider is not null)
{
user.AuthenticationProviderId = providerId;
await UpdateUserAsync(user).ConfigureAwait(false);
}
}
var providerId = authenticationProvider.GetType().FullName;
if (user is null)
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
username,
remoteEndPoint);
throw new AuthenticationException("Invalid username or password entered.");
}
if (user.HasPermission(PermissionKind.IsDisabled))
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException(
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
}
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
!_networkManager.IsInLocalNetwork(remoteEndPoint))
{
_logger.LogInformation(
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("Forbidden.");
}
if (!user.IsParentalScheduleAllowed())
{
_logger.LogInformation(
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("User is not allowed access at this time.");
}
// Update LastActivityDate and LastLoginDate, then save
if (success)
{
if (isUserSession)
{
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{
user.AuthenticationProviderId = providerId;
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
}
user.InvalidLoginAttemptCount = 0;
await UpdateUserAsync(user).ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
}
else
{
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username,
remoteEndPoint);
if (user is null)
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
username,
remoteEndPoint);
throw new AuthenticationException("Invalid username or password entered.");
}
if (user.HasPermission(PermissionKind.IsDisabled))
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException(
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
}
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
!_networkManager.IsInLocalNetwork(remoteEndPoint))
{
_logger.LogInformation(
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("Forbidden.");
}
if (!user.IsParentalScheduleAllowed())
{
_logger.LogInformation(
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("User is not allowed access at this time.");
}
// Update LastActivityDate and LastLoginDate, then save
if (success)
{
if (isUserSession)
{
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
}
user.InvalidLoginAttemptCount = 0;
await UpdateUserInternalAsync(user).ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
}
else
{
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username,
remoteEndPoint);
}
}
return success ? user : null;
@@ -542,22 +607,22 @@ namespace Jellyfin.Server.Implementations.Users
public async Task InitializeAsync()
{
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
if (_users.Any())
{
return;
}
var defaultName = Environment.UserName;
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
{
defaultName = "MyJellyfinUser";
}
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
{
return;
}
var defaultName = Environment.UserName;
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
{
defaultName = "MyJellyfinUser";
}
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
newUser.SetPermission(PermissionKind.IsAdministrator, true);
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
@@ -565,7 +630,6 @@ namespace Jellyfin.Server.Implementations.Users
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_users.Add(newUser.Id, newUser);
}
}
@@ -602,122 +666,120 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
user.SubtitleMode = config.SubtitleMode;
user.HidePlayedInLatest = config.HidePlayedInLatest;
user.EnableLocalPassword = config.EnableLocalPassword;
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
user.DisplayCollectionsView = config.DisplayCollectionsView;
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
user.AudioLanguagePreference = config.AudioLanguagePreference;
user.RememberAudioSelections = config.RememberAudioSelections;
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
// Only set cast receiver id if it is passed in and it exists in the server config.
if (!string.IsNullOrEmpty(config.CastReceiverId)
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
user.CastReceiverId = config.CastReceiverId;
var user = UserQuery(dbContext)
.AsTracking()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
user.SubtitleMode = config.SubtitleMode;
user.HidePlayedInLatest = config.HidePlayedInLatest;
user.EnableLocalPassword = config.EnableLocalPassword;
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
user.DisplayCollectionsView = config.DisplayCollectionsView;
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
user.AudioLanguagePreference = config.AudioLanguagePreference;
user.RememberAudioSelections = config.RememberAudioSelections;
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
// Only set cast receiver id if it is passed in and it exists in the server config.
if (!string.IsNullOrEmpty(config.CastReceiverId)
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
{
user.CastReceiverId = config.CastReceiverId;
}
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
/// <inheritdoc/>
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
-1 => null,
0 => 3,
_ => policy.LoginAttemptsBeforeLockout
};
var user = UserQuery(dbContext)
.AsTracking()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
user.MaxParentalRatingScore = policy.MaxParentalRating;
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
user.AuthenticationProviderId = policy.AuthenticationProviderId;
user.PasswordResetProviderId = policy.PasswordResetProviderId;
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
user.MaxActiveSessions = policy.MaxActiveSessions;
user.SyncPlayAccess = policy.SyncPlayAccess;
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
{
-1 => null,
0 => 3,
_ => policy.LoginAttemptsBeforeLockout
};
user.AccessSchedules.Clear();
foreach (var policyAccessSchedule in policy.AccessSchedules)
{
user.AccessSchedules.Add(policyAccessSchedule);
user.MaxParentalRatingScore = policy.MaxParentalRating;
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
user.AuthenticationProviderId = policy.AuthenticationProviderId;
user.PasswordResetProviderId = policy.PasswordResetProviderId;
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
user.MaxActiveSessions = policy.MaxActiveSessions;
user.SyncPlayAccess = policy.SyncPlayAccess;
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
user.AccessSchedules.Clear();
foreach (var policyAccessSchedule in policy.AccessSchedules)
{
user.AccessSchedules.Add(policyAccessSchedule);
}
// TODO: fix this at some point
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
// TODO: fix this at some point
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
@@ -729,15 +791,17 @@ namespace Jellyfin.Server.Implementations.Users
return;
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{
dbContext.Remove(user.ProfileImage);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Remove(user.ProfileImage);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
user.ProfileImage = null;
_users[user.Id] = user;
user.ProfileImage = null;
}
}
internal static void ThrowIfInvalidUsername(string name)
@@ -883,15 +947,42 @@ namespace Jellyfin.Server.Implementations.Users
user.InvalidLoginAttemptCount);
}
await UpdateUserAsync(user).ConfigureAwait(false);
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
private async Task UpdateUserInternalAsync(User user)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{
dbContext.Users.Attach(user);
dbContext.Entry(user).State = EntityState.Modified;
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes all members of this class.
/// </summary>
/// <param name="disposing">Defines if the class has been cleaned up by a dispose or finalizer.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_userLock.Dispose();
}
}
}
}
-31
View File
@@ -2,7 +2,6 @@ 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;
@@ -24,7 +23,6 @@ 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;
@@ -33,7 +31,6 @@ using MediaBrowser.Providers.Lyric;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Server
{
@@ -42,8 +39,6 @@ namespace Jellyfin.Server
/// </summary>
public class CoreAppHost : ApplicationHost
{
private readonly IConfiguration _startupConfig;
/// <summary>
/// Initializes a new instance of the <see cref="CoreAppHost" /> class.
/// </summary>
@@ -62,7 +57,6 @@ namespace Jellyfin.Server
options,
startupConfig)
{
_startupConfig = startupConfig;
}
/// <inheritdoc/>
@@ -104,31 +98,6 @@ 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,7 +59,6 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Graylog" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Part 2 Migration for NormalisedUsername.
/// </summary>
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
#pragma warning disable SA1649 // File name should match first type name
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
#pragma warning restore SA1649 // File name should match first type name
{
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
/// </summary>
/// <param name="contextFactory">Db Context factory.</param>
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var user in users)
{
user.NormalizedUsername = user.Username.ToUpperInvariant();
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
}
@@ -7,7 +7,6 @@ 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;
@@ -50,7 +49,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;
}
@@ -181,7 +181,9 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
}
}
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
var attachmentIndexName = attachmentIndex.ToString(CultureInfo.InvariantCulture);
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndexName)
?? _pathManager.GetAttachmentPath(itemIdString, attachmentIndexName)!;
if (File.Exists(newAttachmentPath))
{
File.Delete(oldAttachmentPath);
-5
View File
@@ -24,7 +24,6 @@ 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;
@@ -286,10 +285,6 @@ 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,8 +67,6 @@ 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}"
@@ -96,16 +94,10 @@ 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
@@ -224,10 +216,6 @@ 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
@@ -268,10 +256,6 @@ 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
@@ -280,10 +264,6 @@ 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
@@ -303,7 +283,6 @@ 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}
@@ -315,10 +294,8 @@ 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.7</VersionPrefix>
<VersionPrefix>10.11.10</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent
{
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
/// <inheritdoc />
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{
var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
{
throw new ArgumentException("Path resolved to filename not in log directory");
}
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false))
{
+2 -2
View File
@@ -37,8 +37,8 @@ public interface IPathManager
/// </summary>
/// <param name="mediaSourceId">The media source id.</param>
/// <param name="fileName">The attachmentFileName index.</param>
/// <returns>The absolute path.</returns>
public string GetAttachmentPath(string mediaSourceId, string fileName);
/// <returns>The absolute path, or <c>null</c> if <paramref name="fileName"/> cannot be reduced to a safe leaf name.</returns>
public string? GetAttachmentPath(string mediaSourceId, string fileName);
/// <summary>
/// Gets the path to the attachment folder.
+17 -10
View File
@@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library
/// <summary>
/// Gets the users.
/// </summary>
/// <value>The users.</value>
IEnumerable<User> Users { get; }
/// <returns>The users.</returns>
IEnumerable<User> GetUsers();
/// <summary>
/// Gets the user ids.
/// </summary>
/// <value>The users ids.</value>
IEnumerable<Guid> UsersIds { get; }
/// <returns>The users ids.</returns>
IEnumerable<Guid> GetUsersIds();
/// <summary>
/// Initializes the user manager and ensures that a user exists.
@@ -47,6 +47,12 @@ namespace MediaBrowser.Controller.Library
/// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception>
User? GetUserById(Guid id);
/// <summary>
/// Gets the first available user.
/// </summary>
/// <returns>The first user, or <c>null</c> if no users exist.</returns>
User? GetFirstUser();
/// <summary>
/// Gets the name of the user by.
/// </summary>
@@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library
/// <summary>
/// Renames the user.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="userId">The UserId to change.</param>
/// <param name="oldName">The old Username.</param>
/// <param name="newName">The new name.</param>
/// <returns>Task.</returns>
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
/// <exception cref="ArgumentException">If the provided user doesn't exist.</exception>
Task RenameUser(User user, string newName);
Task RenameUser(Guid userId, string oldName, string newName);
/// <summary>
/// Updates the user.
@@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library
/// <summary>
/// Resets the password.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="userId">The users Id.</param>
/// <returns>Task.</returns>
Task ResetPassword(User user);
Task ResetPassword(Guid userId);
/// <summary>
/// Changes the password.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="userId">The users id.</param>
/// <param name="newPassword">New password to use.</param>
/// <returns>Awaitable task.</returns>
Task ChangePassword(User user, string newPassword);
Task ChangePassword(Guid userId, string newPassword);
/// <summary>
/// Gets the user dto.
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId>
<VersionPrefix>10.11.7</VersionPrefix>
<VersionPrefix>10.11.10</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -413,7 +413,9 @@ namespace MediaBrowser.Controller.MediaEncoding
}
return state.VideoStream.VideoRange == VideoRange.HDR
&& IsDoviWithHdr10Bl(state.VideoStream);
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|| IsHdr10Plus(state.VideoStream)
|| IsDoviWithHdr10Bl(state.VideoStream));
}
private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
@@ -428,8 +430,10 @@ namespace MediaBrowser.Controller.MediaEncoding
// Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding.
// All other HDR formats working.
return state.VideoStream.VideoRange == VideoRange.HDR
&& (IsDoviWithHdr10Bl(state.VideoStream)
|| state.VideoStream.VideoRangeType is VideoRangeType.HLG);
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|| IsHdr10Plus(state.VideoStream)
|| IsDoviWithHdr10Bl(state.VideoStream)
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
}
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
@@ -1301,7 +1305,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(canvasArgs);
}
arg.Append(" -i file:\"").Append(subtitlePath).Append('\"');
arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"');
}
if (state.AudioStream is not null && state.AudioStream.IsExternal)
@@ -1313,7 +1317,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(' ').Append(seekAudioParam);
}
arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"');
arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
}
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
@@ -1574,14 +1578,15 @@ namespace MediaBrowser.Controller.MediaEncoding
int bitrate = state.OutputVideoBitrate.Value;
// Bit rate under 1000k is not allowed in h264_qsv
// Bit rate under 1000k is not allowed in h264_qsv.
if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
{
bitrate = Math.Max(bitrate, 1000);
}
// Currently use the same buffer size for all encoders
int bufsize = bitrate * 2;
// Currently use the same buffer size for all non-QSV encoders.
// Use long arithmetic to prevent int32 overflow for very high bitrate values.
int bufsize = (int)Math.Min((long)bitrate * 2, int.MaxValue);
if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase))
{
@@ -1609,16 +1614,33 @@ namespace MediaBrowser.Controller.MediaEncoding
mbbrcOpt = " -mbbrc 1";
}
// Some less powerful H.264 HW decoders require strict CPB size
// So bufsize optimizations should not be applied to them
int factor = 2;
var codec = state.ActualOutputVideoCodec;
var level = state.GetRequestedLevel(codec);
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)
&& double.TryParse(level, CultureInfo.InvariantCulture, out double requestedLevel)
&& requestedLevel < 51)
{
factor = 1;
}
// Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
// Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {bitrate + 1} -rc_init_occupancy {bitrate * 2} -bufsize {bitrate * 4}");
// Use long arithmetic and clamp to int.MaxValue to prevent int32 overflow
// (e.g. bitrate * 4 wraps to a negative value for bitrates above ~537 million)
int qsvMaxrate = (int)Math.Min((long)bitrate + 1, int.MaxValue);
int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue);
int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue);
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}");
}
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase))
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
{
// Override the too high default qmin 18 in transcoding preset
// Override the too high default qmin 18 in transcoding preset in legacy h26x_amf
return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
}
@@ -1,104 +0,0 @@
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);
}
@@ -1,36 +0,0 @@
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; }
}
@@ -1,49 +0,0 @@
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;
}
@@ -1,49 +0,0 @@
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; }
}
@@ -1,19 +0,0 @@
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;
}
@@ -6,6 +6,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
@@ -98,9 +99,21 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaSourceInfo mediaSource,
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
var hasUnsafeFileName = mediaSource.MediaAttachments.Any(a => !IsSafeBulkAttachmentFileName(a.FileName));
var isMatroskaSubtitles = inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase);
if (hasUnsafeFileName && isMatroskaSubtitles)
{
_logger.LogError(
"Refusing attachment extraction for .mks input {InputFile}: an attachment FileName tag is not a safe leaf name.",
inputFile);
throw new InvalidOperationException(
string.Format(
CultureInfo.InvariantCulture,
"Refusing attachment extraction for .mks input {0}: unsafe attachment FileName tag.",
inputFile));
}
if (hasUnsafeFileName)
{
foreach (var attachment in mediaSource.MediaAttachments)
{
@@ -241,7 +254,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
@@ -341,6 +356,27 @@ namespace MediaBrowser.MediaEncoding.Attachments
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
}
/// <summary>
/// Decides whether an attachment FILENAME tag is safe to feed into ffmpeg's
/// bulk <c>-dump_attachment:t ""</c> mode, which writes each attachment using
/// its filename tag verbatim relative to the working directory. Anything that
/// could escape the working directory - path separators, "..", or empty
/// leaves - is rerouted to the one-by-one path where the filename is
/// sanitised via <see cref="IPathManager.GetAttachmentPath"/>.
/// </summary>
private static bool IsSafeBulkAttachmentFileName(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return true;
}
// PathHelper.GetSafeLeafFileName collapses to the leaf component;
// the bulk path is only safe when the supplied name already _was_
// that leaf (no separators, no "."/"..").
return PathHelper.GetSafeLeafFileName(fileName) == fileName;
}
/// <inheritdoc />
public void Dispose()
{
@@ -19,6 +19,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
@@ -372,7 +373,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
CreateNoWindow = true,
UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath,
Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)),
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
@@ -24,8 +24,6 @@ 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
@@ -123,20 +121,6 @@ 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.7</VersionPrefix>
<VersionPrefix>10.11.10</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
+2 -2
View File
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{
savePaths.Add(mediaFolderPath);
}
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{
savePaths.Add(internalPath);
}
@@ -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}");
}
+67 -535
View File
@@ -1,551 +1,82 @@
# 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)
<h1 align="center">Jellyfin</h1>
<h3 align="center">The Free Software Media System</h3>
---
## 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`)
<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>
---
## Architecture
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.
```
┌─────────────┐ ┌─────────────┐
│ Jellyfin │ │ Jellyfin │
│ Pod A │ │ Pod B │
│ │ │ │
│ ┌─────────┐ │ │ ┌─────────┐ │
│ │Transcode│ │ │ │Transcode│ │
│ │Manager │ │ │ │Manager │ │
│ └────┬────┘ │ │ └────┬────┘ │
└──────┼──────┘ └──────┼──────┘
│ │
└─────────┬─────────┘
┌───────▼───────┐
│ Redis │ ← ITranscodeSessionStore
│ (lease store)│ TTL-based ownership
└───────────────┘
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!
┌─────────────────────┐
│ Shared NAS / NFS │ ← HLS segments + manifests
│ (shared storage) │
└─────────────────────┘
```
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).
**How takeover works:**
<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/>
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.
<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>
---
## Quick Start
## Jellyfin Server
### Single instance (no Redis)
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.
No configuration required. `NullTranscodeSessionStore` is used automatically. Behavior is identical to upstream Jellyfin.
## Server Development
```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
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.
### Prerequisites
- [.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.*
---
Before the project can be built, you must first install the [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system.
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).
@@ -563,12 +94,13 @@ 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 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.
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.
There are two options to get the files for the web client.
There are three options to get the files for the web client.
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`
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`
### Running The Server
@@ -601,7 +133,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/net10.0 # Change into the build output directory
cd Jellyfin.Server/bin/Debug/net9.0 # Change into the build output directory
```
2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`.
@@ -666,5 +198,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/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/fa104b7d73f759d7262794b94569f1b89df41c0b/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
</p>
+2 -2
View File
@@ -1,4 +1,4 @@
using System.Reflection;
[assembly: AssemblyVersion("10.11.7")]
[assembly: AssemblyFileVersion("10.11.7")]
[assembly: AssemblyVersion("10.11.10")]
[assembly: AssemblyFileVersion("10.11.10")]
-22
View File
@@ -1,22 +0,0 @@
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
@@ -1,49 +0,0 @@
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 }}
@@ -1,137 +0,0 @@
{{/*
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 }}
@@ -1,15 +0,0 @@
{{- 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 }}
@@ -1,97 +0,0 @@
{{- 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 }}
@@ -1,14 +0,0 @@
{{- 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 }}
@@ -1,20 +0,0 @@
{{- 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 }}
@@ -1,21 +0,0 @@
{{- 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 }}
@@ -1,84 +0,0 @@
{{- 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 }}
@@ -1,90 +0,0 @@
{{- 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 }}
@@ -1,15 +0,0 @@
{{- 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 }}
@@ -1,58 +0,0 @@
{{- 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 }}
@@ -1,21 +0,0 @@
{{- 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 }}
@@ -1,20 +0,0 @@
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
@@ -1,27 +0,0 @@
{{- 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 }}
@@ -1,277 +0,0 @@
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
@@ -1,405 +0,0 @@
# 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
@@ -1,202 +0,0 @@
> **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
@@ -1,164 +0,0 @@
> **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
@@ -1,221 +0,0 @@
# 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
@@ -1,83 +0,0 @@
# 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
@@ -1,473 +0,0 @@
# 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.
@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
Username = username;
NormalizedUsername = username.ToUpperInvariant();
AuthenticationProviderId = authenticationProviderId;
PasswordResetProviderId = passwordResetProviderId;
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
[StringLength(255)]
public string Username { get; set; }
/// <summary>
/// Gets or sets the user's normalized name.
/// </summary>
/// <remarks>
/// Required, Max length = 255.
/// </remarks>
[MaxLength(255)]
[StringLength(255)]
public string NormalizedUsername { get; set; }
/// <summary>
/// Gets or sets the user's password, or <c>null</c> if none is set.
/// </summary>
@@ -268,6 +268,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
}).ConfigureAwait(false);
return result;
}
catch (DbUpdateConcurrencyException)
{
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
throw;
}
catch (Exception e)
{
logger.LogError(e, "Error trying to save changes.");
@@ -289,6 +294,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
});
return result;
}
catch (DbUpdateConcurrencyException)
{
// a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat.
throw;
}
catch (Exception e)
{
logger.LogError(e, "Error trying to save changes.");
@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
builder
.HasIndex(entity => entity.Username)
.IsUnique();
builder
.HasIndex(entity => entity.NormalizedUsername)
.IsUnique();
}
}
}
@@ -1,31 +0,0 @@
<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>
@@ -1,112 +0,0 @@
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);
}
}
}
@@ -1,42 +0,0 @@
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));
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddNormalizedUsername : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// this is the first part of the migration. Add the column.
migrationBuilder.AddColumn<string>(
name: "NormalizedUsername",
table: "Users",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NormalizedUsername",
table: "Users");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddUniqueNormalizedUsernameIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Users_NormalizedUsername",
table: "Users",
column: "NormalizedUsername",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_NormalizedUsername",
table: "Users");
}
}
}
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -1303,6 +1303,11 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<bool>("MustUpdatePassword")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(65535)
.HasColumnType("TEXT");
@@ -1345,6 +1350,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
@@ -15,7 +15,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId>
<VersionPrefix>10.11.7</VersionPrefix>
<VersionPrefix>10.11.10</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
+77
View File
@@ -0,0 +1,77 @@
using System;
using System.IO;
namespace Jellyfin.Extensions;
/// <summary>
/// Helpers for safely composing filesystem paths from untrusted input.
/// </summary>
/// <remarks>
/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
/// any code that joins a trusted directory with an externally-supplied name:
/// it neither normalises <c>..</c> nor rejects a rooted second argument
/// (a rooted second arg silently discards the first). Use the helpers below
/// any time the name comes from media metadata, request input, archive
/// entries, or any other channel that can be influenced by a third party.
/// </remarks>
public static class PathHelper
{
/// <summary>
/// Reduces a possibly-untrusted file name to a safe leaf-only name with no
/// directory components.
/// </summary>
/// <param name="fileName">The candidate file name.</param>
/// <returns>
/// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
/// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
/// </returns>
public static string? GetSafeLeafFileName(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return null;
}
var leaf = Path.GetFileName(fileName);
if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..")
{
return null;
}
return leaf;
}
/// <summary>
/// Returns whether <paramref name="candidate"/> resolves to a path that
/// equals or is contained inside <paramref name="root"/>.
/// </summary>
/// <param name="root">The directory the candidate must remain inside.</param>
/// <param name="candidate">The candidate absolute or relative path.</param>
/// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
/// <remarks>
/// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
/// so <c>..</c> segments are collapsed before the comparison. The root is
/// compared with a trailing directory separator to prevent prefix
/// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
/// <c>/var/dataset</c>).
/// </remarks>
public static bool IsContainedIn(string root, string candidate)
{
ArgumentException.ThrowIfNullOrEmpty(root);
ArgumentException.ThrowIfNullOrEmpty(candidate);
var fullRoot = Path.GetFullPath(root);
var fullCandidate = Path.GetFullPath(candidate);
if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
{
return true;
}
var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
? fullRoot
: fullRoot + Path.DirectorySeparatorChar;
return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
}
}
+2 -2
View File
@@ -1204,7 +1204,7 @@ namespace Jellyfin.LiveTv
{
Services = services,
IsEnabled = services.Length > 0,
EnabledUsers = _userManager.Users
EnabledUsers = _userManager.GetUsers()
.Where(IsLiveTvEnabled)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.ToArray()
@@ -1220,7 +1220,7 @@ namespace Jellyfin.LiveTv
public IEnumerable<User> GetEnabledUsers()
{
return _userManager.Users
return _userManager.GetUsers()
.Where(IsLiveTvEnabled);
}
@@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Recordings
private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
{
var users = _userManager.Users
var users = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess))
.Select(i => i.Id)
.ToList();
@@ -1,259 +0,0 @@
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,
};
}
}
}
@@ -1,223 +0,0 @@
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,44 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Controller.ClientEvent;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests
{
public class ClientEventLoggerTests
{
[Theory]
[InlineData("../../../../etc/passwd", "1.0")]
[InlineData("..\\..\\windows\\system32", "1.0")]
[InlineData("normal-client", "../../../etc/passwd")]
[InlineData("/absolute/path", "1.0")]
public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
{
var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
Directory.CreateDirectory(logDir);
try
{
var paths = new Mock<IServerApplicationPaths>();
paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
var logger = new ClientEventLogger(paths.Object);
using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
Assert.True(File.Exists(resolved));
}
finally
{
Directory.Delete(logDir, recursive: true);
}
}
}
}
@@ -1,25 +0,0 @@
<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>
@@ -1,126 +0,0 @@
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));
}
}
@@ -1,99 +0,0 @@
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));
}
}
@@ -1,336 +0,0 @@
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);
}
}
@@ -1,169 +0,0 @@
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,
};
}
@@ -1,167 +0,0 @@
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);
}
}
@@ -1,384 +0,0 @@
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,
};
}
}
@@ -1,429 +0,0 @@
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,7 +8,6 @@ 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;
@@ -37,8 +36,7 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>(),
Mock.Of<ITranscodeSessionStore>());
Mock.Of<IHostApplicationLifetime>());
await Assert.ThrowsAsync(exceptionType, () => sessionManager.GetAuthorizationToken(
new User("test", "default", "default"),
@@ -65,8 +63,7 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>(),
Mock.Of<ITranscodeSessionStore>());
Mock.Of<IHostApplicationLifetime>());
await Assert.ThrowsAsync(exceptionType, () => sessionManager.AuthenticateNewSessionInternal(authenticationRequest, false));
}
@@ -0,0 +1,240 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public sealed class UserManagerNormalizedUsernameTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly UserManager _userManager;
public UserManagerNormalizedUsernameTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
// Create the schema
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateDbContext);
var cryptoProvider = new Mock<ICryptoProvider>();
var configManager = new Mock<IServerConfigurationManager>();
var appPaths = new Mock<IServerApplicationPaths>();
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
var appHost = new Mock<IApplicationHost>();
var defaultAuthProvider = new DefaultAuthenticationProvider(
NullLogger<DefaultAuthenticationProvider>.Instance,
cryptoProvider.Object);
var invalidAuthProvider = new InvalidAuthProvider();
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
configManager.Object,
appHost.Object);
_userManager = new UserManager(
factory.Object,
new NoopEventManager(),
new Mock<INetworkManager>().Object,
appHost.Object,
new Mock<IImageProcessor>().Object,
NullLogger<UserManager>.Instance,
configManager.Object,
new IPasswordResetProvider[] { defaultPasswordResetProvider },
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
}
public void Dispose()
{
_userManager.Dispose();
_connection.Dispose();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
// ----- GetUserByName tests -----
[Theory]
// German umlauts
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n
[InlineData("Ñoño", "ÑOÑO")]
// ASCII, invariant uppercase lookup
[InlineData("jellyfin", "JELLYFIN")]
// Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
[InlineData("Çelebi", "ÇELEBI")]
public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
string username, string normalizedLookup)
{
await _userManager.CreateUserAsync(username);
var found = _userManager.GetUserByName(normalizedLookup);
Assert.NotNull(found);
Assert.Equal(username, found.Username);
}
[Theory]
// German umlaut, look up by both upper and lower case
[InlineData("münchen")]
// Spanish tilde-n
[InlineData("Ñoño")]
// lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
[InlineData("ali")]
// mixed ASCII + umlaut
[InlineData("testüser")]
public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
{
await _userManager.CreateUserAsync(username);
var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
var exactFound = _userManager.GetUserByName(username);
Assert.NotNull(upperFound);
Assert.NotNull(lowerFound);
Assert.NotNull(exactFound);
}
[Theory]
[InlineData("nonexistent")]
// No user with NormalizedUsername = "MÜNCHEN" has been created
[InlineData("MÜNCHEN")]
public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
{
var result = _userManager.GetUserByName(lookupName);
Assert.Null(result);
}
// ----- CreateUserAsync duplicate detection tests -----
[Theory]
// German umlaut, case-swapped duplicate
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n, lowercase duplicate
[InlineData("Ñoño", "ñoño")]
// ASCII, uppercase duplicate
[InlineData("alice", "ALICE")]
// Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
[InlineData("çelebi", "ÇELEBI")]
public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
string existingUsername, string duplicateUsername)
{
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.CreateUserAsync(duplicateUsername));
}
[Theory]
// Different non-ASCII names that do not collide after normalization
[InlineData("münchen", "münchen2")]
[InlineData("ali", "ali2")]
// Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
[InlineData("noño", "nono")]
public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
string firstUsername, string secondUsername)
{
var first = await _userManager.CreateUserAsync(firstUsername);
var second = await _userManager.CreateUserAsync(secondUsername);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.NotEqual(first.Id, second.Id);
}
// ----- RenameUser tests -----
[Theory]
// Rename to non-ASCII name
[InlineData("alice", "münchen")]
// Rename between similar non-ASCII and ASCII
[InlineData("müller", "mueller")]
// Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
[InlineData("ali", "ALI2")]
// Rename to Spanish tilde-n name
[InlineData("testuser", "Ñoño")]
public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
string originalName, string newName)
{
var user = await _userManager.CreateUserAsync(originalName);
await _userManager.RenameUser(user.Id, originalName, newName);
var renamed = _userManager.GetUserById(user.Id);
Assert.NotNull(renamed);
Assert.Equal(newName, renamed.Username);
Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
}
[Theory]
// Same name different case: NormalizedUsername already taken
[InlineData("münchen", "MÜNCHEN")]
// Spanish, lowercase conflicts with existing uppercase-normalised entry
[InlineData("Ñoño", "ñoño")]
// ASCII, capitalised conflict
[InlineData("alice", "Alice")]
// Mixed ASCII + umlaut
[InlineData("testüser", "TESTÜSER")]
public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
string existingUsername, string conflictingNewName)
{
var targetUser = await _userManager.CreateUserAsync("renametarget");
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
}
private sealed class NoopEventManager : IEventManager
{
public void Publish<T>(T eventArgs)
where T : EventArgs
{
}
public Task PublishAsync<T>(T eventArgs)
where T : EventArgs
=> Task.CompletedTask;
}
}
}

Some files were not shown because too many files have changed in this diff Show More