Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c62d40f0d | |||
| dca3cc74b7 | |||
| be095f85ab | |||
| f51c63e244 | |||
| cc678383c9 | |||
| ba0720a555 | |||
| 417df3df57 | |||
| 169e48ac00 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<VersionPrefix>10.11.8</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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Data</PackageId>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<VersionPrefix>10.11.8</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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.8</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Controller</PackageId>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<VersionPrefix>10.11.8</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 1–6.
|
||||
/// </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 2–10.
|
||||
/// </summary>
|
||||
public int RecoverySegmentBufferCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hardware acceleration type.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Model</PackageId>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<VersionPrefix>10.11.8</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -239,7 +239,7 @@ namespace MediaBrowser.Providers.Subtitles
|
||||
|
||||
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
|
||||
{
|
||||
if (!_allowedSubtitleFormats.Contains("." + extension, StringComparison.OrdinalIgnoreCase))
|
||||
if (!_allowedSubtitleFormats.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Invalid subtitle format: {extension}");
|
||||
}
|
||||
|
||||
@@ -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.**
|
||||
|
||||
[](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)
|
||||
[](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
[](https://github.com/jellyfin/jellyfin)
|
||||
<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>
|
||||
|
||||
<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
@@ -1,4 +1,4 @@
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: AssemblyVersion("10.11.7")]
|
||||
[assembly: AssemblyFileVersion("10.11.7")]
|
||||
[assembly: AssemblyVersion("10.11.8")]
|
||||
[assembly: AssemblyFileVersion("10.11.8")]
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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**: 1–5 sentence summary
|
||||
- **Issues**: tag with `Fixes #NNN`
|
||||
4. CI runs automatically:
|
||||
- `ci-tests.yml` — tests on Linux, macOS, Windows
|
||||
- `ci-openapi.yml` — OpenAPI diff
|
||||
- `ci-codeql-analysis.yml` — security scan
|
||||
5. A maintainer will review and merge
|
||||
|
||||
### Title format
|
||||
|
||||
Use the imperative mood:
|
||||
- ✅ `Add lyrics endpoint for audio items`
|
||||
- ✅ `Fix null reference in LibraryController`
|
||||
- ❌ `Added lyrics endpoint`
|
||||
- ❌ `Fixed null reference`
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Package Dependency
|
||||
|
||||
1. Add the version to `Directory.Packages.props`:
|
||||
```xml
|
||||
<PackageVersion Include="SomePackage" Version="1.2.3" />
|
||||
```
|
||||
2. Add the reference to the relevant `.csproj` (no `Version=` attribute):
|
||||
```xml
|
||||
<PackageReference Include="SomePackage" />
|
||||
```
|
||||
|
||||
**Never** specify both a version in `Directory.Packages.props` AND in the `.csproj` — that causes `NU1008`.
|
||||
|
||||
---
|
||||
|
||||
## Project Conventions
|
||||
|
||||
See `.github/instructions/` for detailed instructions per concern:
|
||||
|
||||
| Topic | File |
|
||||
|---|---|
|
||||
| C# style | `csharp.instructions.md` |
|
||||
| API controllers | `api.instructions.md` |
|
||||
| Tests | `testing.instructions.md` |
|
||||
| CI/CD workflows | `ci-cd.instructions.md` |
|
||||
| Documentation | `docs.instructions.md` |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 20–120s depending on server configuration)
|
||||
|
||||
### Implications for Segment Files
|
||||
|
||||
FFmpeg writes `.ts` files sequentially. A typical write pattern:
|
||||
|
||||
1. Open `segment_N.ts` for write
|
||||
2. Write video/audio data (2–4 MB for a 2–4s segment)
|
||||
3. Close and rename/flush
|
||||
|
||||
If the pod dies **mid-write** of `segment_N.ts`:
|
||||
|
||||
- The file may be 0 bytes, partially filled, or have a corrupted end
|
||||
- NFSv3 does **not** guarantee close-to-open consistency for concurrent readers
|
||||
— another pod may see a stale cached version or a partial file
|
||||
- The NFS server releases the lock within seconds to minutes, but the file
|
||||
content is not rolled back
|
||||
|
||||
**Recovery rule (must implement in Phase 5.2)**:
|
||||
|
||||
> When resuming from a manifest on shared storage, identify the last `.ts` segment
|
||||
> that appears in the `.m3u8` `#EXTINF` entries AND is non-zero in size AND has a
|
||||
> stable mtime (not being written). Restart FFmpeg from **one segment before** that
|
||||
> point to ensure the last segment is re-written cleanly.
|
||||
|
||||
This is analogous to the WAL recovery principle: never trust the last write from a
|
||||
crashed writer.
|
||||
|
||||
### NFS Lock Hold-Up on Active Pod
|
||||
|
||||
When a Jellyfin pod has an open file handle on the NFS mount and the NAS becomes
|
||||
unreachable, NFSv3 with `hard` mount option (confirmed in existing PVs) will block
|
||||
I/O indefinitely — the pod will not crash, but it will stall. This is the correct
|
||||
behavior for transcode recovery: FFmpeg stalls rather than emitting corrupt segments.
|
||||
Test this in Issue 5.1.2 NAS outage test.
|
||||
|
||||
---
|
||||
|
||||
## Minimum Recovery State
|
||||
|
||||
For a takeover pod to resume an orphaned transcode session, the following minimum
|
||||
state must be durably stored (Phase 5.2.1):
|
||||
|
||||
| Field | Source | Why Needed |
|
||||
|---|---|---|
|
||||
| `sessionId` | server-generated UUID | Stable correlation key; not client-supplied |
|
||||
| `playSessionId` | client-supplied (may be null) | Needed to match kill-timer pings |
|
||||
| `ownerPod` | k8s `POD_NAME` env var | Identify which pod is current owner |
|
||||
| `manifestPath` | `OutputFilePath` with `.m3u8` extension | Entry point for takeover pod |
|
||||
| `segmentPathPrefix` | derived from `manifestPath` directory | Find `.ts` files |
|
||||
| `mediaSourceId` | `StreamState.MediaSource.Id` | Re-open the same stream |
|
||||
| `itemId` | `StreamState.Request.ItemId` | Re-construct `StreamingRequestDto` |
|
||||
| `encodingParams` | serialized subset of `StreamState` | Restart FFmpeg with identical params |
|
||||
| `lastHeartbeatUtc` | updated by owner pod on segment write | Orphan detection: > 120s = orphaned |
|
||||
| `lastCompletedSegmentIndex` | updated on each segment flush | Recovery knows where to seek |
|
||||
| `deviceId` | `StreamState.Request.DeviceId` | Kill-job scope on cleanup |
|
||||
|
||||
---
|
||||
|
||||
## HA Failure Scenario Walk-Through
|
||||
|
||||
### Scenario: Pod A dies mid-transcode, Pod B receives next segment request
|
||||
|
||||
```
|
||||
Pod A (owner) Redis (durable store) Pod B (takeover)
|
||||
| | |
|
||||
| write sessionKey → Redis | |
|
||||
|-------------------------------->| |
|
||||
| | |
|
||||
| heartbeat every 30s | |
|
||||
|-------------------------------->| |
|
||||
| | |
|
||||
DIES (OOMKill / node drain) | |
|
||||
| GET segment_N+1.ts
|
||||
|<------------------------|
|
||||
| session key exists |
|
||||
| lastHeartbeat > 120s ago
|
||||
| ownerPod != me |
|
||||
| |
|
||||
[today, WITHOUT Phase 5.2]: |
|
||||
| |
|
||||
| _activeTranscodingJobs is empty on Pod B
|
||||
| OnTranscodeBeginRequest() → null
|
||||
| No ffmpeg started
|
||||
| Client receives stale m3u8, then 404s on segment
|
||||
| Playback stalls indefinitely
|
||||
| |
|
||||
[with Phase 5.2]: |
|
||||
| |
|
||||
| CAS: set ownerPod = pod-B |
|
||||
|<------------------------|
|
||||
| |
|
||||
| recover from segment_N-1 |
|
||||
| StartFfMpeg(resumeFrom=N-1)
|
||||
|<------------------------|
|
||||
| |
|
||||
| client resumes from segment N-1 (~4s rewind)
|
||||
```
|
||||
|
||||
### Current State (Without Phase 5.2)
|
||||
|
||||
1. Client sends `GET .../segment_100.ts` to pod B (Traefik sticky session cookie
|
||||
`jellyfin-server-id` routes to pod B because pod A is gone)
|
||||
2. Pod B calls `GetStreamingState()` → computes same `OutputFilePath` (deterministic hash)
|
||||
3. Pod B calls `File.Exists(playlistPath)` → **true** (file exists on NFS from pod A)
|
||||
4. Pod B calls `OnTranscodeBeginRequest(playlistPath, HLS)` → **null** (no job in pod B's `_activeTranscodingJobs`)
|
||||
5. `job is null` → `OnTranscodeEndRequest` not called, no ping, no FFmpeg restart
|
||||
6. Pod B reads and returns the existing `.m3u8` from disk
|
||||
7. Client requests segment 100 → pod B tries to serve `segment_100.ts`
|
||||
- If the file exists and is complete: **success** (but no new segments will be produced)
|
||||
- If the file does not exist yet (pod A was mid-write): **404**, client stalls
|
||||
|
||||
Without Phase 5.2, the transcode stream terminates on pod death. No recovery happens
|
||||
automatically. The client must re-initiate playback from the beginning or from a
|
||||
seek point.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Before Phase 5.2
|
||||
|
||||
| # | Question | Who Answers | When |
|
||||
|---|---|---|---|
|
||||
| Q1 | What is the actual `leasetime` configured on the Ugreen DXP4800 NFS server? (default 90s, but UGOS Pro may differ) | Issue 5.1.2 benchmark pod | 5.1.2 |
|
||||
| Q2 | Does the NFS mount use `nfsvers=3` exclusively, or does UGOS Pro negotiate v4 when requested? | `nfsstat -m` in test pod | 5.1.2 |
|
||||
| Q3 | What is the minimum HLS segment duration in practice? (affects recovery seek distance) | FFmpeg log inspection | 5.1.1 follow-on |
|
||||
| Q4 | Does the Jellyfin web client re-supply a stable `playSessionId` on reconnect, or generate a new one? | Client code inspection | 5.2.2a |
|
||||
| Q5 | Does `StackExchange.Redis` in the fork use connection multiplexing that survives pod address changes? | 5.2.1a implementation | 5.2.1a |
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- [jellyfin-ha-plan.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-plan.md) — overall HA plan and phase structure
|
||||
- [jellyfin-ha-phase5-transcoding.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-phase5-transcoding.md) — Phase 5 issue list, rollback matrix, Go/No-Go preconditions
|
||||
- [jellyfin-ha-failover-test.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-failover-test.md) — SLO baselines, failover test procedures
|
||||
- [ci-cd.md](../home_k3s_cluster/docs/ci-cd.md) — Phase 5 CI/CD paths
|
||||
- `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs` — `_activeTranscodingJobs`, `StartFfMpeg()`, `KillTranscodingJob()`
|
||||
- `Jellyfin.Api/Controllers/DynamicHlsController.cs` — `GetLiveHlsStream()`, segment lookup
|
||||
- `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` — age-only cleanup
|
||||
- `Emby.Server.Implementations/Session/SessionManager.cs` — `_activeLiveStreamSessions`
|
||||
- `kubernetes/apps/media/nfs-pv.yaml` — `nfsvers=3` confirmed
|
||||
|
||||
---
|
||||
|
||||
## Bitrate/Segment Tradeoffs
|
||||
|
||||
### Why shorter segments trade throughput for faster failover
|
||||
|
||||
HLS streaming works by dividing a media stream into a series of short, independently decodable
|
||||
segments. The segment length is a fundamental trade-off: longer segments reduce per-segment HTTP
|
||||
overhead and allow FFmpeg to apply more aggressive compression across each chunk, improving overall
|
||||
bitrate efficiency. Shorter segments, however, mean that when a pod fails mid-transcode, a takeover
|
||||
pod only needs to rewind to the previous segment boundary — not the start of a much longer one.
|
||||
With the default 6-second segment length, a client could stall for up to 6 seconds before the
|
||||
takeover pod produces a new segment for it to consume. With the HA recovery default of 2 seconds
|
||||
(`RecoverySegmentLengthSeconds = 2`), that stall window is reduced to at most 2 seconds of rewind,
|
||||
dramatically improving the perceived continuity of playback during a pod failover.
|
||||
|
||||
### The rolling segment buffer and disk usage
|
||||
|
||||
In HA mode, `RecoverySegmentBufferCount` (default `5`) controls how many segments are retained in
|
||||
the HLS playlist at any one time. This creates a rolling on-disk buffer of `5 × 2 s = 10 seconds`
|
||||
of media that a takeover pod can serve immediately while it restarts FFmpeg from the last known
|
||||
position. Keeping fewer segments wastes less NFS storage but shrinks the window in which a newly
|
||||
promoted pod can respond to in-flight client requests without waiting for new segments to be
|
||||
produced. Keeping more segments lengthens the recovery window but increases NFS write pressure and
|
||||
disk usage proportionally. The valid range (2–10) was chosen so that the minimum buffer is always
|
||||
at least 4 seconds (2 × 2 s) and the maximum stays under 20 seconds (10 × 2 s), balancing storage
|
||||
cost against recovery robustness.
|
||||
|
||||
### Tuning guidance and rollback
|
||||
|
||||
The two knobs, `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount`, can be adjusted in
|
||||
the Jellyfin server's encoding options without restarting the service; the new values take effect on
|
||||
the next transcode session that enters HA mode. To reduce disk I/O at the cost of a slightly longer
|
||||
stall window, increase `RecoverySegmentLengthSeconds` toward its maximum of 6 (matching the
|
||||
throughput-optimized default). To shrink the NFS footprint at the cost of a narrower recovery
|
||||
window, lower `RecoverySegmentBufferCount` toward its minimum of 2. To roll back to the
|
||||
pre-HA-mode behavior entirely, set `RecoverySegmentLengthSeconds = 6` and ensure that no active
|
||||
session is registered in the `ITranscodeSessionStore` (which disables HA mode detection in
|
||||
`DynamicHlsController`). All changes are backwards-compatible: in single-pod deployments where the
|
||||
store is a no-op, these settings have no effect on the FFmpeg command generated.
|
||||
-31
@@ -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>
|
||||
-1690
File diff suppressed because it is too large
Load Diff
-1146
File diff suppressed because it is too large
Load Diff
-1687
File diff suppressed because it is too large
Load Diff
-112
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-42
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Extensions</PackageId>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<VersionPrefix>10.11.8</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
-384
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
-429
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>Jellyfin.DbMigrator</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="AWSSDK.S3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.DbMigrator;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the migration result for a single table.
|
||||
/// </summary>
|
||||
/// <param name="TableName">The name of the table.</param>
|
||||
/// <param name="SqliteRowCount">The number of rows read from SQLite.</param>
|
||||
/// <param name="PostgresRowCount">The number of rows verified in PostgreSQL after migration.</param>
|
||||
/// <param name="Error">The error message if migration failed, or <see langword="null"/> on success.</param>
|
||||
public sealed record TableReport(
|
||||
string TableName,
|
||||
long SqliteRowCount,
|
||||
long PostgresRowCount,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Provides utilities for collecting and printing the migration report.
|
||||
/// </summary>
|
||||
public static class MigrationReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Prints a formatted summary of per-table migration results to the console.
|
||||
/// </summary>
|
||||
/// <param name="reports">The collection of per-table results.</param>
|
||||
public static void Print(IReadOnlyList<TableReport> reports)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Migration Report ===");
|
||||
Console.WriteLine(
|
||||
$"{"Table",-40} {"SQLite",10} {"PostgreSQL",10} {"Status",-10}");
|
||||
Console.WriteLine(new string('-', 74));
|
||||
|
||||
int failed = 0;
|
||||
foreach (var r in reports)
|
||||
{
|
||||
string status = r.Error is null ? "OK" : "FAILED";
|
||||
if (r.Error is not null)
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"{r.TableName,-40} {r.SqliteRowCount,10} {r.PostgresRowCount,10} {status,-10}");
|
||||
|
||||
if (r.Error is not null)
|
||||
{
|
||||
Console.WriteLine($" Error: {r.Error}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine(new string('-', 74));
|
||||
Console.WriteLine(
|
||||
$"Total: {reports.Count} tables, {failed} failed, {reports.Count - failed} succeeded.");
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Npgsql;
|
||||
|
||||
namespace Jellyfin.DbMigrator;
|
||||
|
||||
/// <summary>
|
||||
/// Writes rows to a PostgreSQL database using batched INSERT statements.
|
||||
/// </summary>
|
||||
public static class PostgresBulkWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// The maximum number of rows per INSERT batch.
|
||||
/// </summary>
|
||||
private const int BatchSize = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Inserts all rows into the specified PostgreSQL table using batched INSERT statements.
|
||||
/// When <paramref name="isDryRun"/> is <see langword="true"/>, logs what would be inserted without writing.
|
||||
/// </summary>
|
||||
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
|
||||
/// <param name="tableName">The name of the target PostgreSQL table.</param>
|
||||
/// <param name="rows">The rows to insert, as dictionaries mapping column name to value.</param>
|
||||
/// <param name="isDryRun">When <see langword="true"/>, skips actual writes.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>The number of rows that were inserted (or would have been inserted in dry-run mode).</returns>
|
||||
public static async Task<long> WriteTableAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tableName,
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> rows,
|
||||
bool isDryRun,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
TableNameValidator.EnsureSafe(tableName);
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
|
||||
// Collect column names from the first row.
|
||||
var columns = new List<string>(rows[0].Keys);
|
||||
|
||||
if (isDryRun)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" [dry-run] Would insert {rows.Count} rows into \"{tableName}\" " +
|
||||
$"({string.Join(", ", columns)}).");
|
||||
return rows.Count;
|
||||
}
|
||||
|
||||
long inserted = 0L;
|
||||
|
||||
for (int offset = 0; offset < rows.Count; offset += BatchSize)
|
||||
{
|
||||
int end = Math.Min(offset + BatchSize, rows.Count);
|
||||
int batchCount = end - offset;
|
||||
|
||||
var sql = BuildInsertSql(tableName, columns, batchCount);
|
||||
|
||||
var cmd = connection.CreateCommand();
|
||||
await using (cmd.ConfigureAwait(false))
|
||||
{
|
||||
cmd.CommandText = sql;
|
||||
|
||||
int paramIndex = 0;
|
||||
for (int rowIdx = offset; rowIdx < end; rowIdx++)
|
||||
{
|
||||
var row = rows[rowIdx];
|
||||
foreach (var col in columns)
|
||||
{
|
||||
string paramName = $"p{paramIndex.ToString(CultureInfo.InvariantCulture)}";
|
||||
row.TryGetValue(col, out object? val);
|
||||
cmd.Parameters.AddWithValue(paramName, val ?? DBNull.Value);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
inserted += batchCount;
|
||||
}
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the PostgreSQL integer sequence for each table that contains an <c>Id</c> column,
|
||||
/// so that future auto-generated primary keys do not conflict with migrated data.
|
||||
/// </summary>
|
||||
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
|
||||
/// <param name="tableNames">The names of the tables whose sequences should be advanced.</param>
|
||||
/// <param name="isDryRun">When <see langword="true"/>, logs the SQL without executing it.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
public static async Task AdvanceSequencesAsync(
|
||||
NpgsqlConnection connection,
|
||||
IEnumerable<string> tableNames,
|
||||
bool isDryRun,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(tableNames);
|
||||
|
||||
foreach (var tableName in tableNames)
|
||||
{
|
||||
// Check if the table has an "Id" column.
|
||||
bool hasIdColumn = await TableHasColumnAsync(
|
||||
connection, tableName, "Id", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!hasIdColumn)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string sql =
|
||||
$"SELECT setval(pg_get_serial_sequence('{tableName}', 'Id'), " +
|
||||
$"COALESCE((SELECT MAX(\"Id\") FROM \"{tableName}\"), 1))";
|
||||
|
||||
if (isDryRun)
|
||||
{
|
||||
Console.WriteLine($" [dry-run] Would advance sequence: {sql}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var cmd = connection.CreateCommand();
|
||||
await using (cmd.ConfigureAwait(false))
|
||||
{
|
||||
cmd.CommandText = sql;
|
||||
try
|
||||
{
|
||||
await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Sequence may not exist for tables without serial PK — log and continue.
|
||||
Console.WriteLine(
|
||||
$" Warning: Could not advance sequence for \"{tableName}\": {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of rows currently in the specified PostgreSQL table.
|
||||
/// </summary>
|
||||
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
|
||||
/// <param name="tableName">The name of the table to count.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>The row count, or -1 if the table does not exist.</returns>
|
||||
public static async Task<long> CountRowsAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
TableNameValidator.EnsureSafe(tableName);
|
||||
|
||||
var cmd = connection.CreateCommand();
|
||||
await using (cmd.ConfigureAwait(false))
|
||||
{
|
||||
cmd.CommandText = $"SELECT COUNT(*) FROM \"{tableName}\"";
|
||||
try
|
||||
{
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is long count ? count : Convert.ToInt64(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (NpgsqlException)
|
||||
{
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a parameterised bulk INSERT SQL statement for the given table, columns, and row count.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The target table name.</param>
|
||||
/// <param name="columns">The ordered list of column names.</param>
|
||||
/// <param name="rowCount">The number of value-rows to include.</param>
|
||||
/// <returns>A parameterised INSERT statement.</returns>
|
||||
private static string BuildInsertSql(string tableName, IReadOnlyList<string> columns, int rowCount)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CultureInfo.InvariantCulture, $"INSERT INTO \"{tableName}\" (");
|
||||
|
||||
for (int i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
sb.Append(CultureInfo.InvariantCulture, $"\"{columns[i]}\"");
|
||||
}
|
||||
|
||||
sb.Append(") VALUES ");
|
||||
|
||||
int paramIndex = 0;
|
||||
for (int row = 0; row < rowCount; row++)
|
||||
{
|
||||
if (row > 0)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
sb.Append('(');
|
||||
for (int col = 0; col < columns.Count; col++)
|
||||
{
|
||||
if (col > 0)
|
||||
{
|
||||
sb.Append(", ");
|
||||
}
|
||||
|
||||
sb.Append(CultureInfo.InvariantCulture, $"@p{paramIndex.ToString(CultureInfo.InvariantCulture)}");
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
sb.Append(')');
|
||||
}
|
||||
|
||||
sb.Append(" ON CONFLICT DO NOTHING");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a given column exists in a PostgreSQL table.
|
||||
/// </summary>
|
||||
/// <param name="connection">An open <see cref="NpgsqlConnection"/>.</param>
|
||||
/// <param name="tableName">The table name to check.</param>
|
||||
/// <param name="columnName">The column name to look for.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns><see langword="true"/> if the column exists; otherwise, <see langword="false"/>.</returns>
|
||||
private static async Task<bool> TableHasColumnAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tableName,
|
||||
string columnName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cmd = connection.CreateCommand();
|
||||
await using (cmd.ConfigureAwait(false))
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT COUNT(*) FROM information_schema.columns " +
|
||||
"WHERE table_name = @table AND column_name = @col";
|
||||
cmd.Parameters.AddWithValue("table", tableName);
|
||||
cmd.Parameters.AddWithValue("col", columnName);
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
long count = result is long l ? l : Convert.ToInt64(result, CultureInfo.InvariantCulture);
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Transfer;
|
||||
using Jellyfin.DbMigrator;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Npgsql;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ordered table list (respects FK constraints).
|
||||
// ---------------------------------------------------------------------------
|
||||
string[] tableOrder =
|
||||
[
|
||||
// Group 1 – no FK dependencies
|
||||
"Users",
|
||||
"ApiKeys",
|
||||
"Devices",
|
||||
"DeviceOptions",
|
||||
|
||||
// Group 2 – BaseItems (self-referencing FK only)
|
||||
"BaseItems",
|
||||
|
||||
// Group 3 – children of BaseItems + ItemValues
|
||||
"AncestorIds",
|
||||
"BaseItemImageInfos",
|
||||
"BaseItemMetadataFields",
|
||||
"BaseItemTrailerTypes",
|
||||
"BaseItemProviders",
|
||||
"Chapters",
|
||||
"ItemValues",
|
||||
"ItemValuesMap",
|
||||
"MediaStreamInfos",
|
||||
"AttachmentStreamInfos",
|
||||
"KeyframeData",
|
||||
|
||||
// Group 4 – People
|
||||
"Peoples",
|
||||
"PeopleBaseItemMap",
|
||||
|
||||
// Group 5 – User-related data
|
||||
"UserData",
|
||||
"MediaSegments",
|
||||
"TrickplayInfos",
|
||||
|
||||
// Group 6 – Misc / user preferences
|
||||
"ActivityLogs",
|
||||
"AccessSchedules",
|
||||
"Permissions",
|
||||
"Preferences",
|
||||
"DisplayPreferences",
|
||||
"ItemDisplayPreferences",
|
||||
"CustomItemDisplayPreferences",
|
||||
"ImageInfos",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse command-line arguments.
|
||||
// ---------------------------------------------------------------------------
|
||||
string? sqlitePath = null;
|
||||
string? postgresConnectionString = null;
|
||||
bool isDryRun = false;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--sqlite" when i + 1 < args.Length:
|
||||
sqlitePath = args[++i];
|
||||
break;
|
||||
case "--postgres" when i + 1 < args.Length:
|
||||
postgresConnectionString = args[++i];
|
||||
break;
|
||||
case "--dry-run":
|
||||
isDryRun = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sqlitePath) || string.IsNullOrWhiteSpace(postgresConnectionString))
|
||||
{
|
||||
await Console.Error.WriteLineAsync(
|
||||
"Usage: Jellyfin.DbMigrator --sqlite <path> --postgres <connection-string> [--dry-run]")
|
||||
.ConfigureAwait(false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!File.Exists(sqlitePath))
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"SQLite database not found: {sqlitePath}")
|
||||
.ConfigureAwait(false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (isDryRun)
|
||||
{
|
||||
await Console.Out.WriteLineAsync("[dry-run] No data will be written to PostgreSQL.")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-migration S3 backup.
|
||||
// ---------------------------------------------------------------------------
|
||||
string? s3Bucket = Environment.GetEnvironmentVariable("S3_BACKUP_BUCKET");
|
||||
string? awsRegion = Environment.GetEnvironmentVariable("AWS_DEFAULT_REGION");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(s3Bucket) && !string.IsNullOrWhiteSpace(awsRegion))
|
||||
{
|
||||
await Console.Out.WriteLineAsync($"Uploading {sqlitePath} to s3://{s3Bucket}/ in region {awsRegion}…")
|
||||
.ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await UploadToS3Async(sqlitePath, s3Bucket, awsRegion, isDryRun).ConfigureAwait(false);
|
||||
await Console.Out.WriteLineAsync("S3 backup complete.").ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"S3 backup failed (continuing): {ex.Message}")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Console.Out.WriteLineAsync(
|
||||
"S3_BACKUP_BUCKET or AWS_DEFAULT_REGION not set – skipping pre-migration backup.")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open connections.
|
||||
// ---------------------------------------------------------------------------
|
||||
var sqliteConnectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
}.ToString();
|
||||
|
||||
await using var sqliteConnection = new SqliteConnection(sqliteConnectionString);
|
||||
await sqliteConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
await using var pgConnection = new NpgsqlConnection(postgresConnectionString);
|
||||
await pgConnection.OpenAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrate tables.
|
||||
// ---------------------------------------------------------------------------
|
||||
var reports = new List<TableReport>();
|
||||
bool anyFailure = false;
|
||||
|
||||
foreach (var tableName in tableOrder)
|
||||
{
|
||||
await Console.Out.WriteLineAsync($"Migrating table: {tableName}").ConfigureAwait(false);
|
||||
|
||||
long sqliteCount = 0L;
|
||||
long pgCount = 0L;
|
||||
string? error = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Read from SQLite.
|
||||
sqliteCount = await SqliteTableReader.CountRowsAsync(
|
||||
sqliteConnection, tableName).ConfigureAwait(false);
|
||||
|
||||
if (sqliteCount < 0)
|
||||
{
|
||||
await Console.Out.WriteLineAsync($" Table \"{tableName}\" not found in SQLite – skipping.")
|
||||
.ConfigureAwait(false);
|
||||
reports.Add(new TableReport(tableName, 0L, 0L, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
await Console.Out.WriteLineAsync($" SQLite rows: {sqliteCount}").ConfigureAwait(false);
|
||||
|
||||
var rows = await SqliteTableReader.ReadAllRowsAsync(
|
||||
sqliteConnection, tableName).ConfigureAwait(false);
|
||||
|
||||
// Write to PostgreSQL.
|
||||
long inserted = await PostgresBulkWriter.WriteTableAsync(
|
||||
pgConnection, tableName, rows, isDryRun).ConfigureAwait(false);
|
||||
|
||||
await Console.Out.WriteLineAsync($" Inserted: {inserted}").ConfigureAwait(false);
|
||||
|
||||
// Verify row count in PostgreSQL.
|
||||
pgCount = isDryRun
|
||||
? 0L
|
||||
: await PostgresBulkWriter.CountRowsAsync(pgConnection, tableName).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
anyFailure = true;
|
||||
await Console.Error.WriteLineAsync($" ERROR migrating \"{tableName}\": {ex.Message}")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
reports.Add(new TableReport(tableName, sqliteCount, pgCount, error));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Advance PostgreSQL sequences.
|
||||
// ---------------------------------------------------------------------------
|
||||
await Console.Out.WriteLineAsync("Advancing PostgreSQL sequences…").ConfigureAwait(false);
|
||||
await PostgresBulkWriter.AdvanceSequencesAsync(
|
||||
pgConnection, tableOrder, isDryRun).ConfigureAwait(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Print report.
|
||||
// ---------------------------------------------------------------------------
|
||||
MigrationReport.Print(reports);
|
||||
|
||||
return anyFailure ? 1 : 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local functions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Uploads a file to the configured S3 bucket before migration starts.
|
||||
static async Task UploadToS3Async(
|
||||
string filePath,
|
||||
string bucket,
|
||||
string region,
|
||||
bool isDryRun)
|
||||
{
|
||||
if (isDryRun)
|
||||
{
|
||||
await Console.Out.WriteLineAsync(
|
||||
$" [dry-run] Would upload \"{filePath}\" to s3://{bucket}/{Path.GetFileName(filePath)}")
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var regionEndpoint = RegionEndpoint.GetBySystemName(region);
|
||||
using var s3Client = new AmazonS3Client(regionEndpoint);
|
||||
using var transferUtility = new TransferUtility(s3Client);
|
||||
|
||||
string key = $"jellyfin-db-backups/{Path.GetFileName(filePath)}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.bak";
|
||||
|
||||
await transferUtility.UploadAsync(filePath, bucket, key).ConfigureAwait(false);
|
||||
await Console.Out.WriteLineAsync($" Uploaded to s3://{bucket}/{key}").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Jellyfin.DbMigrator;
|
||||
|
||||
/// <summary>
|
||||
/// Reads rows from a SQLite database table using raw ADO.NET.
|
||||
/// </summary>
|
||||
public static class SqliteTableReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all rows from the specified SQLite table as a list of column-name-to-value dictionaries.
|
||||
/// </summary>
|
||||
/// <param name="connection">An open <see cref="SqliteConnection"/>.</param>
|
||||
/// <param name="tableName">The name of the table to read.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A list where each element is a dictionary mapping column name to its value (may be <see langword="null"/>).</returns>
|
||||
public static async Task<List<IReadOnlyDictionary<string, object?>>> ReadAllRowsAsync(
|
||||
SqliteConnection connection,
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
TableNameValidator.EnsureSafe(tableName);
|
||||
|
||||
var rows = new List<IReadOnlyDictionary<string, object?>>();
|
||||
|
||||
var cmd = connection.CreateCommand();
|
||||
await using (cmd.ConfigureAwait(false))
|
||||
{
|
||||
cmd.CommandText = $"SELECT * FROM \"{tableName}\"";
|
||||
|
||||
var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (reader.ConfigureAwait(false))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var row = new Dictionary<string, object?>(reader.FieldCount, StringComparer.Ordinal);
|
||||
for (int i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
string col = reader.GetName(i);
|
||||
bool isNull = await reader.IsDBNullAsync(i, cancellationToken).ConfigureAwait(false);
|
||||
object? val = isNull ? null : reader.GetValue(i);
|
||||
row[col] = val;
|
||||
}
|
||||
|
||||
rows.Add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the row count for the specified table in the SQLite database.
|
||||
/// </summary>
|
||||
/// <param name="connection">An open <see cref="SqliteConnection"/>.</param>
|
||||
/// <param name="tableName">The name of the table to count.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>The number of rows in the table, or -1 if the table does not exist.</returns>
|
||||
public static async Task<long> CountRowsAsync(
|
||||
SqliteConnection connection,
|
||||
string tableName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
TableNameValidator.EnsureSafe(tableName);
|
||||
|
||||
// Check if the table exists first.
|
||||
var checkCmd = connection.CreateCommand();
|
||||
await using (checkCmd.ConfigureAwait(false))
|
||||
{
|
||||
checkCmd.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name";
|
||||
checkCmd.Parameters.AddWithValue("$name", tableName);
|
||||
var exists = await checkCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (exists is not long existsLong || existsLong == 0)
|
||||
{
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
var cmd = connection.CreateCommand();
|
||||
await using (cmd.ConfigureAwait(false))
|
||||
{
|
||||
cmd.CommandText = $"SELECT COUNT(*) FROM \"{tableName}\"";
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is long count ? count : Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Jellyfin.DbMigrator;
|
||||
|
||||
/// <summary>
|
||||
/// Validates database table names to prevent SQL injection when names are
|
||||
/// interpolated into raw SQL strings.
|
||||
/// </summary>
|
||||
internal static partial class TableNameValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the compiled regular expression that matches safe table names.
|
||||
/// A safe name consists only of ASCII letters, decimal digits, and underscores.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^[A-Za-z0-9_]+$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex SafeNameRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Throws an <see cref="ArgumentException"/> when <paramref name="tableName"/>
|
||||
/// contains characters that are not safe to embed inside a quoted SQL identifier.
|
||||
/// </summary>
|
||||
/// <param name="tableName">The candidate table name.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="tableName"/> contains characters outside
|
||||
/// <c>[A-Za-z0-9_]</c>.
|
||||
/// </exception>
|
||||
public static void EnsureSafe(string tableName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
|
||||
if (!SafeNameRegex().IsMatch(tableName))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Table name '{tableName}' contains characters that are not allowed in a SQL identifier.",
|
||||
nameof(tableName));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user